diff options
| author | srdusr <[email protected]> | 2026-04-08 16:11:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-04-08 16:11:00 +0200 |
| commit | 7b9bf73e46102969d5802300469862296f979ef6 (patch) | |
| tree | f38ac2bc2f686989bee86f4bd273f2f9127f4475 | |
| parent | f77a9570e973dda7247c653754e62d5a9a895658 (diff) | |
| download | mitmux-7b9bf73e46102969d5802300469862296f979ef6.tar.gz mitmux-7b9bf73e46102969d5802300469862296f979ef6.zip | |
Multiple proxy listeners and upstream proxy chaining
Closes the last two items from the original "worth considering" list.
Multiple listeners: -listen takes a comma-separated address list
(-listen "127.0.0.1:8080,127.0.0.1:8081"). Server.Addr became
Server.Addrs; ListenAndServe binds every address up front - before any
of them start serving - so a bad address fails startup immediately
rather than leaving the daemon partially listening, and rolls back
already-opened listeners if a later one fails to bind. All addresses
share the same handler/history/CA/rules: one logical proxy reachable on
more than one address, not several independent proxies in one process.
Upstream proxy chaining: -upstream-proxy host:port (optional http://
prefix, stripped for convenience) routes every outbound connection
through another HTTP CONNECT proxy instead of dialing origins directly.
dialViaProxy does the CONNECT handshake to the upstream and hands back
a plain net.Conn as if it were a direct connection; dialUpstreamTLS
(CONNECT/HTTPS path) and dialUpstreamPlain (plain-HTTP path) both take
an upstreamProxy parameter and route through it when set. The two paths
need different handling: CONNECT/HTTPS is transparent below the tunnel
(once the CONNECT handshake succeeds, TLS and the request on top of it
look identical to a direct connection, so roundTripH2 and the H1 read
side need no changes at all), but plain HTTP has to send an
absolute-form request line to the upstream proxy instead of origin-form
- so roundTripH1 gained a proxyForm parameter, and forward() selects it
based on scheme=="http" && UpstreamProxy!="".
Chaining into another intercepting/MITM proxy (including another
mitmuxd) needs that proxy's own CA trusted too, or TLS verification
fails - this is inherent to chaining MITM proxies, not a gap here, and
confirmed live below rather than left as a guess.
internal/proxy/dialer_test.go: dialViaProxy against a real local CONNECT
stub (not a mock) - direct dial, successful tunnel-and-echo through a
proxy, and a proxy that refuses the CONNECT with a non-200. All three
exercise the actual network code path, not just the string-building
around it.
Verified live: started a daemon with two -listen addresses, sent
requests through both, confirmed a single shared history; killed it
mid-flight with SIGTERM and confirmed both listeners closed cleanly;
started it with one bad address in the list and confirmed startup
failed immediately with the already-bound port released, no lingering
process. For chaining: sent plain HTTP and HTTPS through a downstream
mitmuxd configured with -upstream-proxy pointing at a genuine
passthrough CONNECT stub (tunnels raw bytes, doesn't MITM) and got real
content back on both; separately chained through a second mitmuxd
instance and got the expected "certificate signed by unknown authority"
error, cleanly recorded in history rather than hanging.
go build/vet/gofmt/test/mod tidy all clean.
| -rw-r--r-- | PLAN.md | 40 | ||||
| -rw-r--r-- | README.md | 19 | ||||
| -rw-r--r-- | cmd/mitmuxd/main.go | 22 | ||||
| -rw-r--r-- | internal/proxy/dialer_test.go | 139 | ||||
| -rw-r--r-- | internal/proxy/proxy.go | 174 |
5 files changed, 359 insertions, 35 deletions
@@ -167,8 +167,44 @@ to test against; only the command text itself (sourced from each platform's standard, documented tooling) is confirmed correct by inspection. -Still open from "worth considering": multiple proxy listeners and -upstream proxy chaining. Not started yet. +Shipped since: multiple proxy listeners and upstream proxy chaining - +the last two items from the original "worth considering" list. + +Multiple listeners: `-listen` takes a comma-separated address list +(`-listen "127.0.0.1:8080,127.0.0.1:8081"`); all bound addresses share +the same handler, history store, CA and rules - one logical proxy +reachable on more than one address/port, not several independent +proxies in one process. Every address is bound up front before any of +them start serving, so a bad address fails startup immediately instead +of leaving the daemon partially listening. + +Upstream proxy chaining: `-upstream-proxy host:port` (optional +`http://` prefix, stripped) routes every outbound connection through +another HTTP CONNECT proxy instead of dialing origins directly - +chaining mitmux into Burp, a corporate proxy, or any other +CONNECT-speaking proxy. SOCKS5 upstreams aren't implemented. For the +CONNECT/HTTPS path, chaining is transparent below the tunnel: once the +CONNECT handshake to the upstream proxy succeeds, TLS and the +request/response on top of it are identical to a direct connection, so +no other code needed to change. The plain-HTTP path is different: an +absolute-form request line ("GET http://host/path HTTP/1.1") has to be +sent to the upstream proxy instead of origin-form, so `roundTripH1` +gained a `proxyForm` parameter and `forward()` selects it based on +whether the request is plain HTTP and an upstream proxy is configured. + +Chaining into another intercepting/MITM proxy (including another +mitmuxd instance) will fail TLS verification unless that proxy's own CA +is separately trusted - expected, not a mitmux-specific gap: the +upstream MITM terminates and re-signs the connection with its own CA, +which mitmux's outbound TLS client (verifying against the system root +store) has no reason to trust. Confirmed live: chaining through a +genuine passthrough CONNECT proxy (tunnels raw bytes, no MITM) works +correctly for both plain HTTP and HTTPS; chaining through a second +mitmuxd instance correctly fails with a clear +"certificate signed by unknown authority" error recorded in history, +rather than hanging or crashing. + +This closes every item from the original "worth considering" list. Skipped deliberately (from the research, matches this tool's stated scope): active/passive vulnerability scanning, plugin marketplace, @@ -123,8 +123,21 @@ go build -o bin/mitmux ./cmd/mitmux ``` Both binaries take flags for non-default setups - `-listen`, `-socket`, -`-ca-dir`, `-db` on `mitmuxd`; `-socket` on `mitmux`. Run either with -`-h` for the full list. +`-ca-dir`, `-db`, `-upstream-proxy` on `mitmuxd`; `-socket` on `mitmux`. +Run either with `-h` for the full list. + +`-listen` takes a comma-separated list to bind more than one address +(`-listen "127.0.0.1:8080,127.0.0.1:8081"`) - one logical proxy on +several ports/interfaces, sharing the same history, CA and rules. + +`-upstream-proxy host:port` chains every outbound connection through +another HTTP CONNECT proxy (Burp, a corporate proxy, anything that +speaks CONNECT) instead of dialing origins directly. Chaining into +another *intercepting* proxy needs that proxy's own CA trusted too - +it terminates and re-signs the connection with its own CA, which +mitmux's outbound TLS client has no reason to trust otherwise; you'll +see a clear certificate-verification error in history rather than a +silent failure. SOCKS5 upstreams aren't implemented. ## Usage @@ -330,5 +343,7 @@ reasoning behind each: never runs them for you (see Quick start above for why) - No WebSocket interception - No client (mutual-TLS) certificate support +- Upstream proxy chaining (`-upstream-proxy`) is HTTP CONNECT only, no + SOCKS5 - No active or passive vulnerability scanning, no plugin system - this is a manual-testing tool, not a scanner diff --git a/cmd/mitmuxd/main.go b/cmd/mitmuxd/main.go index 9c77516..baaba85 100644 --- a/cmd/mitmuxd/main.go +++ b/cmd/mitmuxd/main.go @@ -14,6 +14,7 @@ import ( "os/signal" "path/filepath" "runtime" + "strings" "syscall" "time" @@ -24,13 +25,25 @@ import ( ) func main() { - listen := flag.String("listen", "127.0.0.1:8080", "proxy listen address") + listen := flag.String("listen", "127.0.0.1:8080", "proxy listen address(es) - comma-separated for more than one, e.g. \"127.0.0.1:8080,127.0.0.1:8081\"") 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)") installCA := flag.Bool("install-ca", false, "generate the CA if needed, print OS-specific trust-store install steps, and exit (doesn't start the proxy)") + upstreamProxy := flag.String("upstream-proxy", "", "chain all outbound connections through this HTTP CONNECT proxy (host:port, optional http:// prefix) instead of dialing origins directly") flag.Parse() + var listenAddrs []string + for _, a := range strings.Split(*listen, ",") { + if a = strings.TrimSpace(a); a != "" { + listenAddrs = append(listenAddrs, a) + } + } + if len(listenAddrs) == 0 { + log.Fatalf("-listen: no addresses given") + } + upstream := strings.TrimPrefix(strings.TrimSpace(*upstreamProxy), "http://") + dir := *caDir if dir == "" { d, err := ca.Dir() @@ -80,11 +93,14 @@ func main() { defer os.Remove(sockFile) log.Printf("control socket: %s", sockFile) - srv := proxy.New(*listen, root, db) + srv := proxy.New(listenAddrs, root, db, upstream) + if upstream != "" { + log.Printf("chaining outbound connections through upstream proxy %s", upstream) + } hub := ipc.NewHub() srv.OnEntry = ipc.LogAndBroadcast(hub) - ipcSrv := ipc.NewServer(db, hub, srv, *listen) + ipcSrv := ipc.NewServer(db, hub, srv, strings.Join(listenAddrs, ", ")) go func() { if err := ipcSrv.Serve(sockLn); err != nil { log.Printf("control socket: %v", err) diff --git a/internal/proxy/dialer_test.go b/internal/proxy/dialer_test.go new file mode 100644 index 0000000..14746c9 --- /dev/null +++ b/internal/proxy/dialer_test.go @@ -0,0 +1,139 @@ +package proxy + +import ( + "bufio" + "context" + "io" + "net" + "net/http" + "testing" + "time" +) + +// startStubConnectProxy runs a minimal HTTP CONNECT proxy for the +// duration of the test: it accepts one CONNECT request, replies with the +// given status, and if status is 200 splices the tunnel through to a +// real dial of the requested host. Returns the proxy's address. +func startStubConnectProxy(t *testing.T, status int) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + c, err := ln.Accept() + if err != nil { + return + } + defer c.Close() + br := bufio.NewReader(c) + req, err := http.ReadRequest(br) + if err != nil { + return + } + if req.Method != http.MethodConnect { + c.Write([]byte("HTTP/1.1 405 Method Not Allowed\r\n\r\n")) + return + } + if status != http.StatusOK { + c.Write([]byte("HTTP/1.1 403 Forbidden\r\n\r\n")) + return + } + target, err := net.Dial("tcp", req.Host) + if err != nil { + c.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) + return + } + defer target.Close() + c.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) + done := make(chan struct{}, 2) + go func() { io.Copy(target, br); done <- struct{}{} }() + go func() { io.Copy(c, target); done <- struct{}{} }() + <-done + }() + return ln.Addr().String() +} + +// startEchoServer runs a TCP server that echoes back whatever it reads, +// standing in for "the origin" on the far side of a CONNECT tunnel. +func startEchoServer(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + io.Copy(c, c) + }(c) + } + }() + return ln.Addr().String() +} + +func TestDialViaProxyDirect(t *testing.T) { + echoAddr := startEchoServer(t) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + conn, err := dialViaProxy(ctx, echoAddr, "") + if err != nil { + t.Fatalf("dialViaProxy direct: %v", err) + } + defer conn.Close() + + if _, err := conn.Write([]byte("hello")); err != nil { + t.Fatalf("write: %v", err) + } + buf := make([]byte, 5) + if _, err := io.ReadFull(conn, buf); err != nil { + t.Fatalf("read: %v", err) + } + if string(buf) != "hello" { + t.Errorf("got %q, want %q", buf, "hello") + } +} + +func TestDialViaProxyTunneled(t *testing.T) { + echoAddr := startEchoServer(t) + proxyAddr := startStubConnectProxy(t, http.StatusOK) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + conn, err := dialViaProxy(ctx, echoAddr, proxyAddr) + if err != nil { + t.Fatalf("dialViaProxy via proxy: %v", err) + } + defer conn.Close() + + if _, err := conn.Write([]byte("world")); err != nil { + t.Fatalf("write: %v", err) + } + buf := make([]byte, 5) + if _, err := io.ReadFull(conn, buf); err != nil { + t.Fatalf("read: %v", err) + } + if string(buf) != "world" { + t.Errorf("got %q, want %q", buf, "world") + } +} + +func TestDialViaProxyRejected(t *testing.T) { + proxyAddr := startStubConnectProxy(t, http.StatusForbidden) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _, err := dialViaProxy(ctx, "example.invalid:443", proxyAddr) + if err == nil { + t.Fatal("expected an error when the upstream proxy refuses CONNECT, got nil") + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index 3b2d50d..82328db 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -25,10 +25,12 @@ import ( "context" "crypto/tls" "errors" + "fmt" "io" "log" "net" "net/http" + "net/url" "sync" "time" @@ -58,9 +60,20 @@ var hopByHopHeaders = []string{ "Upgrade", } -// Server is a forward proxy listener. +// Server is a forward proxy listener. It can bind more than one address +// at once (Addrs) - all sharing the same handler, history store, CA and +// rules, so a client on any of them sees identical behavior; this is for +// cases like wanting a separate port per client/network segment, not +// for running logically different proxies in one process. type Server struct { - Addr string + Addrs []string + + // UpstreamProxy, if set (host:port, no scheme), chains every + // outbound connection through another HTTP CONNECT proxy instead of + // dialing origins directly - e.g. routing mitmux's own traffic + // through Burp, a corporate proxy, or a network-access proxy. + // SOCKS5 upstreams aren't implemented (see PLAN.md). + UpstreamProxy string // OnEntry, if set, is called after each request/response pair is // stored, so a daemon can broadcast it to live TUI subscribers. @@ -71,27 +84,50 @@ type Server struct { server *http.Server } -// New creates a proxy Server bound to addr (e.g. "127.0.0.1:8080"), +// New creates a proxy Server bound to addrs (e.g. ["127.0.0.1:8080"]), // 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} +// db. upstreamProxy chains outbound connections through another HTTP +// CONNECT proxy (host:port, no scheme) instead of dialing origins +// directly; empty disables chaining. +func New(addrs []string, root *ca.CA, db *store.Store, upstreamProxy string) *Server { + s := &Server{Addrs: addrs, ca: root, store: db, UpstreamProxy: upstreamProxy} s.server = &http.Server{ - Addr: addr, Handler: http.HandlerFunc(s.handle), ConnContext: withClientTee, } return s } -// ListenAndServe starts the proxy and blocks until it stops. +// ListenAndServe binds every address in Addrs and blocks until one of +// them stops (including on Shutdown, which closes all of them - every +// Serve call below then returns http.ErrServerClosed). Addresses are all +// bound up front before any of them start serving, so a bad address +// (already in use, unparseable, ...) fails startup immediately rather +// than leaving the daemon partially listening. func (s *Server) ListenAndServe() error { - ln, err := net.Listen("tcp", s.Addr) - if err != nil { - return err + if len(s.Addrs) == 0 { + return errors.New("no listen addresses configured") + } + lns := make([]net.Listener, len(s.Addrs)) + for i, addr := range s.Addrs { + ln, err := net.Listen("tcp", addr) + if err != nil { + for _, opened := range lns[:i] { + opened.Close() + } + return fmt.Errorf("listen on %s: %w", addr, err) + } + lns[i] = ln } - log.Printf("proxy listening on %s", s.Addr) - return s.server.Serve(&teeListener{Listener: ln}) + + errCh := make(chan error, len(lns)) + for i, ln := range lns { + log.Printf("proxy listening on %s", s.Addrs[i]) + go func(ln net.Listener) { + errCh <- s.server.Serve(&teeListener{Listener: ln}) + }(ln) + } + return <-errCh } // Shutdown gracefully stops the proxy. @@ -161,7 +197,7 @@ func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { } dial := func(ctx context.Context) (net.Conn, string, error) { - return dialUpstreamTLS(ctx, hostPort, hostname) + return dialUpstreamTLS(ctx, hostPort, hostname, s.UpstreamProxy) } handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.forward(dial, "https", hostname, w, r) @@ -179,11 +215,14 @@ 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) (net.Conn, string, error) { - nd := &net.Dialer{Timeout: 10 * time.Second} - raw, err := nd.DialContext(ctx, "tcp", hostPort) +// dialUpstreamTLS connects to the real server (directly, or tunneled +// through upstreamProxy if set - see dialViaProxy), offering both +// HTTP/2 and HTTP/1.1 over ALPN and letting the server pick. Chaining +// through another proxy is transparent to everything from here on: once +// the CONNECT tunnel is up, TLS and the request/response on top of it +// look identical to a direct connection. +func dialUpstreamTLS(ctx context.Context, hostPort, sni, upstreamProxy string) (net.Conn, string, error) { + raw, err := dialViaProxy(ctx, hostPort, upstreamProxy) if err != nil { return nil, "", err } @@ -199,23 +238,98 @@ func dialUpstreamTLS(ctx context.Context, hostPort, sni string) (net.Conn, strin } // 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) { +// non-CONNECT proxy path, which is always HTTP/1.1. Unlike the TLS/ +// CONNECT path, chaining here means dialing the upstream proxy's own +// address directly and writing it an absolute-form request (what a +// proxy expects) rather than tunneling - see forward()'s viaProxyForm. +func dialUpstreamPlain(ctx context.Context, host, upstreamProxy string) (net.Conn, string, error) { if _, _, err := net.SplitHostPort(host); err != nil { host = net.JoinHostPort(host, "80") } + target := host + if upstreamProxy != "" { + target = upstreamProxy + } nd := &net.Dialer{Timeout: 10 * time.Second} - conn, err := nd.DialContext(ctx, "tcp", host) + conn, err := nd.DialContext(ctx, "tcp", target) return conn, "http/1.1", err } +// dialViaProxy returns a raw TCP connection ready to speak TLS to +// hostPort - dialed directly if upstreamProxy is empty, or tunneled +// through upstreamProxy via an HTTP CONNECT request otherwise. +func dialViaProxy(ctx context.Context, hostPort, upstreamProxy string) (net.Conn, error) { + nd := &net.Dialer{Timeout: 10 * time.Second} + if upstreamProxy == "" { + return nd.DialContext(ctx, "tcp", hostPort) + } + + conn, err := nd.DialContext(ctx, "tcp", upstreamProxy) + if err != nil { + return nil, fmt.Errorf("dial upstream proxy %s: %w", upstreamProxy, err) + } + + connectReq := &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Opaque: hostPort}, + Host: hostPort, + Header: make(http.Header), + } + if err := connectReq.Write(conn); err != nil { + conn.Close() + return nil, fmt.Errorf("write CONNECT to upstream proxy %s: %w", upstreamProxy, err) + } + + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, connectReq) + if err != nil { + conn.Close() + return nil, fmt.Errorf("read CONNECT response from upstream proxy %s: %w", upstreamProxy, err) + } + if resp.StatusCode != http.StatusOK { + conn.Close() + return nil, fmt.Errorf("upstream proxy %s refused CONNECT to %s: %s", upstreamProxy, hostPort, resp.Status) + } + if br.Buffered() > 0 { + // The upstream proxy shouldn't send anything past the CONNECT + // response before the tunnel starts, but if it did, those bytes + // are sitting in br's buffer, not on conn - replay them first + // rather than silently dropping the start of the TLS handshake. + return &prefixedConn{Conn: conn, r: br}, nil + } + return conn, nil +} + +// prefixedConn serves buffered bytes from r before falling through to +// reading directly off the underlying connection. +type prefixedConn struct { + net.Conn + r *bufio.Reader +} + +func (c *prefixedConn) Read(p []byte) (int, error) { + if c.r.Buffered() > 0 { + return c.r.Read(p) + } + return c.Conn.Read(p) +} + // 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) { +// bytes of both can be captured. proxyForm selects an absolute-form +// request line ("GET http://host/path HTTP/1.1") instead of origin-form +// - needed when conn is a connection to another proxy, which expects +// that form, rather than to the origin server itself. +func roundTripH1(conn net.Conn, outReq *http.Request, proxyForm bool) (*http.Response, *teeConn, error) { tee := newTeeConn(conn) - if err := outReq.Write(tee); err != nil { - return nil, nil, err + var writeErr error + if proxyForm { + writeErr = outReq.WriteProxy(tee) + } else { + writeErr = outReq.Write(tee) + } + if writeErr != nil { + return nil, nil, writeErr } resp, err := http.ReadResponse(bufio.NewReader(tee), outReq) if err != nil { @@ -290,7 +404,11 @@ func (s *Server) forward(dial dialer, scheme, hostname string, w http.ResponseWr if negotiated == http2.NextProtoTLS { resp, err = roundTripH2(conn, outReq) } else { - resp, upstreamTee, err = roundTripH1(conn, outReq) + // Only the plain-HTTP path needs absolute-form: a CONNECT tunnel + // (chained through an upstream proxy or not) is transparent from + // here on, so it always uses origin-form like a direct connection. + proxyForm := scheme == "http" && s.UpstreamProxy != "" + resp, upstreamTee, err = roundTripH1(conn, outReq, proxyForm) } duration := time.Since(started) @@ -446,7 +564,7 @@ func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { } host := r.URL.Host dial := func(ctx context.Context) (net.Conn, string, error) { - return dialUpstreamPlain(ctx, host) + return dialUpstreamPlain(ctx, host, s.UpstreamProxy) } s.forward(dial, r.URL.Scheme, r.URL.Host, w, r) } |