From 6c5fd3b6db58c11671384c0ac1d344ad8a6e3e25 Mon Sep 17 00:00:00 2001 From: Brayden Habets Date: Fri, 6 Nov 2026 02:07:58 +0000 Subject: [PATCH] feat: add SOCKS5 UDP routing and UDP server tunnel - Route SOCKS5 UDP ASSOCIATE datagrams through TunnelDomains-aware dialer - Upgrade go-socks5 to v0.1.4 for UDP datagram parsing fixes - Add [UDPServerTunnel] for WireGuard-side UDP port forwarding - Share UDP relay response forwarding between client and server tunnels - Add config, relay, netstack, and SOCKS5 UDP tests --- README.md | 28 +++++-- config.go | 74 +++++++++++++++--- config_test.go | 60 +++++++++++++++ go.mod | 2 +- go.sum | 4 +- router_test.go | 39 ++++++++++ routine.go | 35 +++++++-- socks5_udp_test.go | 155 ++++++++++++++++++++++++++++++++++++++ udp_proxy.go | 45 ++++++----- udp_server_tunnel.go | 149 ++++++++++++++++++++++++++++++++++++ udp_server_tunnel_test.go | 140 ++++++++++++++++++++++++++++++++++ 11 files changed, 689 insertions(+), 42 deletions(-) create mode 100644 socks5_udp_test.go create mode 100644 udp_server_tunnel.go create mode 100644 udp_server_tunnel_test.go diff --git a/README.md b/README.md index cd6f0718..dcd1956f 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,14 @@ of wireproxy by [@artem-russkikh](https://github.com/artem-russkikh). # Feature - TCP static routing for client and server -- SOCKS5/HTTP proxy (currently only CONNECT is supported) +- UDP static routing for client and server +- SOCKS5 proxy with TCP `CONNECT` and UDP `ASSOCIATE` +- HTTP proxy (currently only CONNECT is supported) - Transparent TLS ([SNI](https://en.wikipedia.org/wiki/Server_Name_Indication)) proxy -# TODO - -- UDP Support in SOCKS5 -- UDP static routing +SOCKS5 UDP ASSOCIATE is intended for clients that support relaying UDP over +SOCKS5 (for example transparent proxy clients). Most browsers do not use UDP +ASSOCIATE, even when configured with a SOCKS5 proxy. # Usage @@ -128,6 +129,7 @@ Target = localhost:25545 Target = ssh.myserver.net:22 # Socks5 creates a socks5 proxy on your LAN, and all traffic would be routed via wireguard. +# Both TCP CONNECT and UDP ASSOCIATE are supported. [Socks5] BindAddress = 127.0.0.1:25344 @@ -141,6 +143,8 @@ BindAddress = 127.0.0.1:25344 # whose destination host matches one of the patterns are routed through wireguard; # every other connection is dialed directly over your normal network. When # TunnelDomains is unset, all traffic is routed through wireguard (default). +# The same rules apply to UDP ASSOCIATE datagrams, using each datagram's +# destination host. # Each TunnelDomains line is a single, full Go regular expression (RE2). Repeat # the key for multiple patterns; do NOT comma-separate (so quantifiers like {2,4} # keep working). Matching is case-insensitive and a trailing dot is ignored. @@ -229,11 +233,25 @@ Target = service-two.servicenet:5001 ListenPort = 5080 Target = service-three.servicenet:80 +# UDPProxyTunnel is the UDP client tunnel: UDP traffic sent to BindAddress is +# forwarded through WireGuard to Target. +# Flow: +# --> localhost:53 --(wireguard)--> 1.1.1.1:53 [UDPProxyTunnel] BindAddress = 127.0.0.1:53 Target = 1.1.1.1:53 InactivityTimeout = 30 # If its set to 0, it will never timeout +# UDPServerTunnel is the UDP server tunnel: UDP traffic arriving on the +# WireGuard side on ListenPort is forwarded to a target on your local network. +# The listener binds to every address in [Interface] Address. +# Flow: +# --(wireguard)--> ListenPort --> localhost:19132 +[UDPServerTunnel] +ListenPort = 19132 +Target = 127.0.0.1:19132 +InactivityTimeout = 30 # If its set to 0, it will never timeout + [Resolve] # Set DNS Resovle Strategy # `ipv4`: Prioritize A records. diff --git a/config.go b/config.go index 9bf53ff4..e5ef0dc6 100644 --- a/config.go +++ b/config.go @@ -35,12 +35,22 @@ type DeviceConfig struct { CheckAliveInterval int } +// UDPProxyTunnelConfig listens on the local machine and forwards UDP traffic +// through WireGuard to a fixed target. type UDPProxyTunnelConfig struct { BindAddress string Target string InactivityTimeout int } +// UDPServerTunnelConfig listens on the WireGuard side and forwards UDP +// traffic to a target reachable from the local network. +type UDPServerTunnelConfig struct { + ListenPort int + Target string + InactivityTimeout int +} + type TCPClientTunnelConfig struct { BindAddress *net.TCPAddr Target string @@ -565,6 +575,22 @@ func parseResolveConfig(section *ini.Section) (*ResolveConfig, error) { return config, nil } +// parseInactivityTimeout reads the optional InactivityTimeout key shared by +// the UDP tunnel sections. A missing key means "never expire". +func parseInactivityTimeout(section *ini.Section) (int, error) { + if sectionKey, err := section.GetKey("InactivityTimeout"); err == nil { + timeoutVal, err := sectionKey.Int() + if err != nil { + return 0, err + } + if timeoutVal < 0 { + return 0, errors.New("InactivityTimeout should not be negative") + } + return timeoutVal, nil + } + return 0, nil +} + func parseUDPProxyTunnelConfig(section *ini.Section) (RoutineSpawner, error) { config := &UDPProxyTunnelConfig{} @@ -578,18 +604,41 @@ func parseUDPProxyTunnelConfig(section *ini.Section) (RoutineSpawner, error) { if err != nil { return nil, err } + if target == "" { + return nil, errors.New("Target should not be empty") + } config.Target = target - inactivityTimeout := 0 - if sectionKey, err := section.GetKey("InactivityTimeout"); err == nil { - timeoutVal, err := sectionKey.Int() - if err != nil { - return nil, err - } - if timeoutVal < 0 { - return nil, errors.New("InactivityTimeout should not be negative") - } - inactivityTimeout = timeoutVal + inactivityTimeout, err := parseInactivityTimeout(section) + if err != nil { + return nil, err + } + config.InactivityTimeout = inactivityTimeout + + return config, nil +} + +func parseUDPServerTunnelConfig(section *ini.Section) (RoutineSpawner, error) { + config := &UDPServerTunnelConfig{} + + listenPort, err := parsePort(section, "ListenPort") + if err != nil { + return nil, err + } + config.ListenPort = listenPort + + target, err := parseString(section, "Target") + if err != nil { + return nil, err + } + if target == "" { + return nil, errors.New("Target should not be empty") + } + config.Target = target + + inactivityTimeout, err := parseInactivityTimeout(section) + if err != nil { + return nil, err } config.InactivityTimeout = inactivityTimeout @@ -681,6 +730,11 @@ func ParseConfig(path string) (*Configuration, error) { return nil, err } + err = parseRoutinesConfig(&routinesSpawners, cfg, "UDPServerTunnel", parseUDPServerTunnelConfig) + if err != nil { + return nil, err + } + err = parseRoutinesConfig(&routinesSpawners, cfg, "Socks5", parseSocks5Config) if err != nil { return nil, err diff --git a/config_test.go b/config_test.go index be1d36a3..99e00028 100644 --- a/config_test.go +++ b/config_test.go @@ -124,3 +124,63 @@ InactivityTimeout = -1` t.Fatal("expected negative InactivityTimeout to be rejected") } } + +func TestParseUDPServerTunnelConfig(t *testing.T) { + const config = ` +[UDPServerTunnel] +ListenPort = 19132 +Target = 127.0.0.1:19132 +InactivityTimeout = 30` + + iniData, err := loadIniConfig(config) + if err != nil { + t.Fatal(err) + } + + spawner, err := parseUDPServerTunnelConfig(iniData.Section("UDPServerTunnel")) + if err != nil { + t.Fatal(err) + } + + cfg, ok := spawner.(*UDPServerTunnelConfig) + if !ok { + t.Fatalf("unexpected spawner type %T", spawner) + } + if cfg.ListenPort != 19132 { + t.Fatalf("ListenPort = %d, want 19132", cfg.ListenPort) + } + if cfg.Target != "127.0.0.1:19132" { + t.Fatalf("Target = %q, want 127.0.0.1:19132", cfg.Target) + } + if cfg.InactivityTimeout != 30 { + t.Fatalf("InactivityTimeout = %d, want 30", cfg.InactivityTimeout) + } +} + +func TestParseUDPServerTunnelRejectsInvalidConfig(t *testing.T) { + cases := map[string]string{ + "missing listen port": ` +[UDPServerTunnel] +Target = 127.0.0.1:19132`, + "missing target": ` +[UDPServerTunnel] +ListenPort = 19132`, + "negative timeout": ` +[UDPServerTunnel] +ListenPort = 19132 +Target = 127.0.0.1:19132 +InactivityTimeout = -1`, + } + + for name, config := range cases { + t.Run(name, func(t *testing.T) { + iniData, err := loadIniConfig(config) + if err != nil { + t.Fatal(err) + } + if _, err := parseUDPServerTunnelConfig(iniData.Section("UDPServerTunnel")); err == nil { + t.Fatal("expected invalid config to be rejected") + } + }) + } +} diff --git a/go.mod b/go.mod index 3dc3cf69..cc0f572c 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/akamensky/argparse v1.4.0 github.com/go-ini/ini v1.67.0 github.com/landlock-lsm/go-landlock v0.6.0 - github.com/things-go/go-socks5 v0.1.3 + github.com/things-go/go-socks5 v0.1.4 golang.org/x/net v0.55.0 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb suah.dev/protect v1.2.3 diff --git a/go.sum b/go.sum index f2e099b2..5f474725 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ github.com/landlock-lsm/go-landlock v0.6.0 h1:KwHctSfiTmEw12jeCBK0lryabdlFR7YvH3 github.com/landlock-lsm/go-landlock v0.6.0/go.mod h1:mn5GSi81Jf7yMs5WSi+SUi4sUeNLUGVdbT4Id6wXNQw= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/things-go/go-socks5 v0.1.3 h1:W6lC9bUiuubcVVGU5BRRrwUP6k/7HEwrJm5O+mIUHDE= -github.com/things-go/go-socks5 v0.1.3/go.mod h1:LZ99kKdQKnpwKv9P1Ilowna9cafg3cmYB/Dr1iagwDg= +github.com/things-go/go-socks5 v0.1.4 h1:DF0OvKL4O1pxmSh2J/suagjyGV7+hduBblpldPOuVSM= +github.com/things-go/go-socks5 v0.1.4/go.mod h1:LZ99kKdQKnpwKv9P1Ilowna9cafg3cmYB/Dr1iagwDg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= diff --git a/router_test.go b/router_test.go index 0903df81..ea990ebc 100644 --- a/router_test.go +++ b/router_test.go @@ -1,6 +1,9 @@ package wireproxy import ( + "context" + "io" + "net" "regexp" "testing" ) @@ -134,6 +137,42 @@ TunnelDomains = ^(unclosed` } } +func TestRoutedDialContextDialsDirect(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + _, _ = conn.Write([]byte("direct")) + }() + + // A zero VirtualTun is enough: a non-matching destination must never touch + // netstack. If this regresses, the nil Tnet will panic. + vt := VirtualTun{} + router := NewDomainRouter(mustCompile(t, `^tunnel\.invalid$`), false) + + conn, err := vt.RoutedDialContext(router)(context.Background(), "tcp", listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = conn.Close() }() + + got := make([]byte, len("direct")) + if _, err := io.ReadFull(conn, got); err != nil { + t.Fatal(err) + } + if string(got) != "direct" { + t.Fatalf("got %q, want %q", got, "direct") + } +} + func TestParseConfigDefaultsNoRouting(t *testing.T) { const config = ` [SNI] diff --git a/routine.go b/routine.go index 731d0175..0708865e 100644 --- a/routine.go +++ b/routine.go @@ -166,15 +166,26 @@ func (passthroughResolver) Resolve(ctx context.Context, name string) (context.Co return ctx, nil, nil } +// RoutedDialContext returns a context-aware dialer that sends whitelisted +// hosts through the tunnel and dials everything else directly. It is used by +// the SOCKS5 UDP relay, where each datagram is dialed independently. +func (d VirtualTun) RoutedDialContext(router *DomainRouter) func(context.Context, string, string) (net.Conn, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + if router.route(hostFromAddr(address)) { + return d.Tnet.DialContext(ctx, network, address) + } + var dialer net.Dialer + return dialer.DialContext(ctx, network, address) + } +} + // RoutedDial returns a dialer that sends whitelisted hosts through the tunnel // and dials everything else directly. Used by the HTTP and SNI proxies, whose // dial callbacks receive the destination hostname intact. func (d VirtualTun) RoutedDial(router *DomainRouter) func(network, address string) (net.Conn, error) { + dial := d.RoutedDialContext(router) return func(network, address string) (net.Conn, error) { - if router.route(hostFromAddr(address)) { - return d.Tnet.Dial(network, address) - } - return net.Dial(network, address) + return dial(context.Background(), network, address) } } @@ -195,8 +206,10 @@ func (d VirtualTun) routedSocks5Dial(router *DomainRouter) func(context.Context, } } -// SpawnRoutine spawns a socks5 server. -func (config *Socks5Config) SpawnRoutine(vt *VirtualTun) { +// newServer builds the socks5 server. WithDial is used by both CONNECT and +// UDP ASSOCIATE, while WithDialAndRequest lets CONNECT make its routing +// decision using the original request FQDN. +func (config *Socks5Config) newServer(vt *VirtualTun) *socks5.Server { var authMethods []socks5.Authenticator if username := config.Username; username != "" { authMethods = append(authMethods, socks5.UserPassAuthenticator{ @@ -219,14 +232,22 @@ func (config *Socks5Config) SpawnRoutine(vt *VirtualTun) { ) } else { // Split-routing path: keep the FQDN so we can decide per connection. + // WithDial covers UDP ASSOCIATE datagrams, whose destination is only + // known per datagram. router := NewDomainRouter(config.TunnelDomains, config.LogDomains) options = append(options, + socks5.WithDial(vt.RoutedDialContext(router)), socks5.WithDialAndRequest(vt.routedSocks5Dial(router)), socks5.WithResolver(passthroughResolver{}), ) } - server := socks5.NewServer(options...) + return socks5.NewServer(options...) +} + +// SpawnRoutine spawns a socks5 server. +func (config *Socks5Config) SpawnRoutine(vt *VirtualTun) { + server := config.newServer(vt) if err := server.ListenAndServe("tcp", config.BindAddress); err != nil { log.Fatal(err) diff --git a/socks5_udp_test.go b/socks5_udp_test.go new file mode 100644 index 00000000..0dbcaf77 --- /dev/null +++ b/socks5_udp_test.go @@ -0,0 +1,155 @@ +package wireproxy + +import ( + "net" + "net/netip" + "regexp" + "testing" + "time" + + "github.com/things-go/go-socks5/statute" + "golang.zx2c4.com/wireguard/tun/netstack" +) + +// socks5UDPRoundTrip performs a SOCKS5 UDP ASSOCIATE handshake, sends one +// datagram to destination, and verifies that the echoed payload comes back +// through the relay. +func socks5UDPRoundTrip(t *testing.T, config *Socks5Config, vt *VirtualTun, destination string) { + t.Helper() + + server := config.newServer(vt) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + + go func() { + _ = server.Serve(listener) + }() + + control, err := net.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = control.Close() }() + + if _, err := control.Write(statute.NewMethodRequest(statute.VersionSocks5, []byte{statute.MethodNoAuth}).Bytes()); err != nil { + t.Fatal(err) + } + methodReply, err := statute.ParseMethodReply(control) + if err != nil { + t.Fatal(err) + } + if methodReply.Method != statute.MethodNoAuth { + t.Fatalf("negotiated method = %d, want no-auth", methodReply.Method) + } + + associate := statute.Request{ + Version: statute.VersionSocks5, + Command: statute.CommandAssociate, + DstAddr: statute.AddrSpec{ + AddrType: statute.ATYPIPv4, + IP: net.IPv4zero, + Port: 0, + }, + } + if _, err := control.Write(associate.Bytes()); err != nil { + t.Fatal(err) + } + reply, err := statute.ParseReply(control) + if err != nil { + t.Fatal(err) + } + if reply.Response != statute.RepSuccess { + t.Fatalf("UDP ASSOCIATE reply = %d, want success", reply.Response) + } + + client, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = client.Close() }() + + relay, err := net.ResolveUDPAddr("udp", reply.BndAddr.String()) + if err != nil { + t.Fatal(err) + } + + payload := []byte("hello over socks5 udp") + datagram, err := statute.NewDatagram(destination, payload) + if err != nil { + t.Fatal(err) + } + if _, err := client.WriteToUDP(datagram.Bytes(), relay); err != nil { + t.Fatal(err) + } + + if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatal(err) + } + buf := make([]byte, 64*1024) + n, _, err := client.ReadFromUDP(buf) + if err != nil { + t.Fatalf("reading UDP ASSOCIATE response: %v", err) + } + + response, err := statute.ParseDatagram(buf[:n]) + if err != nil { + t.Fatal(err) + } + if string(response.Data) != string(payload) { + t.Fatalf("response payload = %q, want %q", response.Data, payload) + } +} + +// TestSocks5UDPAssociateForwards exercises the split-routing path with a +// destination that does not match TunnelDomains, so it is dialed directly. +func TestSocks5UDPAssociateForwards(t *testing.T) { + echo := startUDPEcho(t) + + config := &Socks5Config{ + TunnelDomains: []*regexp.Regexp{regexp.MustCompile(`^tunnel\.invalid$`)}, + } + socks5UDPRoundTrip(t, config, &VirtualTun{}, echo.LocalAddr().String()) +} + +// TestSocks5UDPAssociateTunnelsDatagram exercises the split-routing path with a +// destination that matches TunnelDomains, so the datagram is dialed through +// netstack just as it would be through WireGuard. +func TestSocks5UDPAssociateTunnelsDatagram(t *testing.T) { + deviceAddr := netip.MustParseAddr("10.99.0.2") + _, tnet, err := netstack.CreateNetTUN([]netip.Addr{deviceAddr}, nil, 1420) + if err != nil { + t.Fatal(err) + } + + echo, err := tnet.ListenUDP(&net.UDPAddr{IP: net.IP(deviceAddr.AsSlice())}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = echo.Close() }() + + go func() { + buf := make([]byte, 64*1024) + for { + n, addr, err := echo.ReadFrom(buf) + if err != nil { + return + } + if _, err := echo.WriteTo(buf[:n], addr); err != nil { + return + } + } + }() + + config := &Socks5Config{ + TunnelDomains: []*regexp.Regexp{regexp.MustCompile(`^10\.99\.0\.2$`)}, + } + vt := &VirtualTun{ + Tnet: tnet, + Conf: &DeviceConfig{Endpoint: []netip.Addr{deviceAddr}}, + } + + socks5UDPRoundTrip(t, config, vt, echo.LocalAddr().String()) +} diff --git a/udp_proxy.go b/udp_proxy.go index 5cb3ae7b..bf307076 100644 --- a/udp_proxy.go +++ b/udp_proxy.go @@ -1,6 +1,7 @@ package wireproxy import ( + "errors" "fmt" "log" "net" @@ -142,14 +143,13 @@ func (conf *UDPProxyTunnelConfig) SpawnRoutine(vt *VirtualTun) { }() } -// handles data from the remote WireGuard side back to the local client -// this function blocks until the session is closed -func (conf *UDPProxyTunnelConfig) handleRemoteToLocal(listener *net.UDPConn, srcAddr string, s *udpSession, removeSession func(string, *udpSession)) { - defer func() { - removeSession(srcAddr, s) - }() - buf := make([]byte, 64*1024) +// forwardRemoteToPeer copies datagrams received from s.remoteConn back to the +// peer that owns the listener-side session. It blocks until the session is +// closed or the remote connection fails, and always calls onDone on exit. +func forwardRemoteToPeer(listener net.PacketConn, peer net.Addr, s *udpSession, onDone func()) { + defer onDone() + buf := make([]byte, 64*1024) for { select { case <-s.closeChan: @@ -158,7 +158,7 @@ func (conf *UDPProxyTunnelConfig) handleRemoteToLocal(listener *net.UDPConn, src } if err := s.remoteConn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { - errorLogger.Printf("UDPProxyTunnel: could not set remote read deadline: %v", err) + errorLogger.Printf("UDP relay: could not set remote read deadline: %v", err) return } n, err := s.remoteConn.Read(buf) @@ -172,22 +172,33 @@ func (conf *UDPProxyTunnelConfig) handleRemoteToLocal(listener *net.UDPConn, src continue } } - errorLogger.Printf("UDPProxyTunnel: read error from remote: %v", err) + if errors.Is(err, net.ErrClosed) { + return + } + errorLogger.Printf("UDP relay: read error from remote: %v", err) return } s.touch() - dstUDPAddr, err := net.ResolveUDPAddr("udp", srcAddr) - if err != nil { - errorLogger.Printf("UDPProxyTunnel: cannot resolve local address %s: %v", srcAddr, err) + if _, err := listener.WriteTo(buf[:n], peer); err != nil { + errorLogger.Printf("UDP relay: cannot write to peer %s: %v", peer, err) return } + } +} - _, err = listener.WriteToUDP(buf[:n], dstUDPAddr) - if err != nil { - errorLogger.Printf("UDPProxyTunnel: cannot write to local %s: %v", srcAddr, err) - return - } +// handles data from the remote WireGuard side back to the local client +// this function blocks until the session is closed +func (conf *UDPProxyTunnelConfig) handleRemoteToLocal(listener *net.UDPConn, srcAddr string, s *udpSession, removeSession func(string, *udpSession)) { + dstUDPAddr, err := net.ResolveUDPAddr("udp", srcAddr) + if err != nil { + errorLogger.Printf("UDPProxyTunnel: cannot resolve local address %s: %v", srcAddr, err) + removeSession(srcAddr, s) + return } + + forwardRemoteToPeer(listener, dstUDPAddr, s, func() { + removeSession(srcAddr, s) + }) } diff --git a/udp_server_tunnel.go b/udp_server_tunnel.go new file mode 100644 index 00000000..7a2b3a6b --- /dev/null +++ b/udp_server_tunnel.go @@ -0,0 +1,149 @@ +package wireproxy + +import ( + "errors" + "log" + "net" + "sync" + "time" +) + +// SpawnRoutine spawns a UDP listener on the WireGuard side and forwards each +// remote peer to the configured local target. +// Flow: --(wireguard)--> ListenPort --> Target (local network) +func (conf *UDPServerTunnelConfig) SpawnRoutine(vt *VirtualTun) { + target, err := net.ResolveUDPAddr("udp", conf.Target) + if err != nil { + log.Fatalf("UDPServerTunnel: could not resolve target %s: %v", conf.Target, err) + } + + var addrs []net.IP + if vt.Conf != nil { + for _, addr := range vt.Conf.Endpoint { + addrs = append(addrs, net.IP(addr.AsSlice())) + } + } + if len(addrs) == 0 { + log.Fatal("UDPServerTunnel: no WireGuard interface address configured") + } + + inactivityDur := time.Duration(conf.InactivityTimeout) * time.Second + + started := 0 + for _, addr := range addrs { + listener, err := vt.Tnet.ListenUDP(&net.UDPAddr{IP: addr, Port: conf.ListenPort}) + if err != nil { + errorLogger.Printf("UDPServerTunnel: could not listen on WireGuard address %s port %d: %v", addr, conf.ListenPort, err) + continue + } + + started++ + log.Printf("UDPServerTunnel listening on %s, forwarding to %s", listener.LocalAddr(), conf.Target) + go serveUDPServerTunnel(listener, target, inactivityDur) + } + + if started == 0 { + log.Fatal("UDPServerTunnel: could not listen on any WireGuard interface address") + } +} + +// serveUDPServerTunnel relays datagrams between WireGuard peers and a local +// target. Each remote peer owns one connected local UDP socket so replies can +// be matched back to the correct peer. +func serveUDPServerTunnel(listener net.PacketConn, target *net.UDPAddr, inactivityDur time.Duration) { + sessions := make(map[string]*udpSession) + var sessionMu sync.Mutex + done := make(chan struct{}) + + removeSession := func(key string, sess *udpSession) { + sessionMu.Lock() + if current, ok := sessions[key]; ok && current == sess { + current.close() + delete(sessions, key) + } + sessionMu.Unlock() + } + + defer func() { + close(done) + sessionMu.Lock() + for key, sess := range sessions { + sess.close() + delete(sessions, key) + } + sessionMu.Unlock() + }() + + if inactivityDur > 0 { + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-done: + return + case <-ticker.C: + } + + now := time.Now() + sessionMu.Lock() + for key, sess := range sessions { + if sess.inactive(now) { + log.Printf("UDPServerTunnel: closing inactive session for %s", key) + sess.close() + delete(sessions, key) + } + } + sessionMu.Unlock() + } + }() + } + + buf := make([]byte, 64*1024) + for { + n, src, err := listener.ReadFrom(buf) + if err != nil { + if errors.Is(err, net.ErrClosed) { + return + } + errorLogger.Printf("UDPServerTunnel: error reading from WireGuard: %v", err) + continue + } + + key := src.String() + + sessionMu.Lock() + sess := sessions[key] + sessionMu.Unlock() + + if sess == nil { + localConn, err := net.DialUDP("udp", nil, target) + if err != nil { + errorLogger.Printf("UDPServerTunnel: could not dial target %s: %v", target, err) + continue + } + + sess = &udpSession{ + remoteConn: localConn, + lastActive: time.Now(), + closeChan: make(chan struct{}), + inactivityDur: inactivityDur, + } + + sessionMu.Lock() + sessions[key] = sess + sessionMu.Unlock() + + go forwardRemoteToPeer(listener, src, sess, func() { + removeSession(key, sess) + }) + } else { + sess.touch() + } + + if _, err := sess.remoteConn.Write(buf[:n]); err != nil { + errorLogger.Printf("UDPServerTunnel: could not write to target %s: %v", target, err) + removeSession(key, sess) + } + } +} diff --git a/udp_server_tunnel_test.go b/udp_server_tunnel_test.go new file mode 100644 index 00000000..44177471 --- /dev/null +++ b/udp_server_tunnel_test.go @@ -0,0 +1,140 @@ +package wireproxy + +import ( + "net" + "net/netip" + "testing" + "time" + + "golang.zx2c4.com/wireguard/tun/netstack" +) + +// startUDPEcho starts a loopback UDP echo server and returns its listener. +func startUDPEcho(t *testing.T) *net.UDPConn { + t.Helper() + + conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + + go func() { + buf := make([]byte, 64*1024) + for { + n, addr, err := conn.ReadFromUDP(buf) + if err != nil { + return + } + if _, err := conn.WriteToUDP(buf[:n], addr); err != nil { + return + } + } + }() + + return conn +} + +func TestServeUDPServerTunnelForwards(t *testing.T) { + target := startUDPEcho(t) + + // Stand in for the WireGuard-side packet listener. + listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + + go serveUDPServerTunnel(listener, target.LocalAddr().(*net.UDPAddr), 0) + + peer, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = peer.Close() }() + + payloads := [][]byte{[]byte("hello"), []byte("second datagram")} + for _, payload := range payloads { + if _, err := peer.WriteToUDP(payload, listener.LocalAddr().(*net.UDPAddr)); err != nil { + t.Fatal(err) + } + + if err := peer.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatal(err) + } + buf := make([]byte, 64*1024) + n, _, err := peer.ReadFromUDP(buf) + if err != nil { + t.Fatalf("reading forwarded response: %v", err) + } + if got := string(buf[:n]); got != string(payload) { + t.Fatalf("got %q, want %q", got, payload) + } + } +} + +// TestServeUDPServerTunnelOverNetstack verifies that the relay works with a +// listener created on the WireGuard netstack, which is how SpawnRoutine binds +// the server side. +func TestServeUDPServerTunnelOverNetstack(t *testing.T) { + target := startUDPEcho(t) + + deviceAddr := netip.MustParseAddr("10.99.0.2") + _, tnet, err := netstack.CreateNetTUN([]netip.Addr{deviceAddr}, nil, 1420) + if err != nil { + t.Fatal(err) + } + + listener, err := tnet.ListenUDP(&net.UDPAddr{IP: net.IP(deviceAddr.AsSlice()), Port: 0}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = listener.Close() }() + + go serveUDPServerTunnel(listener, target.LocalAddr().(*net.UDPAddr), 0) + + client, err := tnet.DialUDP(nil, listener.LocalAddr().(*net.UDPAddr)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = client.Close() }() + + if _, err := client.Write([]byte("netstack")); err != nil { + t.Fatal(err) + } + if err := client.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatal(err) + } + buf := make([]byte, 64*1024) + n, err := client.Read(buf) + if err != nil { + t.Fatalf("reading netstack response: %v", err) + } + if got := string(buf[:n]); got != "netstack" { + t.Fatalf("got %q, want %q", got, "netstack") + } +} + +func TestServeUDPServerTunnelReturnsWhenListenerCloses(t *testing.T) { + target := startUDPEcho(t) + listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + + done := make(chan struct{}) + go func() { + serveUDPServerTunnel(listener, target.LocalAddr().(*net.UDPAddr), 0) + close(done) + }() + + if err := listener.Close(); err != nil { + t.Fatal(err) + } + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("serveUDPServerTunnel did not return after listener close") + } +}