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 /internal/proxy/dialer_test.go | |
| 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.
Diffstat (limited to 'internal/proxy/dialer_test.go')
| -rw-r--r-- | internal/proxy/dialer_test.go | 139 |
1 files changed, 139 insertions, 0 deletions
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") + } +} |