srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/internal/proxy
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 /internal/proxy
parentf2f0a2135a202e3e15d2a8cbfbd791aad9b04f3a (diff)
downloadmitmux-8d15c2e0b326933f8fc912e3b13f37e78a9bc0b6.tar.gz
mitmux-8d15c2e0b326933f8fc912e3b13f37e78a9bc0b6.zip
History view: SQLite storage, daemon/TUI split over Unix socket
Implements build-order step 3. Adds: - internal/store: SQLite (WAL, single-writer) history table, raw request/response blobs plus metadata for the list view. - internal/proxy: request/response capture wired into forward(). HTTP/1.1 legs are captured byte-exact via a teeConn that records wire bytes as they're read, taken right after the message is fully drained (so no manual re-reading/replaying is needed - RoundTrip's own streaming does the draining). HTTP/2 legs (no meaningful "raw bytes" of their own - multiplexed, HPACK-compressed framing) are reconstructed instead, and marked as such in storage. - internal/ipc: JSON-over-Unix-socket protocol between mitmuxd (owns the proxy and the DB) and any client - list/get for queries, subscribe for a live push stream of newly captured entries. Keeps the proxy engine independent of the UI, per the architecture sketch. - cmd/mitmux: Bubble Tea TUI - a live-updating history table and a request/response detail view with raw bytes. Two real bugs surfaced during testing and got fixed before commit: 1. http.Transport's HTTP/2 auto-dispatch does a literal *tls.Conn type assertion on the dialed connection; wrapping it in a capturing teeConn broke that silently, and HTTP/2 framing got parsed as HTTP/1.1 text. Fixed by dropping http.Transport for the upstream leg entirely in favor of an explicit per-protocol round trip (see PLAN.md stack note). 2. singleConnListener wrapped the client teeConn *inside* a closeSignalConn, so ConnContext's type assertion for it silently failed and HTTP/1.1 client-side capture never activated. Fixed the wrap order; verified via direct SQLite inspection that request_exact flips back to 1 and the stored bytes are genuinely wire-exact (preserved chunked-encoding framing, original header casing/order). Verified live: plain HTTP, HTTPS H1.1, HTTPS H2, and a POST with a body, checked against the raw stored bytes directly in SQLite; IPC list/get/ subscribe against a throwaway client; and the TUI driven end-to-end in a tmux session (list, detail view, tab between request/response, live update on a new request while sitting on the list).
Diffstat (limited to 'internal/proxy')
-rw-r--r--internal/proxy/capture.go83
-rw-r--r--internal/proxy/proxy.go269
-rw-r--r--internal/proxy/tee.go92
3 files changed, 373 insertions, 71 deletions
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)
+}