srdusr
aboutsummaryrefslogtreecommitdiffstats
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
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).
-rw-r--r--PLAN.md7
-rw-r--r--cmd/mitmux/main.go356
-rw-r--r--cmd/mitmuxd/main.go52
-rw-r--r--go.mod35
-rw-r--r--go.sum97
-rw-r--r--internal/ipc/ipc.go126
-rw-r--r--internal/ipc/server.go136
-rw-r--r--internal/proxy/capture.go83
-rw-r--r--internal/proxy/proxy.go269
-rw-r--r--internal/proxy/tee.go92
-rw-r--r--internal/store/store.go186
11 files changed, 1362 insertions, 77 deletions
diff --git a/PLAN.md b/PLAN.md
index 1393282..80537e1 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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()
}
}
diff --git a/go.mod b/go.mod
index 5b564a3..cdfdcd7 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index cc9727d..7049d1f 100644
--- a/go.sum
+++ b/go.sum
@@ -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
+}