diff options
| author | srdusr <[email protected]> | 2026-06-24 14:40:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-06-24 14:40:00 +0200 |
| commit | e01afcbf0de00af52fe90959ba77288679e3303d (patch) | |
| tree | 073ff1430af629e28556a6f651ee47f6989376da /internal/proxy/proxy.go | |
| parent | 23c8ab359c2108654d57176e233d2c099b398f31 (diff) | |
| download | mitmux-e01afcbf0de00af52fe90959ba77288679e3303d.tar.gz mitmux-e01afcbf0de00af52fe90959ba77288679e3303d.zip | |
SOCKS5 upstream proxy chaining
Extends -upstream-proxy to accept a socks5://[user:pass@]host:port
prefix, using golang.org/x/net/proxy (already an indirect dependency
via http2, so no new module) rather than hand-rolling the client side
of RFC 1928/1929. parseSOCKS5 is the single place that decides which
kind of upstream a given UpstreamProxy string names; dialViaProxy
(CONNECT/TLS path) and dialUpstreamPlain (plain-HTTP path) both check
it first and fall through to the existing HTTP CONNECT behavior
otherwise. SOCKS5 needs no absolute-form request adjustment on the
plain-HTTP path the way HTTP-proxy chaining does, since it tunnels
straight to the target rather than expecting a proxy-aware request.
Tested against a real, minimal SOCKS5 server built for the test suite
(exercises dialSOCKS5's actual wire behavior, not a mock of the
client library), plus live against a real standalone SOCKS5 relay
process: both a plain HTTP and an HTTPS request through mitmux were
confirmed, via the relay's own log, to have actually traversed it.
Diffstat (limited to 'internal/proxy/proxy.go')
| -rw-r--r-- | internal/proxy/proxy.go | 92 |
1 files changed, 81 insertions, 11 deletions
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index bc22bcf..a98a35a 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -37,6 +37,7 @@ import ( "time" "golang.org/x/net/http2" + xproxy "golang.org/x/net/proxy" "mitmux/internal/ca" "mitmux/internal/clientcert" @@ -90,11 +91,13 @@ var hopByHopHeaders = []string{ type Server struct { 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, if set, chains every outbound connection through + // another proxy instead of dialing origins directly - e.g. routing + // mitmux's own traffic through Burp, a corporate proxy, a network- + // access proxy, or Tor. Bare "host:port" (or an "http://" prefix, + // stripped before it gets here) means an HTTP CONNECT proxy; a + // "socks5://[user:pass@]host:port" prefix means SOCKS5 - see + // parseSOCKS5. UpstreamProxy string // OnEntry, if set, is called after each request/response pair is @@ -315,6 +318,15 @@ func dialUpstreamPlain(ctx context.Context, host, upstreamProxy string) (net.Con if _, _, err := net.SplitHostPort(host); err != nil { host = net.JoinHostPort(host, "80") } + // SOCKS5 tunnels straight to host, same as dialViaProxy's TLS path - + // see dialSOCKS5's doc comment for why that needs no absolute-form + // adjustment the way chaining through an HTTP proxy does below. + if addr, auth, err := parseSOCKS5(upstreamProxy); err != nil { + return nil, "", err + } else if addr != "" { + conn, err := dialSOCKS5(ctx, addr, auth, host) + return conn, "http/1.1", err + } target := host if upstreamProxy != "" { target = upstreamProxy @@ -324,10 +336,65 @@ func dialUpstreamPlain(ctx context.Context, host, upstreamProxy string) (net.Con return conn, "http/1.1", err } +// parseSOCKS5 returns the proxy's bare "host:port" and optional +// credentials if upstreamProxy has a "socks5://" prefix - the marker +// this tool uses to distinguish a SOCKS5 upstream from the default HTTP +// CONNECT proxy chaining every other non-empty value means. addr == "" +// means upstreamProxy isn't a SOCKS5 proxy, which includes the "no +// upstream proxy configured at all" empty-string case - callers branch +// on that the same way they'd branch on upstreamProxy == "". +func parseSOCKS5(upstreamProxy string) (addr string, auth *xproxy.Auth, err error) { + if !strings.HasPrefix(upstreamProxy, "socks5://") { + return "", nil, nil + } + u, err := url.Parse(upstreamProxy) + if err != nil { + return "", nil, fmt.Errorf("invalid socks5 upstream proxy %q: %w", upstreamProxy, err) + } + if u.User != nil { + pass, _ := u.User.Password() + auth = &xproxy.Auth{User: u.User.Username(), Password: pass} + } + return u.Host, auth, nil +} + +// dialSOCKS5 tunnels to target through the SOCKS5 proxy at proxyAddr. +// Unlike an HTTP CONNECT proxy, SOCKS5 is transport-level and protocol- +// agnostic: the resulting connection behaves exactly like one dialed +// directly to target, with no "absolute-form request" adjustment needed +// on top (see forward's proxyForm). +func dialSOCKS5(ctx context.Context, proxyAddr string, auth *xproxy.Auth, target string) (net.Conn, error) { + d, err := xproxy.SOCKS5("tcp", proxyAddr, auth, xproxy.Direct) + if err != nil { + return nil, fmt.Errorf("configure SOCKS5 proxy %s: %w", proxyAddr, err) + } + // xproxy.Direct (the forward dialer passed above) always yields a + // ContextDialer-capable SOCKS5 client, per the library's own + // implementation - this fallback exists so a future forward-dialer + // change can't silently drop context cancellation rather than fail + // to compile against a changed interface. + cd, ok := d.(xproxy.ContextDialer) + if !ok { + return d.Dial("tcp", target) + } + conn, err := cd.DialContext(ctx, "tcp", target) + if err != nil { + return nil, fmt.Errorf("dial %s via SOCKS5 proxy %s: %w", target, proxyAddr, err) + } + return conn, nil +} + // 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. +// hostPort - dialed directly if upstreamProxy is empty, tunneled through +// a SOCKS5 proxy if upstreamProxy has a "socks5://" prefix, or tunneled +// through an HTTP CONNECT proxy otherwise. func dialViaProxy(ctx context.Context, hostPort, upstreamProxy string) (net.Conn, error) { + if addr, auth, err := parseSOCKS5(upstreamProxy); err != nil { + return nil, err + } else if addr != "" { + return dialSOCKS5(ctx, addr, auth, hostPort) + } + nd := &net.Dialer{Timeout: 10 * time.Second} if upstreamProxy == "" { return nd.DialContext(ctx, "tcp", hostPort) @@ -504,10 +571,13 @@ func (s *Server) forward(dial dialer, scheme, hostname string, w http.ResponseWr if negotiated == http2.NextProtoTLS { resp, err = roundTripH2(conn, outReq) } else { - // 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 != "" + // Only the plain-HTTP path needs absolute-form, and only when + // chained through an HTTP proxy specifically - a CONNECT tunnel + // (chained or not) is transparent from here on, so it always uses + // origin-form like a direct connection, and so does a SOCKS5 + // upstream: SOCKS5 tunnels straight to the origin, invisible to + // the HTTP layer, same as dialSOCKS5's doc comment explains. + proxyForm := scheme == "http" && s.UpstreamProxy != "" && !strings.HasPrefix(s.UpstreamProxy, "socks5://") resp, upstreamTee, err = roundTripH1(conn, outReq, proxyForm) } duration := time.Since(started) |