srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/internal/proxy
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
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')
-rw-r--r--internal/proxy/dialer_test.go139
-rw-r--r--internal/proxy/proxy.go174
2 files changed, 285 insertions, 28 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")
+ }
+}
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)
}