diff options
| author | srdusr <[email protected]> | 2024-02-14 00:37:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2024-02-14 00:37:00 +0200 |
| commit | 8d15c2e0b326933f8fc912e3b13f37e78a9bc0b6 (patch) | |
| tree | 567274fe13d0a8aea8356e9edeba33ef778cf20f /cmd | |
| parent | f2f0a2135a202e3e15d2a8cbfbd791aad9b04f3a (diff) | |
| download | mitmux-8d15c2e0b326933f8fc912e3b13f37e78a9bc0b6.tar.gz mitmux-8d15c2e0b326933f8fc912e3b13f37e78a9bc0b6.zip | |
History view: SQLite storage, daemon/TUI split over Unix socket
Implements build-order step 3. Adds:
- internal/store: SQLite (WAL, single-writer) history table, raw
request/response blobs plus metadata for the list view.
- internal/proxy: request/response capture wired into forward(). HTTP/1.1
legs are captured byte-exact via a teeConn that records wire bytes as
they're read, taken right after the message is fully drained (so no
manual re-reading/replaying is needed - RoundTrip's own streaming does
the draining). HTTP/2 legs (no meaningful "raw bytes" of their own -
multiplexed, HPACK-compressed framing) are reconstructed instead, and
marked as such in storage.
- internal/ipc: JSON-over-Unix-socket protocol between mitmuxd (owns the
proxy and the DB) and any client - list/get for queries, subscribe for
a live push stream of newly captured entries. Keeps the proxy engine
independent of the UI, per the architecture sketch.
- cmd/mitmux: Bubble Tea TUI - a live-updating history table and a
request/response detail view with raw bytes.
Two real bugs surfaced during testing and got fixed before commit:
1. http.Transport's HTTP/2 auto-dispatch does a literal *tls.Conn type
assertion on the dialed connection; wrapping it in a capturing teeConn
broke that silently, and HTTP/2 framing got parsed as HTTP/1.1 text.
Fixed by dropping http.Transport for the upstream leg entirely in
favor of an explicit per-protocol round trip (see PLAN.md stack note).
2. singleConnListener wrapped the client teeConn *inside* a
closeSignalConn, so ConnContext's type assertion for it silently
failed and HTTP/1.1 client-side capture never activated. Fixed the
wrap order; verified via direct SQLite inspection that request_exact
flips back to 1 and the stored bytes are genuinely wire-exact
(preserved chunked-encoding framing, original header casing/order).
Verified live: plain HTTP, HTTPS H1.1, HTTPS H2, and a POST with a body,
checked against the raw stored bytes directly in SQLite; IPC list/get/
subscribe against a throwaway client; and the TUI driven end-to-end in a
tmux session (list, detail view, tab between request/response, live
update on a new request while sitting on the list).
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/mitmux/main.go | 356 | ||||
| -rw-r--r-- | cmd/mitmuxd/main.go | 52 |
2 files changed, 404 insertions, 4 deletions
diff --git a/cmd/mitmux/main.go b/cmd/mitmux/main.go new file mode 100644 index 0000000..9f07f91 --- /dev/null +++ b/cmd/mitmux/main.go @@ -0,0 +1,356 @@ +// Command mitmux is the terminal client for mitmuxd: a history view over +// the daemon's Unix control socket. The proxy keeps running independently +// of this process. +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "mitmux/internal/ca" + "mitmux/internal/ipc" + "mitmux/internal/store" +) + +func defaultSocketPath() string { + if rt := os.Getenv("XDG_RUNTIME_DIR"); rt != "" { + return filepath.Join(rt, "mitmux.sock") + } + dir, err := ca.Dir() + if err != nil { + return "mitmux.sock" + } + return filepath.Join(dir, "mitmux.sock") +} + +func main() { + socketPath := flag.String("socket", "", "daemon control socket path (default: $XDG_RUNTIME_DIR/mitmux.sock, else <ca-dir>/mitmux.sock)") + flag.Parse() + + path := *socketPath + if path == "" { + path = defaultSocketPath() + } + + client, err := ipc.Dial(path) + if err != nil { + fmt.Fprintf(os.Stderr, "mitmux: connect to daemon at %s: %v\n(is mitmuxd running?)\n", path, err) + os.Exit(1) + } + defer client.Close() + + subCh, subClose, err := ipc.Subscribe(path) + if err != nil { + fmt.Fprintf(os.Stderr, "mitmux: subscribe to daemon at %s: %v\n", path, err) + os.Exit(1) + } + defer subClose() + + m := newModel(client, subCh) + p := tea.NewProgram(m, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "mitmux: %v\n", err) + os.Exit(1) + } +} + +type viewMode int + +const ( + viewList viewMode = iota + viewDetail +) + +type detailTab int + +const ( + tabRequest detailTab = iota + tabResponse +) + +type model struct { + client *ipc.Client + subCh <-chan store.Summary + + mode viewMode + entries []store.Summary + table table.Model + + viewport viewport.Model + detail *ipc.EntryDetail + activeTab detailTab + + statusMsg string + width int + height int + ready bool +} + +func newModel(client *ipc.Client, subCh <-chan store.Summary) *model { + columns := []table.Column{ + {Title: "ID", Width: 5}, + {Title: "Method", Width: 7}, + {Title: "Host", Width: 28}, + {Title: "Path", Width: 32}, + {Title: "Status", Width: 6}, + {Title: "Size", Width: 10}, + {Title: "Time", Width: 8}, + } + t := table.New( + table.WithColumns(columns), + table.WithFocused(true), + ) + st := table.DefaultStyles() + st.Header = st.Header.BorderStyle(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("240")).BorderBottom(true).Bold(true) + st.Selected = st.Selected.Foreground(lipgloss.Color("0")).Background(lipgloss.Color("39")).Bold(true) + t.SetStyles(st) + + return &model{ + client: client, + subCh: subCh, + mode: viewList, + table: t, + } +} + +type listLoadedMsg struct { + entries []store.Summary + err error +} + +type newEntryMsg struct { + entry store.Summary + ok bool +} + +type detailLoadedMsg struct { + detail *ipc.EntryDetail + err error +} + +func (m *model) loadList() tea.Msg { + entries, err := m.client.List(500, 0) + return listLoadedMsg{entries: entries, err: err} +} + +func (m *model) waitForEntry() tea.Msg { + e, ok := <-m.subCh + return newEntryMsg{entry: e, ok: ok} +} + +func (m *model) loadDetail(id int64) tea.Cmd { + return func() tea.Msg { + d, err := m.client.Get(id) + return detailLoadedMsg{detail: d, err: err} + } +} + +func (m *model) Init() tea.Cmd { + return tea.Batch(m.loadList, m.waitForEntry) +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.ready = true + m.table.SetWidth(msg.Width) + m.table.SetHeight(msg.Height - 4) + m.viewport = viewport.New(msg.Width, msg.Height-5) + return m, nil + + case listLoadedMsg: + if msg.err != nil { + m.statusMsg = "list error: " + msg.err.Error() + return m, nil + } + m.entries = msg.entries + m.table.SetRows(rowsFor(m.entries)) + return m, nil + + case newEntryMsg: + if !msg.ok { + m.statusMsg = "live updates disconnected" + return m, nil + } + m.entries = append([]store.Summary{msg.entry}, m.entries...) + m.table.SetRows(rowsFor(m.entries)) + return m, m.waitForEntry + + case detailLoadedMsg: + if msg.err != nil { + m.statusMsg = "get error: " + msg.err.Error() + return m, nil + } + m.detail = msg.detail + m.activeTab = tabRequest + m.viewport.SetContent(detailBody(m.detail, m.activeTab)) + m.viewport.GotoTop() + return m, nil + + case tea.KeyMsg: + switch m.mode { + case viewList: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + case "enter": + if row := m.table.Cursor(); row >= 0 && row < len(m.entries) { + m.mode = viewDetail + m.statusMsg = "" + return m, m.loadDetail(m.entries[row].ID) + } + } + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + return m, cmd + + case viewDetail: + switch msg.String() { + case "q", "esc": + m.mode = viewList + return m, nil + case "ctrl+c": + return m, tea.Quit + case "tab": + if m.activeTab == tabRequest { + m.activeTab = tabResponse + } else { + m.activeTab = tabRequest + } + if m.detail != nil { + m.viewport.SetContent(detailBody(m.detail, m.activeTab)) + m.viewport.GotoTop() + } + return m, nil + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + } + return m, nil +} + +func (m *model) View() string { + if !m.ready { + return "loading..." + } + switch m.mode { + case viewDetail: + return m.detailView() + default: + return m.listView() + } +} + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) + helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + 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) +) + +func (m *model) listView() string { + var b strings.Builder + b.WriteString(titleStyle.Render(fmt.Sprintf(" mitmux - history (%d) ", len(m.entries)))) + 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 · q quit")) + return b.String() +} + +func (m *model) detailView() string { + var b strings.Builder + if m.detail == nil { + b.WriteString("loading...\n") + return b.String() + } + title := fmt.Sprintf(" #%d %s %s%s -> %d ", m.detail.ID, m.detail.Method, m.detail.Host, m.detail.Path, m.detail.StatusCode) + b.WriteString(titleStyle.Render(title)) + b.WriteString("\n") + + reqLabel := fmt.Sprintf("Request (%d bytes%s)", len(m.detail.RequestRaw), exactSuffix(m.detail.RequestExact)) + respLabel := fmt.Sprintf("Response (%d bytes%s)", len(m.detail.ResponseRaw), exactSuffix(m.detail.ResponseExact)) + if m.activeTab == tabRequest { + b.WriteString(tabActive.Render(reqLabel)) + b.WriteString(tabInactive.Render(respLabel)) + } else { + b.WriteString(tabInactive.Render(reqLabel)) + b.WriteString(tabActive.Render(respLabel)) + } + b.WriteString("\n") + b.WriteString(m.viewport.View()) + b.WriteString("\n") + b.WriteString(helpStyle.Render("tab switch · ↑/↓ scroll · esc back · q quit")) + return b.String() +} + +func exactSuffix(exact bool) string { + if exact { + return ", exact" + } + return ", reconstructed" +} + +func detailBody(d *ipc.EntryDetail, tab detailTab) string { + if d == nil { + return "" + } + if tab == tabRequest { + if len(d.RequestRaw) == 0 { + return "(empty)" + } + return string(d.RequestRaw) + } + if len(d.ResponseRaw) == 0 { + if d.Error != "" { + return "(no response - " + d.Error + ")" + } + return "(empty)" + } + return string(d.ResponseRaw) +} + +func rowsFor(entries []store.Summary) []table.Row { + rows := make([]table.Row, len(entries)) + for i, e := range entries { + status := fmt.Sprintf("%d", e.StatusCode) + if e.StatusCode == 0 { + status = "ERR" + } + size := fmt.Sprintf("%s/%s", humanBytes(e.ReqSize), humanBytes(e.RespSize)) + rows[i] = table.Row{ + fmt.Sprintf("%d", e.ID), + e.Method, + e.Host, + e.Path, + status, + size, + e.Duration.Round(time.Millisecond).String(), + } + } + return rows +} + +func humanBytes(n int) string { + if n < 1024 { + return fmt.Sprintf("%dB", n) + } + return fmt.Sprintf("%.1fK", float64(n)/1024) +} diff --git a/cmd/mitmuxd/main.go b/cmd/mitmuxd/main.go index f79f2cf..0d7f214 100644 --- a/cmd/mitmuxd/main.go +++ b/cmd/mitmuxd/main.go @@ -1,24 +1,31 @@ // Command mitmuxd is the mitmux headless proxy daemon. It owns the -// listening socket and (in later build steps) the traffic database; a -// TUI or other client attaches separately without interrupting capture. +// listening socket and the history database; a TUI or other client +// attaches separately, over a Unix control socket, without interrupting +// capture. package main import ( "context" "flag" "log" + "net" "os" "os/signal" + "path/filepath" "syscall" "time" "mitmux/internal/ca" + "mitmux/internal/ipc" "mitmux/internal/proxy" + "mitmux/internal/store" ) func main() { listen := flag.String("listen", "127.0.0.1:8080", "proxy listen address") - caDir := flag.String("ca-dir", "", "directory for CA cert/key (default: XDG config dir)") + caDir := flag.String("ca-dir", "", "directory for CA cert/key and history db (default: XDG config dir)") + dbPath := flag.String("db", "", "path to history database (default: <ca-dir>/history.db)") + socketPath := flag.String("socket", "", "control socket path (default: $XDG_RUNTIME_DIR/mitmux.sock, else <ca-dir>/mitmux.sock)") flag.Parse() dir := *caDir @@ -36,7 +43,43 @@ func main() { } log.Printf("CA ready: %s (install %s/ca.pem in your client's trust store to avoid TLS warnings)", root.Cert.Subject.CommonName, dir) - srv := proxy.New(*listen, root) + dbFile := *dbPath + if dbFile == "" { + dbFile = filepath.Join(dir, "history.db") + } + db, err := store.Open(dbFile) + if err != nil { + log.Fatalf("open history db: %v", err) + } + defer db.Close() + log.Printf("history db: %s", dbFile) + + sockFile := *socketPath + if sockFile == "" { + if rt := os.Getenv("XDG_RUNTIME_DIR"); rt != "" { + sockFile = filepath.Join(rt, "mitmux.sock") + } else { + sockFile = filepath.Join(dir, "mitmux.sock") + } + } + os.Remove(sockFile) // stale socket from an unclean shutdown + sockLn, err := net.Listen("unix", sockFile) + if err != nil { + log.Fatalf("listen on control socket %s: %v", sockFile, err) + } + defer os.Remove(sockFile) + log.Printf("control socket: %s", sockFile) + + hub := ipc.NewHub() + ipcSrv := ipc.NewServer(db, hub) + go func() { + if err := ipcSrv.Serve(sockLn); err != nil { + log.Printf("control socket: %v", err) + } + }() + + srv := proxy.New(*listen, root, db) + srv.OnEntry = ipc.LogAndBroadcast(hub) errCh := make(chan error, 1) go func() { @@ -58,5 +101,6 @@ func main() { if err := srv.Shutdown(ctx); err != nil { log.Fatalf("shutdown: %v", err) } + sockLn.Close() } } |