srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/internal/proxy/dialer_test.go
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-04-08 16:11:00 +0200
committersrdusr <[email protected]>2026-04-08 16:11:00 +0200
commit7b9bf73e46102969d5802300469862296f979ef6 (patch)
treef38ac2bc2f686989bee86f4bd273f2f9127f4475 /internal/proxy/dialer_test.go
parentf77a9570e973dda7247c653754e62d5a9a895658 (diff)
downloadmitmux-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.go139
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")
+ }
+}