diff options
| -rw-r--r-- | PLAN.md | 7 | ||||
| -rw-r--r-- | cmd/mitmux/main.go | 356 | ||||
| -rw-r--r-- | cmd/mitmuxd/main.go | 52 | ||||
| -rw-r--r-- | go.mod | 35 | ||||
| -rw-r--r-- | go.sum | 97 | ||||
| -rw-r--r-- | internal/ipc/ipc.go | 126 | ||||
| -rw-r--r-- | internal/ipc/server.go | 136 | ||||
| -rw-r--r-- | internal/proxy/capture.go | 83 | ||||
| -rw-r--r-- | internal/proxy/proxy.go | 269 | ||||
| -rw-r--r-- | internal/proxy/tee.go | 92 | ||||
| -rw-r--r-- | internal/store/store.go | 186 |
11 files changed, 1362 insertions, 77 deletions
@@ -12,7 +12,12 @@ hudsucker) - same problem, worth studying even though this build is Go. (analogous to `rcgen`) for per-domain leaf certs - Proxy core: `net/http` + manual `CONNECT` handling, or a MITM proxy library if one fits without fighting Go's aggressive header - normalization + normalization. Upstream requests are round-tripped manually (write + the request, read the response off the same connection) rather than + through `http.Transport` - Transport's automatic HTTP/2 dispatch keys + off a literal `*tls.Conn` type assertion on the dialed connection, + which a raw-byte-capturing wrapper around that connection defeats + (found by testing: it silently parsed HTTP/2 framing as HTTP/1.1). - Storage: SQLite in WAL mode - blob columns for raw request/response bytes, FTS5 index for search across bodies - UI: Bubble Tea + Lipgloss (TUI), same family as the packet analyzer's 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() } } @@ -3,6 +3,39 @@ module mitmux go 1.26.5 require ( - golang.org/x/net v0.58.0 // indirect + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + golang.org/x/net v0.58.0 + modernc.org/sqlite v1.56.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) @@ -1,4 +1,101 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go new file mode 100644 index 0000000..c9d92b4 --- /dev/null +++ b/internal/ipc/ipc.go @@ -0,0 +1,126 @@ +// Package ipc is the protocol between mitmuxd (which owns the proxy and +// the history database) and a client such as the TUI, spoken as +// newline-agnostic JSON messages over a Unix domain socket. This keeps +// the proxy engine running independently of any UI attached to it. +package ipc + +import ( + "encoding/json" + "errors" + "fmt" + "net" + + "mitmux/internal/store" +) + +// Request is sent by a client to the daemon. +type Request struct { + Type string `json:"type"` // "list", "get", or "subscribe" + Limit int `json:"limit,omitempty"` + BeforeID int64 `json:"before_id,omitempty"` + ID int64 `json:"id,omitempty"` +} + +// Response is sent by the daemon to a client. +type Response struct { + Type string `json:"type"` // "list", "get", "new", or "error" + Entries []store.Summary `json:"entries,omitempty"` // for "list" + Detail *EntryDetail `json:"detail,omitempty"` // for "get" + New *store.Summary `json:"new,omitempty"` // for "new" (subscribe push) + Error string `json:"error,omitempty"` +} + +// EntryDetail is a full history entry, raw bytes included. +type EntryDetail struct { + store.Summary + RequestRaw []byte `json:"request_raw"` + ResponseRaw []byte `json:"response_raw"` + RequestExact bool `json:"request_exact"` + ResponseExact bool `json:"response_exact"` +} + +// Client talks to a mitmuxd instance for request/response queries +// (list, get). Use Subscribe separately for the live-update stream. +type Client struct { + conn net.Conn + dec *json.Decoder + enc *json.Encoder +} + +// Dial connects to the daemon's control socket at path. +func Dial(path string) (*Client, error) { + conn, err := net.Dial("unix", path) + if err != nil { + return nil, fmt.Errorf("dial %s: %w", path, err) + } + return &Client{conn: conn, dec: json.NewDecoder(conn), enc: json.NewEncoder(conn)}, nil +} + +// Close closes the connection to the daemon. +func (c *Client) Close() error { + return c.conn.Close() +} + +// 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 nil, err + } + var resp Response + if err := c.dec.Decode(&resp); err != nil { + return nil, err + } + if resp.Type == "error" { + return nil, errors.New(resp.Error) + } + return resp.Entries, nil +} + +// Get returns the full entry (raw bytes included) for id. +func (c *Client) Get(id int64) (*EntryDetail, error) { + if err := c.enc.Encode(Request{Type: "get", ID: id}); err != nil { + return nil, err + } + var resp Response + if err := c.dec.Decode(&resp); err != nil { + return nil, err + } + if resp.Type == "error" { + return nil, errors.New(resp.Error) + } + return resp.Detail, nil +} + +// Subscribe opens a dedicated connection that streams newly captured +// history entries as they happen. The returned channel is closed when +// the connection ends; call the returned close func to stop early. +func Subscribe(path string) (<-chan store.Summary, func() error, error) { + conn, err := net.Dial("unix", path) + if err != nil { + return nil, nil, fmt.Errorf("dial %s: %w", path, err) + } + if err := json.NewEncoder(conn).Encode(Request{Type: "subscribe"}); err != nil { + conn.Close() + return nil, nil, err + } + + ch := make(chan store.Summary, 64) + go func() { + defer close(ch) + dec := json.NewDecoder(conn) + for { + var resp Response + if err := dec.Decode(&resp); err != nil { + return + } + if resp.Type == "new" && resp.New != nil { + select { + case ch <- *resp.New: + default: + } + } + } + }() + return ch, conn.Close, nil +} diff --git a/internal/ipc/server.go b/internal/ipc/server.go new file mode 100644 index 0000000..11ba033 --- /dev/null +++ b/internal/ipc/server.go @@ -0,0 +1,136 @@ +package ipc + +import ( + "encoding/json" + "log" + "net" + "sync" + + "mitmux/internal/store" +) + +// Hub fans out newly captured history entries to subscribed clients. +type Hub struct { + mu sync.Mutex + subs map[chan store.Summary]struct{} +} + +// NewHub creates an empty Hub. +func NewHub() *Hub { + return &Hub{subs: make(map[chan store.Summary]struct{})} +} + +// Broadcast notifies all current subscribers of e. Slow subscribers +// drop entries rather than blocking the proxy. +func (h *Hub) Broadcast(e store.Summary) { + h.mu.Lock() + defer h.mu.Unlock() + for ch := range h.subs { + select { + case ch <- e: + default: + } + } +} + +func (h *Hub) subscribe() chan store.Summary { + ch := make(chan store.Summary, 64) + h.mu.Lock() + h.subs[ch] = struct{}{} + h.mu.Unlock() + return ch +} + +func (h *Hub) unsubscribe(ch chan store.Summary) { + h.mu.Lock() + delete(h.subs, ch) + h.mu.Unlock() + close(ch) +} + +// Server serves the daemon side of the mitmux control protocol. +type Server struct { + db *store.Store + hub *Hub +} + +// NewServer creates a control-protocol Server backed by db, broadcasting +// through hub. +func NewServer(db *store.Store, hub *Hub) *Server { + return &Server{db: db, hub: hub} +} + +// Serve accepts connections on ln until it returns an error (e.g. the +// listener is closed). +func (s *Server) Serve(ln net.Listener) error { + for { + conn, err := ln.Accept() + if err != nil { + return err + } + go s.handleConn(conn) + } +} + +func (s *Server) handleConn(conn net.Conn) { + defer conn.Close() + dec := json.NewDecoder(conn) + enc := json.NewEncoder(conn) + + for { + var req Request + if err := dec.Decode(&req); err != nil { + return + } + + switch req.Type { + case "list": + entries, err := s.db.List(req.Limit, req.BeforeID) + if err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + enc.Encode(Response{Type: "list", Entries: entries}) + + case "get": + e, err := s.db.Get(req.ID) + if err != nil { + enc.Encode(Response{Type: "error", Error: err.Error()}) + continue + } + enc.Encode(Response{Type: "get", Detail: &EntryDetail{ + Summary: store.Summary{ + ID: e.ID, StartedAt: e.StartedAt, Duration: e.Duration, + Method: e.Method, Scheme: e.Scheme, Host: e.Host, Path: e.Path, + StatusCode: e.StatusCode, ReqSize: len(e.RequestRaw), RespSize: len(e.ResponseRaw), + Error: e.Error, + }, + RequestRaw: e.RequestRaw, ResponseRaw: e.ResponseRaw, + RequestExact: e.RequestExact, ResponseExact: e.ResponseExact, + }}) + + case "subscribe": + sub := s.hub.subscribe() + defer s.hub.unsubscribe(sub) + for e := range sub { + e := e + if err := enc.Encode(Response{Type: "new", New: &e}); err != nil { + return + } + } + return + + default: + enc.Encode(Response{Type: "error", Error: "unknown request type: " + req.Type}) + } + } +} + +// LogAndBroadcast is a convenience OnEntry callback: logs the entry and +// broadcasts it through hub. +func LogAndBroadcast(hub *Hub) func(store.Summary) { + return func(sum store.Summary) { + log.Printf("%s %s%s -> %d (%s)", sum.Method, sum.Host, sum.Path, sum.StatusCode, sum.Duration) + hub.Broadcast(sum) + } +} diff --git a/internal/proxy/capture.go b/internal/proxy/capture.go new file mode 100644 index 0000000..ccc95e9 --- /dev/null +++ b/internal/proxy/capture.go @@ -0,0 +1,83 @@ +package proxy + +import ( + "bytes" + "io" + "net/http" +) + +// cappedTee wraps an io.Reader, copying up to maxCaptureBytes of what +// passes through into an internal buffer while still passing everything +// through unmodified and unbounded to the real reader. Used to capture a +// bounded sample of a body for reconstruction when exact wire capture +// isn't available (the HTTP/2 leg - see below). +type cappedTee struct { + r io.Reader + buf bytes.Buffer +} + +func newCappedTee(r io.Reader) *cappedTee { + return &cappedTee{r: r} +} + +func (c *cappedTee) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + if n > 0 { + if room := maxCaptureBytes - c.buf.Len(); room > 0 { + end := n + if end > room { + end = room + } + c.buf.Write(p[:end]) + } + } + return n, err +} + +// captureRequest returns the raw bytes of r for storage. When tee is +// non-nil (an HTTP/1.1 client connection), the bytes are exactly what +// was read off the wire. Otherwise (HTTP/2, which has no single "raw +// bytes" representation - it's multiplexed, HPACK-compressed framing) +// it's a reconstruction from the parsed request, exact=false. +func captureRequest(r *http.Request, tee *teeConn, bodyCap *cappedTee) (raw []byte, exact bool) { + if tee != nil { + return tee.Take(), true + } + + dump := r.Clone(r.Context()) + if bodyCap != nil { + dump.Body = io.NopCloser(bytes.NewReader(bodyCap.buf.Bytes())) + dump.ContentLength = int64(bodyCap.buf.Len()) + } else { + dump.Body = http.NoBody + dump.ContentLength = 0 + } + var buf bytes.Buffer + if err := dump.Write(&buf); err != nil { + return nil, false + } + return buf.Bytes(), false +} + +// captureResponse mirrors captureRequest for the upstream leg: exact +// wire bytes when tee is non-nil (upstream negotiated HTTP/1.1), +// otherwise a reconstruction. +func captureResponse(resp *http.Response, tee *teeConn, bodyCap *cappedTee) (raw []byte, exact bool) { + if tee != nil { + return tee.Take(), true + } + + dump := *resp + if bodyCap != nil { + dump.Body = io.NopCloser(bytes.NewReader(bodyCap.buf.Bytes())) + dump.ContentLength = int64(bodyCap.buf.Len()) + } else { + dump.Body = http.NoBody + dump.ContentLength = 0 + } + var buf bytes.Buffer + if err := dump.Write(&buf); err != nil { + return nil, false + } + return buf.Bytes(), false +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 4fe9a4f..0a20382 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -3,11 +3,25 @@ // are intercepted: mitmux terminates TLS with the client using a leaf // certificate signed by its own CA, and separately terminates TLS with // the real server, forwarding requests between the two. ALPN is -// negotiated with the real server first and mirrored to the client so -// HTTP/2 connections stay HTTP/2 end to end rather than being downgraded. +// negotiated independently on each side (see handleConnect) so HTTP/2 +// stays HTTP/2 end to end without one side being forced to match the +// other. Every request/response pair is captured to the history store - +// exactly, byte for byte, on HTTP/1.1 legs; reconstructed on HTTP/2 legs, +// which have no meaningful "raw bytes" of their own (see capture.go). +// +// Upstream requests are round-tripped manually (write the request, +// read the response off the same connection) rather than through +// http.Transport: Transport's automatic HTTP/2 dispatch keys off a +// literal *tls.Conn type assertion on the connection it dials, which a +// capturing wrapper around that connection defeats - the request would +// silently be parsed as HTTP/1.1 over what is actually HTTP/2 framing. +// Handling both protocols explicitly here, per request, avoids that and +// also removes any ambiguity about which connection served which +// request, since each request gets its own connection either way. package proxy import ( + "bufio" "context" "crypto/tls" "errors" @@ -21,6 +35,7 @@ import ( "golang.org/x/net/http2" "mitmux/internal/ca" + "mitmux/internal/store" ) // hopByHopHeaders are stripped before forwarding a request or response, @@ -42,40 +57,36 @@ var hopByHopHeaders = []string{ type Server struct { Addr string - ca *ca.CA - transport *http.Transport - server *http.Server + // OnEntry, if set, is called after each request/response pair is + // stored, so a daemon can broadcast it to live TUI subscribers. + OnEntry func(store.Summary) + + ca *ca.CA + store *store.Store + server *http.Server } // New creates a proxy Server bound to addr (e.g. "127.0.0.1:8080"), -// signing intercepted TLS connections with root. -func New(addr string, root *ca.CA) *Server { - s := &Server{ - Addr: addr, - ca: root, - transport: &http.Transport{ - Proxy: nil, - DialContext: (&net.Dialer{ - Timeout: 10 * time.Second, - }).DialContext, - ForceAttemptHTTP2: false, - MaxIdleConns: 100, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - }, - } +// signing intercepted TLS connections with root and recording history to +// db. +func New(addr string, root *ca.CA, db *store.Store) *Server { + s := &Server{Addr: addr, ca: root, store: db} s.server = &http.Server{ - Addr: addr, - Handler: http.HandlerFunc(s.handle), + Addr: addr, + Handler: http.HandlerFunc(s.handle), + ConnContext: withClientTee, } return s } // ListenAndServe starts the proxy and blocks until it stops. func (s *Server) ListenAndServe() error { + ln, err := net.Listen("tcp", s.Addr) + if err != nil { + return err + } log.Printf("proxy listening on %s", s.Addr) - return s.server.ListenAndServe() + return s.server.Serve(&teeListener{Listener: ln}) } // Shutdown gracefully stops the proxy. @@ -91,15 +102,18 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) { s.handleHTTP(w, r) } +// dialer resolves a fresh upstream connection for one request, along +// with the ALPN protocol negotiated for it ("http/1.1", "h2", or "" if +// not applicable/negotiated). +type dialer func(ctx context.Context) (conn net.Conn, negotiated string, err error) + // handleConnect intercepts a CONNECT request: it terminates TLS with the // client using a leaf certificate signed by mitmux's CA, then forwards // each request upstream over its own independently negotiated TLS // connection. Client-side and upstream-side ALPN are negotiated // separately (each offering both HTTP/2 and HTTP/1.1) rather than one // being forced to match the other, so e.g. an HTTP/1.1-only client -// reaching an HTTP/2-only-preferring server doesn't fail to connect - -// http.Transport (via http2.ConfigureTransport) bridges the two sides -// independently per request. +// reaching an HTTP/2-preferring server doesn't fail to connect. func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { hostPort := r.Host hostname, _, err := net.SplitHostPort(hostPort) @@ -141,18 +155,11 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { return } - tr := &http.Transport{ - DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - return dialUpstreamTLS(ctx, hostPort, hostname) - }, - } - if err := http2.ConfigureTransport(tr); err != nil { - log.Printf("configure h2 transport for %s: %v", hostname, err) + dial := func(ctx context.Context) (net.Conn, string, error) { + return dialUpstreamTLS(ctx, hostPort, hostname) } - defer tr.CloseIdleConnections() - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - s.forward(tr, "https", hostname, w, r) + s.forward(dial, "https", hostname, w, r) }) if clientTLS.ConnectionState().NegotiatedProtocol == http2.NextProtoTLS { @@ -160,7 +167,8 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { return } - err = http.Serve(newSingleConnListener(clientTLS), handler) + h1 := &http.Server{Handler: handler, ConnContext: withClientTee} + err = h1.Serve(newSingleConnListener(clientTLS)) if err != nil && !errors.Is(err, io.EOF) { log.Printf("h1 serve for %s: %v", hostname, err) } @@ -168,11 +176,11 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { // dialUpstreamTLS connects to the real server, offering both HTTP/2 and // HTTP/1.1 over ALPN and letting the server pick. -func dialUpstreamTLS(ctx context.Context, hostPort, sni string) (*tls.Conn, error) { - dialer := &net.Dialer{Timeout: 10 * time.Second} - raw, err := dialer.DialContext(ctx, "tcp", hostPort) +func dialUpstreamTLS(ctx context.Context, hostPort, sni string) (net.Conn, string, error) { + nd := &net.Dialer{Timeout: 10 * time.Second} + raw, err := nd.DialContext(ctx, "tcp", hostPort) if err != nil { - return nil, err + return nil, "", err } conn := tls.Client(raw, &tls.Config{ ServerName: sni, @@ -180,28 +188,103 @@ func dialUpstreamTLS(ctx context.Context, hostPort, sni string) (*tls.Conn, erro }) if err := conn.HandshakeContext(ctx); err != nil { raw.Close() + return nil, "", err + } + return conn, conn.ConnectionState().NegotiatedProtocol, nil +} + +// dialUpstreamPlain connects to a plain (non-TLS) upstream for the +// non-CONNECT proxy path, which is always HTTP/1.1. +func dialUpstreamPlain(ctx context.Context, host string) (net.Conn, string, error) { + if _, _, err := net.SplitHostPort(host); err != nil { + host = net.JoinHostPort(host, "80") + } + nd := &net.Dialer{Timeout: 10 * time.Second} + conn, err := nd.DialContext(ctx, "tcp", host) + return conn, "http/1.1", err +} + +// roundTripH1 writes outReq directly to conn and reads the response back +// off the same connection, wrapping conn in a teeConn so the exact wire +// bytes of both can be captured. +func roundTripH1(conn net.Conn, outReq *http.Request) (*http.Response, *teeConn, error) { + tee := newTeeConn(conn) + if err := outReq.Write(tee); err != nil { + return nil, nil, err + } + resp, err := http.ReadResponse(bufio.NewReader(tee), outReq) + if err != nil { + return nil, nil, err + } + return resp, tee, nil +} + +// roundTripH2 sends outReq over a new single-connection HTTP/2 client. +func roundTripH2(conn net.Conn, outReq *http.Request) (*http.Response, error) { + cc, err := (&http2.Transport{}).NewClientConn(conn) + if err != nil { return nil, err } - return conn, nil + return cc.RoundTrip(outReq) } -// forward sends r upstream via rt and copies the response back to w, -// rewriting r's URL from origin-form (as read off the terminated TLS -// connection) to absolute-form for the round trip. -func (s *Server) forward(rt http.RoundTripper, scheme, hostname string, w http.ResponseWriter, r *http.Request) { +// forward dials upstream, sends r, copies the response back to w, and +// records the exchange to history. r's URL is rewritten from +// origin-form (as read off the terminated connection) to absolute-form +// for the round trip. +func (s *Server) forward(dial dialer, scheme, hostname string, w http.ResponseWriter, r *http.Request) { + clientTee := teeConnFromContext(r.Context()) + outReq := r.Clone(r.Context()) outReq.URL.Scheme = scheme outReq.URL.Host = hostname outReq.RequestURI = "" stripHopByHop(outReq.Header) - resp, err := rt.RoundTrip(outReq) + // Only needed when the client leg isn't tee-captured (HTTP/2): tee + // the body as it streams through so the reconstructed capture isn't + // missing it. + var reqBodyCap *cappedTee + if clientTee == nil && outReq.Body != nil { + reqBodyCap = newCappedTee(outReq.Body) + outReq.Body = io.NopCloser(reqBodyCap) + } + + started := time.Now() + conn, negotiated, dialErr := dial(r.Context()) + if dialErr != nil { + reqRaw, reqExact := captureRequest(r, clientTee, reqBodyCap) + s.record(started, time.Since(started), scheme, hostname, r, reqRaw, reqExact, nil, false, 0, dialErr.Error()) + http.Error(w, dialErr.Error(), http.StatusBadGateway) + return + } + defer conn.Close() + + var resp *http.Response + var upstreamTee *teeConn + var err error + if negotiated == http2.NextProtoTLS { + resp, err = roundTripH2(conn, outReq) + } else { + resp, upstreamTee, err = roundTripH1(conn, outReq) + } + duration := time.Since(started) + + reqRaw, reqExact := captureRequest(r, clientTee, reqBodyCap) + if err != nil { + s.record(started, duration, scheme, hostname, r, reqRaw, reqExact, nil, false, 0, err.Error()) http.Error(w, err.Error(), http.StatusBadGateway) return } defer resp.Body.Close() + var respBodyCap *cappedTee + if upstreamTee == nil { + respBodyCap = newCappedTee(resp.Body) + resp.Body = io.NopCloser(respBodyCap) + } + stripHopByHop(resp.Header) for k, vv := range resp.Header { for _, v := range vv { @@ -210,6 +293,60 @@ func (s *Server) forward(rt http.RoundTripper, scheme, hostname string, w http.R } w.WriteHeader(resp.StatusCode) io.Copy(w, resp.Body) + + var respRaw []byte + var respExact bool + if upstreamTee != nil { + respRaw, respExact = upstreamTee.Take(), true + } else { + respRaw, respExact = captureResponse(resp, nil, respBodyCap) + } + + s.record(started, duration, scheme, hostname, r, reqRaw, reqExact, respRaw, respExact, resp.StatusCode, "") +} + +// record stores one history entry and notifies OnEntry. +func (s *Server) record(started time.Time, duration time.Duration, scheme, host string, r *http.Request, + reqRaw []byte, reqExact bool, respRaw []byte, respExact bool, status int, errMsg string) { + if s.store == nil { + return + } + + e := &store.Entry{ + StartedAt: started, + Duration: duration, + Method: r.Method, + Scheme: scheme, + Host: host, + Path: r.URL.Path, + StatusCode: status, + RequestRaw: reqRaw, + ResponseRaw: respRaw, + RequestExact: reqExact, + ResponseExact: respExact, + Error: errMsg, + } + id, err := s.store.Insert(e) + if err != nil { + log.Printf("store history entry: %v", err) + return + } + + if s.OnEntry != nil { + s.OnEntry(store.Summary{ + ID: id, + StartedAt: e.StartedAt, + Duration: e.Duration, + Method: e.Method, + Scheme: e.Scheme, + Host: e.Host, + Path: e.Path, + StatusCode: e.StatusCode, + ReqSize: len(reqRaw), + RespSize: len(respRaw), + Error: errMsg, + }) + } } // singleConnListener adapts one already-accepted net.Conn into a @@ -220,9 +357,14 @@ type singleConnListener struct { addr net.Addr } +// newSingleConnListener wraps c for one Accept, teeConn on the outside +// so a *teeConn is what ConnContext sees (see withClientTee) - wrapping +// it the other way around lets closeSignalConn's concrete type mask the +// teeConn from that type assertion, silently disabling capture. func newSingleConnListener(c net.Conn) *singleConnListener { ch := make(chan net.Conn, 1) - ch <- &closeSignalConn{Conn: c, onClose: sync.OnceFunc(func() { close(ch) })} + signaled := &closeSignalConn{Conn: c, onClose: sync.OnceFunc(func() { close(ch) })} + ch <- newTeeConn(signaled) return &singleConnListener{ch: ch, addr: c.LocalAddr()} } @@ -248,33 +390,18 @@ func (c *closeSignalConn) Close() error { return err } -// handleHTTP forwards a plain (non-CONNECT) proxy request and copies the -// response back unmodified. +// handleHTTP forwards a plain (non-CONNECT) proxy request, copies the +// response back, and records it to history. func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { if !r.URL.IsAbs() { http.Error(w, "mitmux: request must use absolute-form URI (configure as a proxy, not a target)", http.StatusBadRequest) return } - - outReq := r.Clone(r.Context()) - outReq.RequestURI = "" - stripHopByHop(outReq.Header) - - resp, err := s.transport.RoundTrip(outReq) - if err != nil { - http.Error(w, err.Error(), http.StatusBadGateway) - return - } - defer resp.Body.Close() - - stripHopByHop(resp.Header) - for k, vv := range resp.Header { - for _, v := range vv { - w.Header().Add(k, v) - } + host := r.URL.Host + dial := func(ctx context.Context) (net.Conn, string, error) { + return dialUpstreamPlain(ctx, host) } - w.WriteHeader(resp.StatusCode) - io.Copy(w, resp.Body) + s.forward(dial, r.URL.Scheme, r.URL.Host, w, r) } func stripHopByHop(h http.Header) { diff --git a/internal/proxy/tee.go b/internal/proxy/tee.go new file mode 100644 index 0000000..f6a385a --- /dev/null +++ b/internal/proxy/tee.go @@ -0,0 +1,92 @@ +package proxy + +import ( + "context" + "net" + "sync" +) + +// maxCaptureBytes bounds how much of any single request or response +// mitmux buffers for history storage, independent of how much data +// actually flows through the proxy. Proxying itself always streams the +// full body regardless of this limit - only what gets stored is capped, +// so a multi-gigabyte download can't be turned into a memory exhaustion +// vector just because the history view wants to remember it. +const maxCaptureBytes = 10 << 20 // 10 MiB + +// teeConn wraps a net.Conn, recording every byte read off the wire (up +// to maxCaptureBytes) so it can be attributed to a specific request or +// response later. Take returns everything recorded since the last call +// and resets the buffer, so callers must take exactly once per message +// they want attributed correctly - see forward() for why that's safe +// here (call sites synchronize on the request/response boundary itself). +type teeConn struct { + net.Conn + mu sync.Mutex + buf []byte +} + +func newTeeConn(c net.Conn) *teeConn { + return &teeConn{Conn: c} +} + +func (c *teeConn) Read(p []byte) (int, error) { + n, err := c.Conn.Read(p) + if n > 0 { + c.mu.Lock() + if room := maxCaptureBytes - len(c.buf); room > 0 { + end := n + if end > room { + end = room + } + c.buf = append(c.buf, p[:end]...) + } + c.mu.Unlock() + } + return n, err +} + +// Take returns the bytes read since the last Take call (or since the +// connection was created) and resets the buffer. +func (c *teeConn) Take() []byte { + c.mu.Lock() + defer c.mu.Unlock() + out := c.buf + c.buf = nil + return out +} + +// teeListener wraps a net.Listener so every accepted connection is +// tee-captured. +type teeListener struct { + net.Listener +} + +func (l *teeListener) Accept() (net.Conn, error) { + c, err := l.Listener.Accept() + if err != nil { + return nil, err + } + return newTeeConn(c), nil +} + +type contextKey int + +const clientTeeKey contextKey = iota + +// teeConnFromContext returns the teeConn wrapping the client connection +// the current request was read from, as attached via http.Server's +// ConnContext hook. Returns nil for HTTP/2 client connections, which +// aren't tee-captured (see capture.go). +func teeConnFromContext(ctx context.Context) *teeConn { + tc, _ := ctx.Value(clientTeeKey).(*teeConn) + return tc +} + +func withClientTee(ctx context.Context, c net.Conn) context.Context { + tc, ok := c.(*teeConn) + if !ok { + return ctx + } + return context.WithValue(ctx, clientTeeKey, tc) +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..c0219e4 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,186 @@ +// 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. +package store + +import ( + "database/sql" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +const schema = ` +CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + method TEXT NOT NULL, + scheme TEXT NOT NULL, + host TEXT NOT NULL, + path TEXT NOT NULL, + status_code INTEGER, + request_raw BLOB NOT NULL, + response_raw BLOB, + request_exact INTEGER NOT NULL, + response_exact INTEGER NOT NULL, + error TEXT NOT NULL DEFAULT '' +); +` + +// Store is a handle to the history database. Safe for concurrent use. +type Store struct { + db *sql.DB +} + +// Open opens (creating if needed) the SQLite database at path in WAL mode. +func Open(path string) (*Store, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + // modernc.org/sqlite has no real connection pooling benefit here and + // SQLite only supports one writer at a time; serializing access + // through a single connection avoids SQLITE_BUSY entirely. + db.SetMaxOpenConns(1) + + for _, pragma := range []string{ + "PRAGMA journal_mode = WAL", + "PRAGMA synchronous = NORMAL", + "PRAGMA foreign_keys = ON", + } { + if _, err := db.Exec(pragma); err != nil { + db.Close() + return nil, fmt.Errorf("%s: %w", pragma, err) + } + } + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, fmt.Errorf("create schema: %w", err) + } + return &Store{db: db}, nil +} + +// Close closes the underlying database. +func (s *Store) Close() error { + return s.db.Close() +} + +// Entry is one captured request/response pair. +type Entry struct { + ID int64 + StartedAt time.Time + Duration time.Duration + Method string + Scheme string + Host string + Path string + StatusCode int // 0 if no response was received + RequestRaw []byte + ResponseRaw []byte // nil if no response was received + RequestExact bool // true if RequestRaw is wire-exact, false if reconstructed (e.g. HTTP/2) + ResponseExact bool + Error string // network/transport error, if the request never got a response +} + +// Summary is the lightweight metadata used for the history list view - +// no request/response bodies. +type Summary struct { + ID int64 + StartedAt time.Time + Duration time.Duration + Method string + Scheme string + Host string + Path string + StatusCode int + ReqSize int + RespSize int + Error string +} + +// Insert stores e and returns its assigned ID. +func (s *Store) Insert(e *Entry) (int64, error) { + var statusCode any + if e.StatusCode != 0 { + statusCode = e.StatusCode + } + res, err := s.db.Exec( + `INSERT INTO history + (started_at, duration_ms, method, scheme, host, path, status_code, + request_raw, response_raw, request_exact, response_exact, error) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + e.StartedAt.UnixMilli(), e.Duration.Milliseconds(), e.Method, e.Scheme, e.Host, e.Path, + statusCode, e.RequestRaw, e.ResponseRaw, boolToInt(e.RequestExact), boolToInt(e.ResponseExact), e.Error, + ) + if err != nil { + return 0, fmt.Errorf("insert history entry: %w", err) + } + return res.LastInsertId() +} + +// List returns up to limit history summaries older than beforeID (or the +// most recent if beforeID is 0), newest first. +func (s *Store) List(limit int, beforeID int64) ([]Summary, error) { + if limit <= 0 || limit > 1000 { + limit = 200 + } + if beforeID <= 0 { + beforeID = 1<<63 - 1 + } + rows, err := s.db.Query( + `SELECT id, started_at, duration_ms, method, scheme, host, path, + COALESCE(status_code, 0), length(request_raw), COALESCE(length(response_raw), 0), error + FROM history WHERE id < ? ORDER BY id DESC LIMIT ?`, + beforeID, limit, + ) + if err != nil { + return nil, fmt.Errorf("list 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); err != nil { + return nil, fmt.Errorf("scan history 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( + `SELECT id, started_at, duration_ms, method, scheme, host, path, + COALESCE(status_code, 0), request_raw, response_raw, + request_exact, response_exact, error + FROM history WHERE id = ?`, + id, + ) + var e Entry + var startedAt, durationMs int64 + var reqExact, respExact int + if err := row.Scan(&e.ID, &startedAt, &durationMs, &e.Method, &e.Scheme, &e.Host, &e.Path, + &e.StatusCode, &e.RequestRaw, &e.ResponseRaw, &reqExact, &respExact, &e.Error); err != nil { + return nil, fmt.Errorf("get history entry %d: %w", id, err) + } + e.StartedAt = time.UnixMilli(startedAt) + e.Duration = time.Duration(durationMs) * time.Millisecond + e.RequestExact = reqExact != 0 + e.ResponseExact = respExact != 0 + return &e, nil +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} |