diff options
| -rw-r--r-- | cmd/mitmux/main.go | 172 | ||||
| -rw-r--r-- | cmd/mitmuxd/main.go | 2 | ||||
| -rw-r--r-- | internal/ipc/ipc.go | 26 | ||||
| -rw-r--r-- | internal/ipc/server.go | 22 | ||||
| -rw-r--r-- | internal/store/store.go | 9 |
5 files changed, 209 insertions, 22 deletions
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index d9e1685..55ab263 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -73,6 +73,7 @@ const ( viewRepeater viewRules viewIntruder + viewHelp ) type detailTab int @@ -158,6 +159,9 @@ type model struct { intruderCh <-chan ipc.IntrudeResultMsg intruderClose func() error + daemonStatus *ipc.StatusMsg + prevMode viewMode // for the ? help screen's "esc back" target + statusMsg string width int height int @@ -271,6 +275,16 @@ type repeatSentMsg struct { err error } +type statusLoadedMsg struct { + status *ipc.StatusMsg + err error +} + +func (m *model) loadStatus() tea.Msg { + s, err := m.client.Status() + return statusLoadedMsg{status: s, err: err} +} + func (m *model) loadList() tea.Msg { var entries []store.Summary var err error @@ -474,7 +488,7 @@ func (m *model) focusRuleField() { } func (m *model) Init() tea.Cmd { - return tea.Batch(m.loadList, m.waitForEntry) + return tea.Batch(m.loadList, m.waitForEntry, m.loadStatus) } func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -482,31 +496,35 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.ready = true + // Reserve one line for the persistent status bar (see statusBar, + // appended in View); every height below is against h, not the + // raw terminal height, to leave room for it. + h := msg.Height - 1 m.table.SetWidth(msg.Width) - m.table.SetHeight(msg.Height - 5) + m.table.SetHeight(h - 5) m.searchInput.Width = msg.Width - 2 - m.viewport = viewport.New(msg.Width, msg.Height-5) + m.viewport = viewport.New(msg.Width, h-5) - reqHeight := (msg.Height - 6) / 2 + reqHeight := (h - 6) / 2 m.reqArea.SetWidth(msg.Width) m.reqArea.SetHeight(reqHeight) - m.respView = viewport.New(msg.Width, msg.Height-6-reqHeight) + m.respView = viewport.New(msg.Width, h-6-reqHeight) m.rulesTable.SetWidth(msg.Width) - m.rulesTable.SetHeight(msg.Height - 5) + m.rulesTable.SetHeight(h - 5) formWidth := msg.Width - 12 m.ruleName.Width = formWidth m.ruleMatch.Width = formWidth m.ruleReplace.Width = formWidth - itmplHeight := (msg.Height - 8) / 3 + itmplHeight := (h - 8) / 3 ipayloadsHeight := itmplHeight m.intruderTemplate.SetWidth(msg.Width) m.intruderTemplate.SetHeight(itmplHeight) m.intruderPayloads.SetWidth(msg.Width) m.intruderPayloads.SetHeight(ipayloadsHeight) m.intruderResults.SetWidth(msg.Width) - m.intruderResults.SetHeight(msg.Height - 8 - itmplHeight - ipayloadsHeight) + m.intruderResults.SetHeight(h - 8 - itmplHeight - ipayloadsHeight) return m, nil case listLoadedMsg: @@ -523,6 +541,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusMsg = "live updates disconnected" return m, nil } + if m.daemonStatus != nil { + m.daemonStatus.HistoryCount++ + } if m.query != "" { // Whether it matches the active filter isn't knowable without // re-querying; rather than guess, just surface a count and let @@ -535,6 +556,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.table.SetRows(rowsFor(m.entries)) return m, m.waitForEntry + case statusLoadedMsg: + if msg.err == nil { + m.daemonStatus = msg.status + } + return m, nil + case detailLoadedMsg: if msg.err != nil { m.statusMsg = "get error: " + msg.err.Error() @@ -633,6 +660,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "ctrl+c": return m, tea.Quit + case "?": + m.prevMode = viewList + m.mode = viewHelp + return m, nil case "enter": if row := m.table.Cursor(); row >= 0 && row < len(m.entries) { m.mode = viewDetail @@ -677,6 +708,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "ctrl+c": return m, tea.Quit + case "?": + m.prevMode = viewDetail + m.mode = viewHelp + return m, nil case "r": if m.detail != nil { m.enterRepeater(m.detail) @@ -718,6 +753,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "ctrl+c": return m, tea.Quit + case "?": + if m.repeaterFocus != focusRequest || m.reqArea.Mode() != viInsert { + m.prevMode = viewRepeater + m.mode = viewHelp + return m, nil + } case "ctrl+r": if !m.sending { m.sending = true @@ -795,6 +836,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "ctrl+c": return m, tea.Quit + case "?": + m.prevMode = viewRules + m.mode = viewHelp + return m, nil case "a": m.enterRuleForm(nil) return m, nil @@ -835,6 +880,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "ctrl+c": return m, tea.Quit + case "?": + if !editing { + m.prevMode = viewIntruder + m.mode = viewHelp + return m, nil + } case "ctrl+r": if !m.intruderRunning { m.statusMsg = "starting attack..." @@ -881,6 +932,15 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.intruderResults, cmd = m.intruderResults.Update(msg) } return m, cmd + + case viewHelp: + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + default: + m.mode = m.prevMode + return m, nil + } } } return m, nil @@ -890,21 +950,103 @@ func (m *model) View() string { if !m.ready { return "loading..." } + if m.mode == viewHelp { + return m.helpView() + } + + var body string switch m.mode { case viewDetail: - return m.detailView() + body = m.detailView() case viewRepeater: - return m.repeaterView() + body = m.repeaterView() case viewRules: if m.ruleForm { - return m.ruleFormView() + body = m.ruleFormView() + } else { + body = m.rulesView() } - return m.rulesView() case viewIntruder: - return m.intruderView() + body = m.intruderView() default: - return m.listView() + body = m.listView() + } + return body + "\n" + m.statusBar() +} + +// statusBar is a persistent one-line footer: daemon connection info and +// the current view, so it's always visible regardless of which screen +// is showing (see the WindowSizeMsg handler, which reserves a line for +// it in every height calculation). +func (m *model) statusBar() string { + proxy := "connecting..." + count := "" + if m.daemonStatus != nil { + proxy = m.daemonStatus.ProxyAddr + count = fmt.Sprintf(" · %d requests", m.daemonStatus.HistoryCount) } + view := map[viewMode]string{ + viewList: "history", viewDetail: "detail", viewRepeater: "repeater", + viewRules: "rules", viewIntruder: "intruder", + }[m.mode] + return statusBarStyle.Render(fmt.Sprintf(" mitmux · proxy %s%s · %s ", proxy, count, view)) +} + +func (m *model) helpView() string { + var b strings.Builder + b.WriteString(titleStyle.Render(" mitmux - keybindings ")) + b.WriteString("\n\n") + + section := func(title string, lines ...string) { + b.WriteString(tabActive.Render(" " + title + " ")) + b.WriteString("\n") + for _, l := range lines { + b.WriteString(" " + l + "\n") + } + b.WriteString("\n") + } + + section("Global", + "? this screen (any key returns)", + "ctrl+c quit", + ) + section("History list", + "↑/↓ or j/k navigate (also g/G top/bottom, ctrl+u/d half-page)", + "enter view request/response detail", + "r open in Repeater", + "i open in Intruder", + "/ search (plain text, host:value, AND/OR/NOT)", + "esc clear active search filter", + "m match-and-replace rules", + "q quit", + ) + section("Detail view", + "tab switch request/response", + "↑/↓ or j/k scroll (also g/G, ctrl+u/d - same as history list)", + "r / i open in Repeater / Intruder", + "esc / q back to history", + ) + section("Repeater / Intruder editors - vi-modal", + "Starts in NORMAL mode (not insert) - press i to type, esc to stop.", + "h j k l left/down/up/right 0 / $ line start/end", + "w / b word forward/back x delete character", + "i a I A insert (before/after/line-start/line-end)", + "o / O open line below/above and insert", + "dd / yy delete/yank line p / P paste below/above", + "dw d$ d0 delete word/to-end/to-start-of-line", + "gg / G top/bottom of buffer esc back to normal mode", + "ctrl+r send / start attack tab switch pane", + "ctrl+g (Intruder template only) insert a § marker at cursor", + ) + section("Rules", + "a add rule enter / e edit selected", + "d delete selected space toggle enabled", + "tab/shift+tab move between form fields ctrl+s save form", + "◀▶ change scope/regex toggle esc cancel/back", + ) + + b.WriteString(helpStyle.Render("press any key to go back")) + return b.String() } var ( @@ -913,6 +1055,8 @@ var ( statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("208")) tabActive = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("0")).Background(lipgloss.Color("39")).Padding(0, 1) tabInactive = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Padding(0, 1) + + statusBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("240")) ) func (m *model) listView() string { diff --git a/cmd/mitmuxd/main.go b/cmd/mitmuxd/main.go index 812e146..9db31e5 100644 --- a/cmd/mitmuxd/main.go +++ b/cmd/mitmuxd/main.go @@ -74,7 +74,7 @@ func main() { hub := ipc.NewHub() srv.OnEntry = ipc.LogAndBroadcast(hub) - ipcSrv := ipc.NewServer(db, hub, srv) + ipcSrv := ipc.NewServer(db, hub, srv, *listen) go func() { if err := ipcSrv.Serve(sockLn); err != nil { log.Printf("control socket: %v", err) diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 03a4bc7..d295238 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -48,11 +48,12 @@ type Request struct { // Response is sent by the daemon to a client. type Response struct { - Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "intrude_result", "intrude_done", or "error" + Type string `json:"type"` // "list", "get", "new", "repeat", "rules", "intrude_result", "intrude_done", "status", or "error" Entries []store.Summary `json:"entries,omitempty"` // for "list" Detail *EntryDetail `json:"detail,omitempty"` // for "get" and "repeat" New *store.Summary `json:"new,omitempty"` // for "new" (subscribe push) Rules []rules.Rule `json:"rules,omitempty"` // for "rules" + Status *StatusMsg `json:"status,omitempty"` // for "status" // For "intrude_result": one completed attack request. IntrudeResult *IntrudeResultMsg `json:"intrude_result,omitempty"` @@ -60,6 +61,12 @@ type Response struct { Error string `json:"error,omitempty"` } +// StatusMsg is basic daemon info for a TUI status bar. +type StatusMsg struct { + ProxyAddr string `json:"proxy_addr"` + HistoryCount int64 `json:"history_count"` +} + // IntrudeResultMsg is one completed Intruder attack request. type IntrudeResultMsg struct { Position int `json:"position"` @@ -110,6 +117,23 @@ func (c *Client) Close() error { return c.conn.Close() } +// Status returns basic daemon info for a status bar. +func (c *Client) Status() (*StatusMsg, error) { + c.mu.Lock() + defer c.mu.Unlock() + if err := c.enc.Encode(Request{Type: "status"}); err != nil { + return nil, err + } + var resp Response + if err := c.dec.Decode(&resp); err != nil { + return nil, err + } + if resp.Type == "error" { + return nil, errors.New(resp.Error) + } + return resp.Status, nil +} + // List returns up to limit history summaries older than beforeID (0 for // the most recent), newest first. func (c *Client) List(limit int, beforeID int64) ([]store.Summary, error) { diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 50dff7f..eea0ebb 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -66,16 +66,18 @@ func (h *Hub) unsubscribe(ch chan store.Summary) { // Server serves the daemon side of the mitmux control protocol. type Server struct { - db *store.Store - hub *Hub - repeater Repeater - intruder Intruder + db *store.Store + hub *Hub + repeater Repeater + intruder Intruder + proxyAddr string } // NewServer creates a control-protocol Server backed by db, broadcasting // through hub and sending Repeater/Intruder requests through rep. -func NewServer(db *store.Store, hub *Hub, rep Repeater) *Server { - s := &Server{db: db, hub: hub, repeater: rep} +// proxyAddr is reported back to clients via the "status" request. +func NewServer(db *store.Store, hub *Hub, rep Repeater, proxyAddr string) *Server { + s := &Server{db: db, hub: hub, repeater: rep, proxyAddr: proxyAddr} if in, ok := rep.(Intruder); ok { s.intruder = in } @@ -106,6 +108,14 @@ func (s *Server) handleConn(conn net.Conn) { } switch req.Type { + case "status": + count, err := s.db.Count() + if err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + enc.Encode(Response{Type: "status", Status: &StatusMsg{ProxyAddr: s.proxyAddr, HistoryCount: count}}) + case "list": var entries []store.Summary var err error diff --git a/internal/store/store.go b/internal/store/store.go index 211162e..eb017db 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -185,6 +185,15 @@ func (s *Store) Insert(e *Entry) (int64, error) { return id, nil } +// Count returns the total number of history entries. +func (s *Store) Count() (int64, error) { + var n int64 + if err := s.db.QueryRow(`SELECT count(*) FROM history`).Scan(&n); err != nil { + return 0, fmt.Errorf("count history: %w", err) + } + return n, nil +} + // List returns up to limit history summaries older than beforeID (or the // most recent if beforeID is 0), newest first. func (s *Store) List(limit int, beforeID int64) ([]Summary, error) { |