srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-09-14 22:16:00 +0200
committersrdusr <[email protected]>2024-09-14 22:16:00 +0200
commita02d612ba5efb5f1e79a2f9a0778890e8c3e6acf (patch)
tree4d1da9141392237bfa4e132c04191e12c85ef992 /internal
parent70f675e59ac4bb0e469ca0073f8cc0506b17677e (diff)
downloadmitmux-a02d612ba5efb5f1e79a2f9a0778890e8c3e6acf.tar.gz
mitmux-a02d612ba5efb5f1e79a2f9a0778890e8c3e6acf.zip
Search/filter: FTS5 index over history
Implements build-order step 5. internal/store gains an FTS5 virtual table (history_fts) kept in sync with every Insert in the same transaction, indexing method/host/path plus the full raw request and response text - so search covers headers and bodies, not just metadata. Store.Search ranks by bm25 relevance. internal/ipc's existing "list" request grows an optional query field rather than a new message type. cmd/mitmux gets an inline '/' filter on the history view (bubbles/ textinput), esc to clear; live entries arriving while a filter is active are held back with a "+N new" indicator rather than guessed at, since FTS match can't be evaluated against a bare Summary. Two real bugs found via testing against the actual sqlite3 CLI, not assumed from docs: 1. This SQLite build doesn't support MATCH/bm25() against an aliased FTS5 table ("no such column") - only the literal table name resolves. Fixed by leaving history_fts unaliased in the JOIN. 2. FTS5's query grammar treats a wide range of punctuation as syntax, not literal characters - confirmed '.', '-', '/', '@', '(', ')' all produce parse errors (or worse, silently different results, as hyphens get misparsed as column-filter syntax) in an unquoted bareword. Since that covers the most common things people search proxy history for (domains, paths, hyphenated headers, IPs), this would have made the feature fail by default for its primary use case. Fixed with prepareFTSQuery: quote every plain token as an FTS5 phrase (syntactically valid regardless of content) while still recognizing AND/OR/NOT and column:value filters. Also caught, mid-testing, that a query fix wasn't taking effect - traced to the daemon still running an old `go run` build from before the fix while only the TUI had been restarted; not a code bug, but a reminder to restart both. Verified live end-to-end: plain-text search matching header/body/JSON content, a previously-failing dotted-domain search now returning exactly the right single match, a hyphen/host:-filter case, boolean-free numeric search, filter-clear returning to the unfiltered list, and the pending- count indicator when new traffic arrives mid-filter.
Diffstat (limited to 'internal')
-rw-r--r--internal/ipc/ipc.go17
-rw-r--r--internal/ipc/server.go8
-rw-r--r--internal/store/store.go130
3 files changed, 150 insertions, 5 deletions
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