srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--cmd/mitmux/main.go87
-rw-r--r--internal/ipc/ipc.go17
-rw-r--r--internal/ipc/server.go8
-rw-r--r--internal/store/store.go130
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