diff --git a/engine/engine.go b/engine/engine.go index e9c6fc11..bade3c95 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -2,6 +2,7 @@ package engine import ( "errors" + "fmt" "net" "os/exec" "sync" @@ -178,9 +179,6 @@ func restAPI(k *Key) error { } func netstack(k *Key) (err error) { - if k.Proxy == "" { - return errors.New("empty proxy") - } if k.Device == "" { return errors.New("empty device") } @@ -207,7 +205,7 @@ func netstack(k *Key) (err error) { return err } - if _defaultProxy, err = parseProxy(k.Proxy); err != nil { + if _defaultProxy, err = buildProxy(k); err != nil { return err } tunnel.T().SetProxy(_defaultProxy) @@ -247,6 +245,23 @@ func netstack(k *Key) (err error) { return err } - log.Infof("[STACK] %s <-> %s", k.Device, k.Proxy) + log.Infof("[STACK] %s <-> %s", k.Device, proxyDescription(k)) return nil } + +// proxyDescription renders a short, log-friendly description of the +// proxy configuration, accounting for the split case where tcp-proxy +// and/or udp-proxy override --proxy. +func proxyDescription(k *Key) string { + if k.TCPProxy == "" && k.UDPProxy == "" { + return k.Proxy + } + tcp, udp := k.TCPProxy, k.UDPProxy + if tcp == "" { + tcp = k.Proxy + } + if udp == "" { + udp = k.Proxy + } + return fmt.Sprintf("tcp=%s udp=%s", tcp, udp) +} diff --git a/engine/key.go b/engine/key.go index a8fb1dd6..297a695a 100644 --- a/engine/key.go +++ b/engine/key.go @@ -6,6 +6,8 @@ type Key struct { MTU int `yaml:"mtu"` Mark int `yaml:"fwmark"` Proxy string `yaml:"proxy"` + TCPProxy string `yaml:"tcp-proxy"` + UDPProxy string `yaml:"udp-proxy"` RestAPI string `yaml:"restapi"` Device string `yaml:"device"` LogLevel string `yaml:"loglevel"` diff --git a/engine/parse.go b/engine/parse.go index c859b9f1..071fdf58 100644 --- a/engine/parse.go +++ b/engine/parse.go @@ -1,6 +1,7 @@ package engine import ( + "errors" "fmt" "net" "net/netip" @@ -85,6 +86,54 @@ func parseProxy(s string) (proxy.Proxy, error) { return proxy.Parse(u) } +// buildProxy resolves the (Proxy, TCPProxy, UDPProxy) triple into a +// single proxy.Proxy. When TCPProxy or UDPProxy is set, the result is a +// proxy.Split wrapper with the Proxy value (if any) used as the default +// for the unspecified side. +func buildProxy(k *Key) (proxy.Proxy, error) { + parseOne := func(s string) (proxy.Proxy, error) { + if s == "" { + return nil, nil + } + return parseProxy(s) + } + + base, err := parseOne(k.Proxy) + if err != nil { + return nil, err + } + tcp, err := parseOne(k.TCPProxy) + if err != nil { + return nil, err + } + udp, err := parseOne(k.UDPProxy) + if err != nil { + return nil, err + } + + if tcp == nil && udp == nil { + if base == nil { + return nil, errors.New("no proxy configured: set proxy or tcp-proxy/udp-proxy") + } + return base, nil + } + + tcpSide, udpSide := tcp, udp + if tcpSide == nil { + tcpSide = base + } + if udpSide == nil { + udpSide = base + } + if tcpSide == nil { + return nil, errors.New("udp-proxy is set but no TCP proxy configured (set proxy or tcp-proxy)") + } + if udpSide == nil { + return nil, errors.New("tcp-proxy is set but no UDP proxy configured (set proxy or udp-proxy)") + } + return &proxy.Split{TCP: tcpSide, UDP: udpSide}, nil +} + func parseMulticastGroups(v []string) ([]netip.Addr, error) { groups := make([]netip.Addr, 0, len(v)) for _, ip := range v { diff --git a/engine/parse_test.go b/engine/parse_test.go new file mode 100644 index 00000000..982cb00f --- /dev/null +++ b/engine/parse_test.go @@ -0,0 +1,50 @@ +package engine + +import ( + "strings" + "testing" + + "github.com/xjasonlyu/tun2socks/v2/proxy" +) + +// TestBuildProxySplit guards against regression of the netstack() +// guard that used to reject tcp-proxy/udp-proxy configurations when +// --proxy was empty. buildProxy should accept that pairing and return +// a *proxy.Split. +func TestBuildProxySplit(t *testing.T) { + p, err := buildProxy(&Key{TCPProxy: "direct://", UDPProxy: "direct://"}) + if err != nil { + t.Fatalf("buildProxy split: %v", err) + } + if _, ok := p.(*proxy.Split); !ok { + t.Fatalf("buildProxy split: got %T, want *proxy.Split", p) + } +} + +func TestBuildProxyEmpty(t *testing.T) { + _, err := buildProxy(&Key{}) + if err == nil { + t.Fatal("buildProxy empty: expected error, got nil") + } + if !strings.Contains(err.Error(), "no proxy configured") { + t.Fatalf("buildProxy empty: unexpected error: %v", err) + } +} + +func TestBuildProxyBaseOnly(t *testing.T) { + p, err := buildProxy(&Key{Proxy: "direct://"}) + if err != nil { + t.Fatalf("buildProxy base-only: %v", err) + } + if _, ok := p.(*proxy.Split); ok { + t.Fatalf("buildProxy base-only: got *proxy.Split, want direct proxy") + } +} + +func TestBuildProxyHalfSplit(t *testing.T) { + // udp-proxy set, tcp-proxy empty, no base -> error + _, err := buildProxy(&Key{UDPProxy: "direct://"}) + if err == nil { + t.Fatal("buildProxy half-split: expected error, got nil") + } +} diff --git a/engine/register.go b/engine/register.go index 4bf7447b..26e8f74a 100644 --- a/engine/register.go +++ b/engine/register.go @@ -3,6 +3,7 @@ package engine import ( _ "github.com/xjasonlyu/tun2socks/v2/proxy/direct" _ "github.com/xjasonlyu/tun2socks/v2/proxy/http" + _ "github.com/xjasonlyu/tun2socks/v2/proxy/masque" _ "github.com/xjasonlyu/tun2socks/v2/proxy/reject" _ "github.com/xjasonlyu/tun2socks/v2/proxy/relay" _ "github.com/xjasonlyu/tun2socks/v2/proxy/shadowsocks" diff --git a/go.mod b/go.mod index e1cf8c49..26b74211 100644 --- a/go.mod +++ b/go.mod @@ -12,8 +12,11 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/schema v1.4.1 github.com/gorilla/websocket v1.5.3 + github.com/quic-go/masque-go v0.3.0 + github.com/quic-go/quic-go v0.59.0 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 + github.com/yosida95/uritemplate/v3 v3.0.2 go.uber.org/atomic v1.11.0 go.uber.org/zap v1.27.1 golang.org/x/crypto v0.49.0 @@ -27,13 +30,18 @@ require ( require ( github.com/ajg/form v1.7.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dunglas/httpsfv v1.0.2 // indirect github.com/google/btree v1.1.3 // indirect - github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + go.uber.org/mock v0.5.2 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect + golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.43.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index 3e4472ae..19b27e43 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,12 @@ github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/ajg/form v1.7.1 h1:OsnBDzTkrWdrxvEnO68I72ZVGJGNaMwPhoAm0V+llgc= github.com/ajg/form v1.7.1/go.mod h1:HL757PzLyNkj5AIfptT6L+iGNeXTlnrr/oDePGc/y7Q= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0= +github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= @@ -16,6 +17,8 @@ github.com/go-gost/relay v0.5.0 h1:JG1tgy/KWiVXS0ukuVXvbM0kbYuJTWxYpJ5JwzsCf/c= github.com/go-gost/relay v0.5.0/go.mod h1:lcX+23LCQ3khIeASBo+tJ/WbwXFO32/N5YN6ucuYTG8= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -24,27 +27,32 @@ github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/quic-go/masque-go v0.3.0 h1:7dfKbv/fSWPr0/d+bwOfOuNPwdDtiEHqyG39CFSUPNw= +github.com/quic-go/masque-go v0.3.0/go.mod h1:5jAgw26NNKsyVHJy5QIJbN6i/ijSUSq2OhW+V5mt+6w= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= @@ -53,14 +61,22 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= diff --git a/main.go b/main.go index 4453d881..5d4b4b4d 100644 --- a/main.go +++ b/main.go @@ -29,6 +29,8 @@ func init() { flag.StringVarP(&configFile, "config", "c", "", "YAML format configuration file") flag.StringVarP(&key.Device, "device", "d", "", "Use this device [driver://]name") flag.StringVarP(&key.Proxy, "proxy", "p", "", "Use this proxy [protocol://]host[:port]") + flag.StringVar(&key.TCPProxy, "tcp-proxy", "", "Use this proxy for TCP only (overrides proxy for TCP)") + flag.StringVar(&key.UDPProxy, "udp-proxy", "", "Use this proxy for UDP only (overrides proxy for UDP; required for MASQUE)") flag.StringVar(&key.Interface, "interface", "", "Use network INTERFACE (Linux/MacOS/Windows)") flag.StringVar(&key.LogLevel, "loglevel", "info", "Log level [debug|info|warn|error|silent]") flag.StringVar(&key.RestAPI, "restapi", "", "HTTP statistic server listen address") diff --git a/proxy/masque/capsule.go b/proxy/masque/capsule.go new file mode 100644 index 00000000..3b1204bc --- /dev/null +++ b/proxy/masque/capsule.go @@ -0,0 +1,43 @@ +package masque + +import ( + "errors" + "io" + + "github.com/quic-go/quic-go/http3" + "github.com/quic-go/quic-go/quicvarint" + + "github.com/xjasonlyu/tun2socks/v2/log" +) + +// drainCapsules consumes HTTP/3 capsules from the stream's byte channel. +// This is mandatory: the RequestStream multiplexes DATA-frame bytes on +// the underlying QUIC stream, and if no one reads them, QUIC stream-level +// flow control eventually stalls the whole connection. RFC 9298 permits +// unrecognised capsule types and requires skipping them. +func drainCapsules(pc *h3DatagramConn) { + r := quicvarint.NewReader(pc.rs) + for { + ct, body, err := http3.ParseCapsule(r) + if err != nil { + if !errors.Is(err, io.EOF) && !isClosed(pc) { + log.Debugf("[MASQUE] capsule drain ended: %v", err) + } + return + } + if _, err := io.Copy(io.Discard, body); err != nil { + log.Debugf("[MASQUE] capsule 0x%x body discard: %v", uint64(ct), err) + return + } + log.Debugf("[MASQUE] drained capsule type=0x%x", uint64(ct)) + } +} + +func isClosed(pc *h3DatagramConn) bool { + select { + case <-pc.done: + return true + default: + return false + } +} diff --git a/proxy/masque/masque.go b/proxy/masque/masque.go new file mode 100644 index 00000000..8d6c6f20 --- /dev/null +++ b/proxy/masque/masque.go @@ -0,0 +1,435 @@ +// Package masque implements an RFC 9298 CONNECT-UDP (MASQUE) proxy client +// on top of HTTP/3. It is UDP-only; pair it with a TCP proxy via +// proxy.Split when TCP flows also need to be tunneled. +package masque + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/yosida95/uritemplate/v3" + + "github.com/xjasonlyu/tun2socks/v2/log" + M "github.com/xjasonlyu/tun2socks/v2/metadata" + "github.com/xjasonlyu/tun2socks/v2/proxy" +) + +const ( + defaultTemplate = "https://%s/.well-known/masque/udp/{target_host}/{target_port}/" + settingsTimeout = 10 * time.Second + dialTimeout = 15 * time.Second + defaultKeepAlive = 20 * time.Second + defaultMaxIdle = 5 * time.Minute + connectUDPProtocol = "connect-udp" + h3RequestCancelled = 0x10c +) + +func init() { + proxy.RegisterProtocol("masque", Parse) + proxy.RegisterProtocol("connect-udp", Parse) +} + +var _ proxy.Proxy = (*Masque)(nil) + +// Masque is a CONNECT-UDP proxy client. One instance owns a single +// persistent HTTP/3 connection to the proxy; each DialUDP opens a fresh +// extended-CONNECT request stream on that shared connection. +type Masque struct { + proxyAddr string + authority string + tlsConf *tls.Config + quicConf *quic.Config + template *uritemplate.Template + authHdr string // full "Basic " value, or "" if none + + mu sync.Mutex + conn *http3.ClientConn + rawConn *quic.Conn // identity used by invalidate() to avoid redial races +} + +// Parse implements proxy.ParseFunc. Accepted URL shapes: +// +// masque://[user:pass@]host:port +// masque://[user:pass@]host:port/custom/{target_host}/{target_port}/path +// masque://...?sni=foo&insecure=true&alpn=h3&cacert=/path/to/ca.pem +// +// Query parameters: +// +// sni — overrides tls.Config.ServerName (default: URL host) +// insecure — "true"/"1" disables certificate verification (testing only) +// alpn — comma-separated ALPN list (default: "h3") +// cacert — path to a PEM bundle appended to the system roots +// template — explicit URI template; alternative to putting it in the path +// username — overrides userinfo username +// password — overrides userinfo password +func Parse(u *url.URL) (proxy.Proxy, error) { + if u.Host == "" { + return nil, errors.New("masque: empty host") + } + host, port, err := net.SplitHostPort(u.Host) + if err != nil { + host = u.Host + port = "443" + } + if _, err := strconv.Atoi(port); err != nil { + return nil, fmt.Errorf("masque: invalid port %q: %w", port, err) + } + addr := net.JoinHostPort(host, port) + + q := u.Query() + + sni := host + if v := q.Get("sni"); v != "" { + sni = v + } + alpn := []string{http3.NextProtoH3} + if v := q.Get("alpn"); v != "" { + alpn = alpn[:0] + for _, p := range strings.Split(v, ",") { + if t := strings.TrimSpace(p); t != "" { + alpn = append(alpn, t) + } + } + if len(alpn) == 0 { + return nil, errors.New("masque: alpn query parameter has no usable values") + } + } + rootCAs := loadSystemCAs() + if caPath := q.Get("cacert"); caPath != "" { + pem, err := os.ReadFile(caPath) + if err != nil { + return nil, fmt.Errorf("masque: read cacert: %w", err) + } + if !rootCAs.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("masque: no certificates found in %s", caPath) + } + } + tlsConf := &tls.Config{ + ServerName: sni, + RootCAs: rootCAs, + NextProtos: alpn, + InsecureSkipVerify: q.Get("insecure") == "true" || q.Get("insecure") == "1", + MinVersion: tls.VersionTLS13, + } + + reservedKeys := map[string]struct{}{ + "sni": {}, "insecure": {}, "alpn": {}, "cacert": {}, + "template": {}, "username": {}, "password": {}, + } + var rawTpl string + if t := q.Get("template"); t != "" { + rawTpl = t + } else if u.Path != "" && u.Path != "/" { + qq := url.Values{} + for k, v := range q { + if _, isReserved := reservedKeys[k]; isReserved { + continue + } + qq[k] = v + } + rawTpl = fmt.Sprintf("https://%s%s", addr, u.Path) + if enc := qq.Encode(); enc != "" { + rawTpl += "?" + enc + } + } else { + for k := range q { + if _, isReserved := reservedKeys[k]; !isReserved { + return nil, fmt.Errorf("masque: unknown query parameter %q (no template/path to attach it to)", k) + } + } + rawTpl = fmt.Sprintf(defaultTemplate, addr) + } + tpl, err := uritemplate.New(rawTpl) + if err != nil { + return nil, fmt.Errorf("masque: invalid URI template %q: %w", rawTpl, err) + } + if !templateHas(tpl, "target_host") || !templateHas(tpl, "target_port") { + return nil, fmt.Errorf("masque: template %q must contain {target_host} and {target_port}", rawTpl) + } + + user, pass := "", "" + if u.User != nil { + user = u.User.Username() + pass, _ = u.User.Password() + } + if v := q.Get("username"); v != "" { + user = v + } + if v := q.Get("password"); v != "" { + pass = v + } + var authHdr string + if user != "" || pass != "" { + authHdr = "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass)) + } + + quicConf := &quic.Config{ + EnableDatagrams: true, + KeepAlivePeriod: defaultKeepAlive, + MaxIdleTimeout: defaultMaxIdle, + } + + return &Masque{ + proxyAddr: addr, + authority: addr, + tlsConf: tlsConf, + quicConf: quicConf, + template: tpl, + authHdr: authHdr, + }, nil +} + +// DialContext: MASQUE is UDP-only. tun2socks calls this for TCP flows; +// we refuse so the operator knows to wire up a TCP proxy via proxy.Split. +func (m *Masque) DialContext(context.Context, *M.Metadata) (net.Conn, error) { + return nil, errors.New("masque: CONNECT-UDP is UDP-only; pair with a TCP proxy via --tcp-proxy") +} + +// DialUDP opens a fresh CONNECT-UDP request stream on the shared HTTP/3 +// connection and returns a net.PacketConn bound to that stream. +func (m *Masque) DialUDP(metadata *M.Metadata) (net.PacketConn, error) { + target := metadata.DestinationAddress() + host, portStr, err := net.SplitHostPort(target) + if err != nil { + return nil, fmt.Errorf("masque: bad target %q: %w", target, err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + return nil, fmt.Errorf("masque: bad target port %q: %w", portStr, err) + } + + // RFC 9298 §3.1: target_host MUST enclose IPv6 literals in brackets. + // SplitHostPort strips them, so put them back for the template only; + // targetAddr below still uses the unbracketed form for net.ParseIP. + templateHost := host + if ip := net.ParseIP(host); ip != nil && ip.To4() == nil { + templateHost = "[" + host + "]" + } + + vars := uritemplate.Values{} + vars.Set("target_host", uritemplate.String(templateHost)) + vars.Set("target_port", uritemplate.String(strconv.Itoa(port))) + expanded, err := m.template.Expand(vars) + if err != nil { + return nil, fmt.Errorf("masque: expand template: %w", err) + } + rurl, err := url.Parse(expanded) + if err != nil { + return nil, fmt.Errorf("masque: expanded template is not a valid URL: %w", err) + } + + targetAddr := &net.UDPAddr{Port: port} + if ip := net.ParseIP(host); ip != nil { + targetAddr.IP = ip + } + + var lastErr error + for attempt := 0; attempt < 2; attempt++ { + cc, qc, err := m.getConn(context.Background()) + if err != nil { + return nil, fmt.Errorf("masque: connect proxy: %w", err) + } + // Build a fresh request per attempt: quic-go's SendRequestHeader + // canonicalizes and may mutate the request, so reusing it across + // retries could send a different header set the second time. + req := (&http.Request{ + Method: http.MethodConnect, + Proto: connectUDPProtocol, + URL: rurl, + Host: m.authority, + Header: http.Header{ + http3.CapsuleProtocolHeader: []string{"?1"}, + }, + }).WithContext(context.Background()) + if m.authHdr != "" { + req.Header.Set("Proxy-Authorization", m.authHdr) + } + pc, err := m.dialOnce(cc, req, targetAddr) + if err == nil { + return pc, nil + } + lastErr = err + // Only retry once, and only if the underlying QUIC connection is + // actually dead. Stream-level errors on a healthy conn surface to + // the caller as-is — retrying them would just fail again. + if attempt == 0 { + select { + case <-qc.Context().Done(): + m.invalidate(qc) + continue + default: + } + } + return nil, err + } + return nil, lastErr +} + +func (m *Masque) dialOnce(cc *http3.ClientConn, req *http.Request, target *net.UDPAddr) (net.PacketConn, error) { + openCtx, cancel := context.WithTimeout(context.Background(), dialTimeout) + defer cancel() + + rs, err := cc.OpenRequestStream(openCtx) + if err != nil { + return nil, fmt.Errorf("open request stream: %w", err) + } + + // SendRequestHeader writes via the underlying QUIC stream and + // ReadResponse reads from it via io.ReadFull, so stream deadlines + // bound both calls without needing a watchdog goroutine that could + // race the success path. + deadline := time.Now().Add(dialTimeout) + _ = rs.SetReadDeadline(deadline) + _ = rs.SetWriteDeadline(deadline) + + if err := rs.SendRequestHeader(req); err != nil { + rs.CancelRead(quic.StreamErrorCode(h3RequestCancelled)) + rs.CancelWrite(quic.StreamErrorCode(h3RequestCancelled)) + return nil, fmt.Errorf("send CONNECT-UDP: %w", err) + } + resp, err := rs.ReadResponse() + if err != nil { + rs.CancelRead(quic.StreamErrorCode(h3RequestCancelled)) + rs.CancelWrite(quic.StreamErrorCode(h3RequestCancelled)) + return nil, fmt.Errorf("read CONNECT-UDP response: %w", err) + } + if resp.StatusCode/100 != 2 { + rs.CancelRead(quic.StreamErrorCode(h3RequestCancelled)) + rs.CancelWrite(quic.StreamErrorCode(h3RequestCancelled)) + return nil, fmt.Errorf("masque: proxy rejected CONNECT-UDP: %s", resp.Status) + } + + // Clear the dial-bounded deadlines: the session is now indefinite, + // per-call deadlines on h3DatagramConn handle subsequent timing. + _ = rs.SetReadDeadline(time.Time{}) + _ = rs.SetWriteDeadline(time.Time{}) + + pcCtx, pcCancel := context.WithCancel(context.Background()) + pc := &h3DatagramConn{ + rs: rs, + ctx: pcCtx, + cancel: pcCancel, + target: target, + done: make(chan struct{}), + } + go drainCapsules(pc) + return pc, nil +} + +func (m *Masque) getConn(ctx context.Context) (*http3.ClientConn, *quic.Conn, error) { + m.mu.Lock() + if m.conn != nil { + cc, qc := m.conn, m.rawConn + m.mu.Unlock() + return cc, qc, nil + } + m.mu.Unlock() + + dctx, cancel := context.WithTimeout(ctx, dialTimeout) + defer cancel() + + qc, err := quic.DialAddrEarly(dctx, m.proxyAddr, m.tlsConf, m.quicConf) + if err != nil { + return nil, nil, fmt.Errorf("quic handshake: %w", err) + } + + tr := &http3.Transport{ + TLSClientConfig: m.tlsConf, + QUICConfig: m.quicConf, + EnableDatagrams: true, + } + cc := tr.NewClientConn(qc) + + sctx, scancel := context.WithTimeout(ctx, settingsTimeout) + defer scancel() + select { + case <-cc.ReceivedSettings(): + case <-sctx.Done(): + _ = qc.CloseWithError(0, "settings timeout") + return nil, nil, errors.New("masque: timed out waiting for HTTP/3 SETTINGS") + } + s := cc.Settings() + if s == nil || !s.EnableExtendedConnect { + _ = qc.CloseWithError(0, "no extended connect") + return nil, nil, errors.New("masque: proxy missing SETTINGS_ENABLE_CONNECT_PROTOCOL (0x08)") + } + if !s.EnableDatagrams { + _ = qc.CloseWithError(0, "no h3 datagram") + return nil, nil, errors.New("masque: proxy missing SETTINGS_H3_DATAGRAM (0x33)") + } + + m.mu.Lock() + if m.conn != nil { + cc, existing := m.conn, m.rawConn + m.mu.Unlock() + _ = qc.CloseWithError(0, "superseded") + return cc, existing, nil + } + m.conn = cc + m.rawConn = qc + m.mu.Unlock() + log.Infof("[MASQUE] connected to %s", m.proxyAddr) + return cc, qc, nil +} + +// invalidate drops the cached connection only if it still matches qc. +// Pointer identity prevents two concurrent failing DialUDP calls from +// clobbering each other's freshly-redialled connection. +func (m *Masque) invalidate(qc *quic.Conn) { + m.mu.Lock() + defer m.mu.Unlock() + if m.rawConn != qc { + return + } + _ = qc.CloseWithError(0, "invalidating") + m.conn = nil + m.rawConn = nil +} + +// Close releases the shared HTTP/3 connection. In-flight PacketConns +// will observe the stream close on their next Read/Write. +func (m *Masque) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + if m.rawConn != nil { + err := m.rawConn.CloseWithError(0, "client close") + m.rawConn = nil + m.conn = nil + return err + } + return nil +} + +func loadSystemCAs() *x509.CertPool { + p, err := x509.SystemCertPool() + if err != nil { + log.Warnf("[MASQUE] system cert pool unavailable, using empty pool (set cacert= to add roots): %v", err) + } + if p == nil { + return x509.NewCertPool() + } + return p +} + +func templateHas(t *uritemplate.Template, name string) bool { + for _, v := range t.Varnames() { + if v == name { + return true + } + } + return false +} diff --git a/proxy/masque/masque_test.go b/proxy/masque/masque_test.go new file mode 100644 index 00000000..7330b72b --- /dev/null +++ b/proxy/masque/masque_test.go @@ -0,0 +1,327 @@ +package masque_test + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net" + "net/http" + "net/netip" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/quic-go/masque-go" + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/yosida95/uritemplate/v3" + + M "github.com/xjasonlyu/tun2socks/v2/metadata" + masquecli "github.com/xjasonlyu/tun2socks/v2/proxy/masque" +) + +// generateCert creates a self-signed ECDSA cert for 127.0.0.1 / localhost +// and writes PEM files into dir. +func generateCert(t *testing.T, dir string) (certFile, keyFile string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + tpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "masque-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + DNSNames: []string{"localhost"}, + } + der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatal(err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + certFile = filepath.Join(dir, "cert.pem") + keyFile = filepath.Join(dir, "key.pem") + if err := os.WriteFile(certFile, certPEM, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyFile, keyPEM, 0o600); err != nil { + t.Fatal(err) + } + return certFile, keyFile +} + +// startEcho opens a loopback UDP echo server. +func startEcho(t *testing.T) *net.UDPAddr { + t.Helper() + c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.Close() }) + go func() { + buf := make([]byte, 2048) + for { + n, addr, err := c.ReadFromUDP(buf) + if err != nil { + return + } + _, _ = c.WriteToUDP(buf[:n], addr) + } + }() + return c.LocalAddr().(*net.UDPAddr) +} + +type proxyOpts struct { + requireAuth string // if non-empty, require this Proxy-Authorization value + failAll bool // return 403 to everything +} + +// startProxy starts a MASQUE server on localhost and returns its address. +func startProxy(t *testing.T, opts proxyOpts) *net.UDPAddr { + t.Helper() + dir := t.TempDir() + certFile, keyFile := generateCert(t, dir) + + lc, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + addr := lc.LocalAddr().(*net.UDPAddr) + + tplRaw := fmt.Sprintf("https://127.0.0.1:%d/.well-known/masque/udp/{target_host}/{target_port}/", addr.Port) + tpl := uritemplate.MustNew(tplRaw) + mp := &masque.Proxy{} + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/masque/udp/", func(w http.ResponseWriter, r *http.Request) { + if opts.failAll { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + if opts.requireAuth != "" { + got := r.Header.Get("Proxy-Authorization") + if got != opts.requireAuth { + http.Error(w, "auth required", http.StatusProxyAuthRequired) + return + } + } + mreq, err := masque.ParseRequest(r, tpl) + if err != nil { + var perr *masque.RequestParseError + if errors.As(err, &perr) { + w.WriteHeader(perr.HTTPStatus) + return + } + w.WriteHeader(http.StatusBadRequest) + return + } + if err := mp.Proxy(w, mreq); err != nil { + t.Logf("proxy.Proxy: %v", err) + } + }) + + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + t.Fatal(err) + } + server := &http3.Server{ + Handler: mux, + TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, NextProtos: []string{http3.NextProtoH3}}, + EnableDatagrams: true, + QUICConfig: &quic.Config{EnableDatagrams: true}, + } + errCh := make(chan error, 1) + go func() { errCh <- server.Serve(lc) }() + + // Give Serve a moment to perform its synchronous setDF check. On + // sandboxes that prohibit setting the DF bit (e.g. network-restricted + // CI containers) it fails immediately and we skip the test. + select { + case err := <-errCh: + if err != nil && strings.Contains(err.Error(), "setting DF") { + _ = lc.Close() + t.Skipf("skipping: environment does not support setting UDP DF bit: %v", err) + } + t.Fatalf("http3.Server.Serve exited early: %v", err) + case <-time.After(200 * time.Millisecond): + } + + t.Cleanup(func() { + _ = server.Close() + _ = lc.Close() + _ = mp.Close() + if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) { + t.Logf("http3.Server.Serve: %v", err) + } + }) + return addr +} + +func basicAuth(user, pass string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass)) +} + +func echoMetadata(echo *net.UDPAddr) *M.Metadata { + return &M.Metadata{ + Network: M.UDP, + DstIP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + DstPort: uint16(echo.Port), + } +} + +func parseProxy(t *testing.T, raw string) *masquecli.Masque { + t.Helper() + u, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + p, err := masquecli.Parse(u) + if err != nil { + t.Fatal(err) + } + m := p.(*masquecli.Masque) + t.Cleanup(func() { _ = m.Close() }) + return m +} + +func TestMasqueEchoLoopback(t *testing.T) { + paddr := startProxy(t, proxyOpts{}) + echo := startEcho(t) + + m := parseProxy(t, fmt.Sprintf("masque://%s/?insecure=true&sni=localhost", paddr.String())) + pc, err := m.DialUDP(echoMetadata(echo)) + if err != nil { + t.Fatalf("DialUDP: %v", err) + } + defer pc.Close() + + want := []byte("hello masque") + if _, err := pc.WriteTo(want, nil); err != nil { + t.Fatalf("WriteTo: %v", err) + } + _ = pc.SetReadDeadline(time.Now().Add(3 * time.Second)) + buf := make([]byte, 2048) + n, _, err := pc.ReadFrom(buf) + if err != nil { + t.Fatalf("ReadFrom: %v", err) + } + if !bytes.Equal(buf[:n], want) { + t.Fatalf("echo mismatch: got %q want %q", buf[:n], want) + } +} + +func TestMasqueBasicAuthRequired(t *testing.T) { + paddr := startProxy(t, proxyOpts{requireAuth: basicAuth("alice", "s3cr3t")}) + echo := startEcho(t) + + // Without credentials: must fail with 407. + m1 := parseProxy(t, fmt.Sprintf("masque://%s/?insecure=true&sni=localhost", paddr.String())) + if _, err := m1.DialUDP(echoMetadata(echo)); err == nil { + t.Fatal("expected auth failure, got nil") + } else if !strings.Contains(err.Error(), "407") { + t.Fatalf("expected 407 in error, got: %v", err) + } + + // With credentials: must succeed and echo. + m2 := parseProxy(t, fmt.Sprintf("masque://alice:s3cr3t@%s/?insecure=true&sni=localhost", paddr.String())) + pc, err := m2.DialUDP(echoMetadata(echo)) + if err != nil { + t.Fatalf("DialUDP with creds: %v", err) + } + defer pc.Close() + if _, err := pc.WriteTo([]byte("ping"), nil); err != nil { + t.Fatal(err) + } + _ = pc.SetReadDeadline(time.Now().Add(3 * time.Second)) + buf := make([]byte, 2048) + n, _, err := pc.ReadFrom(buf) + if err != nil { + t.Fatalf("ReadFrom: %v", err) + } + if !bytes.Equal(buf[:n], []byte("ping")) { + t.Fatalf("echo mismatch: got %q", buf[:n]) + } +} + +func TestMasqueRejectsNon2xx(t *testing.T) { + paddr := startProxy(t, proxyOpts{failAll: true}) + echo := startEcho(t) + + m := parseProxy(t, fmt.Sprintf("masque://%s/?insecure=true&sni=localhost", paddr.String())) + _, err := m.DialUDP(echoMetadata(echo)) + if err == nil { + t.Fatal("expected non-2xx error, got nil") + } + if !strings.Contains(err.Error(), "403") { + t.Fatalf("expected 403 in error, got: %v", err) + } +} + +func TestMasqueParseURL(t *testing.T) { + cases := []struct { + name string + raw string + wantErr bool + }{ + {"plain", "masque://proxy.example.com:443", false}, + {"with-auth", "masque://user:pass@proxy.example.com:443", false}, + {"default-port", "masque://proxy.example.com", false}, + {"custom-template", "masque://proxy.example.com:443/custom/{target_host}/{target_port}/", false}, + {"query-options", "masque://proxy.example.com:443/?sni=foo&insecure=true&alpn=h3,h3-29", false}, + {"template-missing-target-host", "masque://proxy.example.com:443/?template=https://proxy.example.com/x/{target_port}", true}, + {"empty-host", "masque://", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + u, err := url.Parse(c.raw) + if err != nil { + t.Fatalf("url.Parse: %v", err) + } + _, err = masquecli.Parse(u) + if (err != nil) != c.wantErr { + t.Fatalf("Parse(%q) err=%v, wantErr=%v", c.raw, err, c.wantErr) + } + }) + } +} + +func TestMasqueRefusesTCP(t *testing.T) { + u, _ := url.Parse("masque://127.0.0.1:4433/?insecure=true") + p, err := masquecli.Parse(u) + if err != nil { + t.Fatal(err) + } + md := &M.Metadata{ + Network: M.TCP, + DstIP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + DstPort: 80, + } + if _, err := p.DialContext(t.Context(), md); err == nil { + t.Fatal("expected DialContext to refuse, got nil") + } +} diff --git a/proxy/masque/packetconn.go b/proxy/masque/packetconn.go new file mode 100644 index 00000000..309344e6 --- /dev/null +++ b/proxy/masque/packetconn.go @@ -0,0 +1,175 @@ +package masque + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "sync" + "time" + + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/quic-go/quic-go/quicvarint" + + "github.com/xjasonlyu/tun2socks/v2/log" +) + +// h3DatagramConn adapts an *http3.RequestStream carrying RFC 9298 +// context-ID-0 HTTP/3 datagrams to a net.PacketConn. +type h3DatagramConn struct { + rs *http3.RequestStream + ctx context.Context + cancel context.CancelFunc + target *net.UDPAddr + + mu sync.Mutex // protects readDl, writeDl, readCancel + readDl time.Time + writeDl time.Time + readCancel context.CancelFunc // cancels the in-flight ReceiveDatagram, if any + + closeOnce sync.Once + closeErr error + done chan struct{} +} + +var _ net.PacketConn = (*h3DatagramConn)(nil) + +// WriteTo prepends the RFC 9298 §5 context ID (0 = raw UDP payload) as a +// QUIC varint and sends the result as an HTTP/3 datagram. The destination +// address is ignored: the stream is already bound to a single target by +// the CONNECT-UDP request. SendDatagram queues internally and does not +// block, so checking the deadline before the call is sufficient. +func (c *h3DatagramConn) WriteTo(p []byte, _ net.Addr) (int, error) { + c.mu.Lock() + dl := c.writeDl + c.mu.Unlock() + if !dl.IsZero() && time.Now().After(dl) { + return 0, os.ErrDeadlineExceeded + } + buf := quicvarint.Append(make([]byte, 0, len(p)+1), 0) + buf = append(buf, p...) + if err := c.rs.SendDatagram(buf); err != nil { + var tooLarge *quic.DatagramTooLargeError + if errors.As(err, &tooLarge) { + return 0, fmt.Errorf("masque: datagram too large (max payload=%d, sent=%d): %w", + tooLarge.MaxDatagramPayloadSize, len(buf), err) + } + return 0, err + } + return len(p), nil +} + +// ReadFrom blocks for the next HTTP/3 datagram whose context ID is 0, +// strips the varint, and copies the payload into p. Datagrams with +// non-zero context IDs (reserved by RFC 9298) are silently dropped. +// +// SetReadDeadline can interrupt a blocked ReadFrom: it cancels the +// in-flight context, the loop observes context.Canceled, and re-derives +// a new context from the updated deadline. +func (c *h3DatagramConn) ReadFrom(p []byte) (int, net.Addr, error) { + for { + c.mu.Lock() + dl := c.readDl + var ctx context.Context + var cancel context.CancelFunc + if dl.IsZero() { + ctx, cancel = context.WithCancel(c.ctx) + } else { + ctx, cancel = context.WithDeadline(c.ctx, dl) + } + c.readCancel = cancel + c.mu.Unlock() + + data, err := c.rs.ReceiveDatagram(ctx) + + c.mu.Lock() + c.readCancel = nil + c.mu.Unlock() + cancel() + + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return 0, c.target, os.ErrDeadlineExceeded + } + if errors.Is(err, context.Canceled) { + select { + case <-c.done: + return 0, c.target, err + default: + // Deadline was changed mid-call; loop and retry + // with the new deadline. + continue + } + } + return 0, c.target, err + } + cid, n, perr := quicvarint.Parse(data) + if perr != nil { + continue // malformed datagram; drop per RFC 9298 §5 + } + if cid != 0 { + continue // reserved for future extensions + } + payload := data[n:] + copied := copy(p, payload) + if copied < len(payload) { + // Match real UDP sockets: truncate silently rather than + // returning a non-nil error, which tunnel/udp.go treats + // as terminal and would tear the session down. + log.Debugf("[MASQUE] datagram truncated (%d > %d)", len(payload), len(p)) + } + return copied, c.target, nil + } +} + +func (c *h3DatagramConn) Close() error { + c.closeOnce.Do(func() { + c.cancel() + close(c.done) + c.mu.Lock() + if c.readCancel != nil { + c.readCancel() + } + c.mu.Unlock() + // rs.Close() only sends FIN on the send side; the drainCapsules + // goroutine reads from the receive side and would block until + // the peer closes. CancelRead wakes it up. + c.rs.CancelRead(quic.StreamErrorCode(h3RequestCancelled)) + c.closeErr = c.rs.Close() + }) + return c.closeErr +} + +func (c *h3DatagramConn) LocalAddr() net.Addr { + return &net.UDPAddr{} +} + +func (c *h3DatagramConn) SetDeadline(t time.Time) error { + c.mu.Lock() + c.readDl = t + c.writeDl = t + if c.readCancel != nil { + c.readCancel() + } + c.mu.Unlock() + return nil +} + +func (c *h3DatagramConn) SetReadDeadline(t time.Time) error { + c.mu.Lock() + c.readDl = t + if c.readCancel != nil { + c.readCancel() + } + c.mu.Unlock() + return nil +} + +func (c *h3DatagramConn) SetWriteDeadline(t time.Time) error { + c.mu.Lock() + c.writeDl = t + c.mu.Unlock() + return nil +} diff --git a/proxy/split.go b/proxy/split.go new file mode 100644 index 00000000..e18b7af6 --- /dev/null +++ b/proxy/split.go @@ -0,0 +1,33 @@ +package proxy + +import ( + "context" + "errors" + "net" + + M "github.com/xjasonlyu/tun2socks/v2/metadata" +) + +// Split pairs two independent proxies so TCP and UDP flows can be routed +// through different backends. This is required for UDP-only backends such +// as MASQUE (RFC 9298) alongside any TCP-capable proxy. +type Split struct { + TCP Proxy + UDP Proxy +} + +var _ Proxy = (*Split)(nil) + +func (s *Split) DialContext(ctx context.Context, metadata *M.Metadata) (net.Conn, error) { + if s.TCP == nil { + return nil, errors.New("split: no TCP proxy configured") + } + return s.TCP.DialContext(ctx, metadata) +} + +func (s *Split) DialUDP(metadata *M.Metadata) (net.PacketConn, error) { + if s.UDP == nil { + return nil, errors.New("split: no UDP proxy configured") + } + return s.UDP.DialUDP(metadata) +}