From 8d15c2e0b326933f8fc912e3b13f37e78a9bc0b6 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Wed, 14 Feb 2024 00:37:00 +0200 Subject: 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). --- internal/proxy/capture.go | 83 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 internal/proxy/capture.go (limited to 'internal/proxy/capture.go') 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 +} -- cgit v1.2.3