srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/internal/proxy/proxy.go
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-06-24 14:40:00 +0200
committersrdusr <[email protected]>2026-06-24 14:40:00 +0200
commite01afcbf0de00af52fe90959ba77288679e3303d (patch)
tree073ff1430af629e28556a6f651ee47f6989376da /internal/proxy/proxy.go
parent23c8ab359c2108654d57176e233d2c099b398f31 (diff)
downloadmitmux-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.go92
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)