srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2024-02-14 00:37:00 +0200
committersrdusr <[email protected]>2024-02-14 00:37:00 +0200
commit8d15c2e0b326933f8fc912e3b13f37e78a9bc0b6 (patch)
tree567274fe13d0a8aea8356e9edeba33ef778cf20f /cmd
parentf2f0a2135a202e3e15d2a8cbfbd791aad9b04f3a (diff)
downloadmitmux-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.go356
-rw-r--r--cmd/mitmuxd/main.go52
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()
}
}