diff options
| -rw-r--r-- | cmd/mitmux/main.go | 87 | ||||
| -rw-r--r-- | internal/ipc/ipc.go | 17 | ||||
| -rw-r--r-- | internal/ipc/server.go | 8 | ||||
| -rw-r--r-- | internal/store/store.go | 130 |
4 files changed, 228 insertions, 14 deletions
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go index 6d7fe8c..f47461c 100644 --- a/cmd/mitmux/main.go +++ b/cmd/mitmux/main.go @@ -13,6 +13,7 @@ import ( "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -94,6 +95,11 @@ type model struct { entries []store.Summary table table.Model + searchInput textinput.Model + searching bool // actively typing a query + query string // applied filter, "" means unfiltered + pendingNew int // live entries captured while a filter hides them + viewport viewport.Model detail *ipc.EntryDetail activeTab detailTab @@ -135,12 +141,17 @@ func newModel(client *ipc.Client, subCh <-chan store.Summary) *model { ta.Placeholder = "raw request bytes" ta.ShowLineNumbers = false + si := textinput.New() + si.Prompt = "/" + si.Placeholder = "search - plain text, or host:example.com / AND / OR / NOT" + return &model{ - client: client, - subCh: subCh, - mode: viewList, - table: t, - reqArea: ta, + client: client, + subCh: subCh, + mode: viewList, + table: t, + reqArea: ta, + searchInput: si, } } @@ -166,7 +177,13 @@ type repeatSentMsg struct { } func (m *model) loadList() tea.Msg { - entries, err := m.client.List(500, 0) + var entries []store.Summary + var err error + if m.query != "" { + entries, err = m.client.Search(m.query, 500, 0) + } else { + entries, err = m.client.List(500, 0) + } return listLoadedMsg{entries: entries, err: err} } @@ -220,7 +237,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width, m.height = msg.Width, msg.Height m.ready = true m.table.SetWidth(msg.Width) - m.table.SetHeight(msg.Height - 4) + m.table.SetHeight(msg.Height - 5) + m.searchInput.Width = msg.Width - 2 m.viewport = viewport.New(msg.Width, msg.Height-5) reqHeight := (msg.Height - 6) / 2 @@ -243,6 +261,14 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusMsg = "live updates disconnected" return m, nil } + if m.query != "" { + // Whether it matches the active filter isn't knowable without + // re-querying; rather than guess, just surface a count and let + // the user refresh the search deliberately. + m.pendingNew++ + m.statusMsg = fmt.Sprintf("+%d new captured (re-enter search to refresh)", m.pendingNew) + return m, m.waitForEntry + } m.entries = append([]store.Summary{msg.entry}, m.entries...) m.table.SetRows(rowsFor(m.entries)) return m, m.waitForEntry @@ -277,6 +303,25 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch m.mode { case viewList: + if m.searching { + switch msg.String() { + case "enter": + m.searching = false + m.query = m.searchInput.Value() + m.searchInput.Blur() + m.pendingNew = 0 + m.statusMsg = "" + return m, m.loadList + case "esc": + m.searching = false + m.searchInput.Blur() + return m, nil + } + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + return m, cmd + } + switch msg.String() { case "q", "ctrl+c": return m, tea.Quit @@ -291,6 +336,18 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.statusMsg = "" return m, m.loadDetail(m.entries[row].ID, true) } + case "/": + m.searching = true + m.searchInput.SetValue(m.query) + m.searchInput.CursorEnd() + return m, m.searchInput.Focus() + case "esc": + if m.query != "" { + m.query = "" + m.pendingNew = 0 + m.statusMsg = "" + return m, m.loadList + } } var cmd tea.Cmd m.table, cmd = m.table.Update(msg) @@ -385,15 +442,27 @@ var ( func (m *model) listView() string { var b strings.Builder - b.WriteString(titleStyle.Render(fmt.Sprintf(" mitmux - history (%d) ", len(m.entries)))) + title := fmt.Sprintf(" mitmux - history (%d) ", len(m.entries)) + if m.query != "" && !m.searching { + title = fmt.Sprintf(" mitmux - history (%d matches for %q) ", len(m.entries), m.query) + } + b.WriteString(titleStyle.Render(title)) b.WriteString("\n") + if m.searching { + b.WriteString(m.searchInput.View()) + b.WriteString("\n") + } b.WriteString(m.table.View()) b.WriteString("\n") if m.statusMsg != "" { b.WriteString(statusStyle.Render(m.statusMsg)) b.WriteString("\n") } - b.WriteString(helpStyle.Render("↑/↓ navigate · enter view · r repeater · q quit")) + help := "↑/↓ navigate · enter view · r repeater · / search · q quit" + if m.query != "" { + help = "↑/↓ navigate · enter view · r repeater · / search · esc clear filter · q quit" + } + b.WriteString(helpStyle.Render(help)) return b.String() } diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index b1a997a..691dadf 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -20,6 +20,11 @@ type Request struct { BeforeID int64 `json:"before_id,omitempty"` ID int64 `json:"id,omitempty"` + // For "list": a non-empty Query switches from most-recent-first to + // an FTS5 search (see store.Store.Search for syntax), ranked by + // relevance. + Query string `json:"query,omitempty"` + // For "repeat": send Raw to scheme://host exactly as given. Scheme string `json:"scheme,omitempty"` Host string `json:"host,omitempty"` @@ -69,7 +74,17 @@ func (c *Client) Close() error { // 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) { - if err := c.enc.Encode(Request{Type: "list", Limit: limit, BeforeID: beforeID}); err != nil { + return c.list(Request{Type: "list", Limit: limit, BeforeID: beforeID}) +} + +// Search returns up to limit history summaries matching an FTS5 query +// (see store.Store.Search for syntax), ranked by relevance. +func (c *Client) Search(query string, limit int, beforeID int64) ([]store.Summary, error) { + return c.list(Request{Type: "list", Query: query, Limit: limit, BeforeID: beforeID}) +} + +func (c *Client) list(req Request) ([]store.Summary, error) { + if err := c.enc.Encode(req); err != nil { return nil, err } var resp Response diff --git a/internal/ipc/server.go b/internal/ipc/server.go index e3b2a29..0e839fb 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -93,7 +93,13 @@ func (s *Server) handleConn(conn net.Conn) { switch req.Type { case "list": - entries, err := s.db.List(req.Limit, req.BeforeID) + var entries []store.Summary + var err error + if req.Query != "" { + entries, err = s.db.Search(req.Query, req.Limit, req.BeforeID) + } else { + entries, err = s.db.List(req.Limit, req.BeforeID) + } if err != nil { enc.Encode(Response{Type: "error", Error: err.Error()}) continue diff --git a/internal/store/store.go b/internal/store/store.go index 88c9991..a3c6b03 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,11 +1,14 @@ // Package store persists proxy history to SQLite in WAL mode. Request // and response bytes are stored as-received where possible (see the // Exact fields) rather than re-serialized from a parsed representation. +// An FTS5 index (history_fts) mirrors method/host/path and the raw +// request/response text for full-text search - see Search. package store import ( "database/sql" "fmt" + "strings" "time" _ "modernc.org/sqlite" @@ -28,6 +31,11 @@ CREATE TABLE IF NOT EXISTS history ( error TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'proxy' ); + +CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5( + method, host, path, request_text, response_text, + tokenize = 'unicode61 remove_diacritics 2' +); ` // Store is a handle to the history database. Safe for concurrent use. @@ -63,6 +71,16 @@ func Open(path string) (*Store, error) { // Added after the initial schema; ignore the "duplicate column" error // on databases that already have it. db.Exec("ALTER TABLE history ADD COLUMN source TEXT NOT NULL DEFAULT 'proxy'") + // Backfill history_fts for rows inserted before it existed. A no-op + // once caught up, since every Insert keeps both tables in sync. + if _, err := db.Exec(` + INSERT INTO history_fts (rowid, method, host, path, request_text, response_text) + SELECT id, method, host, path, CAST(request_raw AS TEXT), COALESCE(CAST(response_raw AS TEXT), '') + FROM history WHERE id NOT IN (SELECT rowid FROM history_fts) + `); err != nil { + db.Close() + return nil, fmt.Errorf("backfill search index: %w", err) + } return &Store{db: db}, nil } @@ -106,7 +124,7 @@ type Summary struct { Source string } -// Insert stores e and returns its assigned ID. +// Insert stores e (and indexes it for search) and returns its assigned ID. func (s *Store) Insert(e *Entry) (int64, error) { var statusCode any if e.StatusCode != 0 { @@ -116,7 +134,14 @@ func (s *Store) Insert(e *Entry) (int64, error) { if source == "" { source = "proxy" } - res, err := s.db.Exec( + + tx, err := s.db.Begin() + if err != nil { + return 0, fmt.Errorf("begin insert: %w", err) + } + defer tx.Rollback() + + res, err := tx.Exec( `INSERT INTO history (started_at, duration_ms, method, scheme, host, path, status_code, request_raw, response_raw, request_exact, response_exact, error, source) @@ -127,7 +152,23 @@ func (s *Store) Insert(e *Entry) (int64, error) { if err != nil { return 0, fmt.Errorf("insert history entry: %w", err) } - return res.LastInsertId() + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("insert history entry: %w", err) + } + + if _, err := tx.Exec( + `INSERT INTO history_fts (rowid, method, host, path, request_text, response_text) + VALUES (?, ?, ?, ?, ?, ?)`, + id, e.Method, e.Host, e.Path, string(e.RequestRaw), string(e.ResponseRaw), + ); err != nil { + return 0, fmt.Errorf("index history entry: %w", err) + } + + if err := tx.Commit(); err != nil { + return 0, fmt.Errorf("commit insert: %w", err) + } + return id, nil } // List returns up to limit history summaries older than beforeID (or the @@ -165,6 +206,57 @@ func (s *Store) List(limit int, beforeID int64) ([]Summary, error) { return out, rows.Err() } +// Search returns up to limit history summaries older than beforeID (or +// the most recent if beforeID is 0) matching query, newest first among +// equally-ranked matches, best-relevance first otherwise. query is +// mostly plain text - "example.com", "x-forwarded-for", "192.168.1.1" +// all just work - plus a little FTS5 syntax for power users: AND/OR/NOT, +// and column filters like host:example.com. See prepareFTSQuery for why +// plain terms need help: FTS5's query grammar treats characters like +// '.', '-', '/', '@' as syntax, so an unquoted domain name is a parse +// error, not a search term, unless quoted first. +func (s *Store) Search(query string, limit int, beforeID int64) ([]Summary, error) { + if limit <= 0 || limit > 1000 { + limit = 200 + } + if beforeID <= 0 { + beforeID = 1<<63 - 1 + } + query = prepareFTSQuery(query) + // history_fts must appear unaliased for MATCH/bm25 to resolve against + // it - this SQLite build doesn't support querying FTS5 through a + // table alias (confirmed directly against the sqlite3 CLI: aliasing + // it raises "no such column"). + rows, err := s.db.Query( + `SELECT h.id, h.started_at, h.duration_ms, h.method, h.scheme, h.host, h.path, + COALESCE(h.status_code, 0), length(h.request_raw), COALESCE(length(h.response_raw), 0), + h.error, h.source + FROM history_fts + JOIN history h ON h.id = history_fts.rowid + WHERE history_fts MATCH ? AND h.id < ? + ORDER BY bm25(history_fts), h.id DESC LIMIT ?`, + query, beforeID, limit, + ) + if err != nil { + return nil, fmt.Errorf("search history: %w", err) + } + defer rows.Close() + + var out []Summary + for rows.Next() { + var sum Summary + var startedAt, durationMs int64 + if err := rows.Scan(&sum.ID, &startedAt, &durationMs, &sum.Method, &sum.Scheme, &sum.Host, &sum.Path, + &sum.StatusCode, &sum.ReqSize, &sum.RespSize, &sum.Error, &sum.Source); err != nil { + return nil, fmt.Errorf("scan search row: %w", err) + } + sum.StartedAt = time.UnixMilli(startedAt) + sum.Duration = time.Duration(durationMs) * time.Millisecond + out = append(out, sum) + } + return out, rows.Err() +} + // Get returns the full entry (including raw bytes) for id. func (s *Store) Get(id int64) (*Entry, error) { row := s.db.QueryRow( @@ -188,6 +280,38 @@ func (s *Store) Get(id int64) (*Entry, error) { return &e, nil } +// prepareFTSQuery makes free text safe to hand to FTS5's MATCH, whose +// query grammar reserves a wide range of punctuation ('.', '-', '/', +// '@', '(', ')', and more - confirmed empirically, not just from docs) +// as syntax. A bareword containing any of it is a parse error, not a +// literal search term, which would make searching for the most common +// things in HTTP traffic (domains, paths, hyphenated headers, IPs) fail +// by default. So: quote every plain token as an FTS5 phrase, which is +// syntactically valid regardless of its contents, while still +// recognizing AND/OR/NOT and column:value filters for anyone using them +// deliberately. +// +// Known limitation: this splits on whitespace, so a query the user +// already wrote as a multi-word "quoted phrase" gets re-split and each +// word individually re-quoted (still a valid query, just no longer an +// exact-adjacency phrase match). Fine for the common case of typing +// plain, unquoted search terms, which is what this exists for. +func prepareFTSQuery(q string) string { + fields := strings.Fields(q) + for i, f := range fields { + switch strings.ToUpper(f) { + case "AND", "OR", "NOT": + continue + } + if col, val, ok := strings.Cut(f, ":"); ok && col != "" && val != "" { + fields[i] = col + `:"` + strings.ReplaceAll(val, `"`, `""`) + `"` + continue + } + fields[i] = `"` + strings.ReplaceAll(f, `"`, `""`) + `"` + } + return strings.Join(fields, " ") +} + func boolToInt(b bool) int { if b { return 1 |