From 628102776c1ee59f209f51d0ec3b58b0ad34adfe Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 27 Feb 2026 10:41:25 -0700 Subject: [PATCH 1/5] Add UoT (UDP-over-TCP) support to egress SOCKS5 server The sing-box UoT protocol tunnels UDP packets over TCP using a magic FQDN (sp.v2.udp-over-tcp.arpa). The egress SOCKS5 server now intercepts these addresses and relays framed UDP packets between the TCP stream and a real UDP socket. Key components: - UoTResolver: passes UoT magic addresses through without DNS resolution, fixing go-socks5's default behavior of resolving FQDNs before Dial - UoTDialer: intercepts UoT addresses in the SOCKS5 Dial function and sets up the UDP relay via net.Pipe - tcpPipeConn: wraps net.Pipe to return *net.TCPAddr for go-socks5 compatibility Co-Authored-By: Claude Opus 4.6 --- egress/cmd/socks5/egress_socks5.go | 5 +- egress/uot.go | 220 +++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 egress/uot.go diff --git a/egress/cmd/socks5/egress_socks5.go b/egress/cmd/socks5/egress_socks5.go index cd092d1..6174088 100644 --- a/egress/cmd/socks5/egress_socks5.go +++ b/egress/cmd/socks5/egress_socks5.go @@ -43,7 +43,10 @@ func main() { } defer ll.Close() - conf := &socks5.Config{} + conf := &socks5.Config{ + Dial: egress.UoTDialer(), + Resolver: &egress.UoTResolver{}, + } proxy, err := socks5.New(conf) if err != nil { panic(err) diff --git a/egress/uot.go b/egress/uot.go new file mode 100644 index 0000000..bad99ea --- /dev/null +++ b/egress/uot.go @@ -0,0 +1,220 @@ +package egress + +import ( + "context" + "encoding/binary" + "fmt" + "io" + "net" + + "github.com/getlantern/broflake/common" +) + +const ( + uotMagicAddress = "sp.v2.udp-over-tcp.arpa" + uotLegacyMagicAddress = "sp.udp-over-tcp.arpa" + + // SOCKS5 address types (used in UoT v2 request header via SocksaddrSerializer) + socksAddrTypeIPv4 = 0x01 + socksAddrTypeFQDN = 0x03 + socksAddrTypeIPv6 = 0x04 +) + +// UoTResolver is a SOCKS5 DNS resolver that passes UoT magic addresses through +// without resolution, falling back to standard DNS for everything else. +// go-socks5 resolves FQDNs before calling Dial, so we must intercept here. +type UoTResolver struct{} + +func (r *UoTResolver) Resolve(ctx context.Context, name string) (context.Context, net.IP, error) { + if name == uotMagicAddress || name == uotLegacyMagicAddress { + // Return nil IP so AddrSpec.Address() falls through to the FQDN, + // allowing the Dial function to match on the magic address. + return ctx, nil, nil + } + addr, err := net.ResolveIPAddr("ip", name) + if err != nil { + return ctx, nil, err + } + return ctx, addr.IP, nil +} + +func isUoTAddress(addr string) bool { + host := addr + if h, _, err := net.SplitHostPort(addr); err == nil { + host = h + } + return host == uotMagicAddress || host == uotLegacyMagicAddress +} + +// tcpPipeConn wraps a net.Conn (from net.Pipe) so that LocalAddr() returns a +// *net.TCPAddr. go-socks5 does target.LocalAddr().(*net.TCPAddr) after Dial. +type tcpPipeConn struct { + net.Conn +} + +func (c *tcpPipeConn) LocalAddr() net.Addr { + return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} +} + +func (c *tcpPipeConn) RemoteAddr() net.Addr { + return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} +} + +type uotRequest struct { + IsConnect bool + Destination *net.UDPAddr +} + +// readUoTRequest reads a UoT v2 request header. +// Wire format: 1 byte IsConnect (bool) + SOCKS5-style address (ATYP + addr + port). +func readUoTRequest(r io.Reader) (*uotRequest, error) { + var isConnect byte + if err := binary.Read(r, binary.BigEndian, &isConnect); err != nil { + return nil, fmt.Errorf("read IsConnect: %w", err) + } + + addr, err := readSocksAddr(r) + if err != nil { + return nil, fmt.Errorf("read destination: %w", err) + } + + return &uotRequest{ + IsConnect: isConnect != 0, + Destination: addr, + }, nil +} + +// readSocksAddr reads a SOCKS5-style address: ATYP + address + 2-byte port (big-endian). +func readSocksAddr(r io.Reader) (*net.UDPAddr, error) { + var atyp byte + if err := binary.Read(r, binary.BigEndian, &atyp); err != nil { + return nil, err + } + + var ip net.IP + var host string + + switch atyp { + case socksAddrTypeIPv4: + ip = make(net.IP, 4) + if _, err := io.ReadFull(r, ip); err != nil { + return nil, err + } + case socksAddrTypeIPv6: + ip = make(net.IP, 16) + if _, err := io.ReadFull(r, ip); err != nil { + return nil, err + } + case socksAddrTypeFQDN: + var length byte + if err := binary.Read(r, binary.BigEndian, &length); err != nil { + return nil, err + } + domain := make([]byte, length) + if _, err := io.ReadFull(r, domain); err != nil { + return nil, err + } + host = string(domain) + default: + return nil, fmt.Errorf("unknown address type: %d", atyp) + } + + var port uint16 + if err := binary.Read(r, binary.BigEndian, &port); err != nil { + return nil, err + } + + if host != "" { + resolved, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", host, port)) + if err != nil { + return nil, err + } + return resolved, nil + } + + return &net.UDPAddr{IP: ip, Port: int(port)}, nil +} + +// handleUoT handles a UoT v2 connection by relaying framed UDP packets between +// a TCP stream and a real UDP socket. +func handleUoT(tcpConn net.Conn) { + defer tcpConn.Close() + + req, err := readUoTRequest(tcpConn) + if err != nil { + common.Debugf("UoT: failed to read request: %v", err) + return + } + + common.Debugf("UoT: relay to %v (isConnect=%v)", req.Destination, req.IsConnect) + + if !req.IsConnect { + common.Debugf("UoT: non-connect mode not supported") + return + } + + udpConn, err := net.DialUDP("udp", nil, req.Destination) + if err != nil { + common.Debugf("UoT: failed to dial UDP %v: %v", req.Destination, err) + return + } + defer udpConn.Close() + + common.Debugf("UoT: connected UDP to %v", req.Destination) + + done := make(chan struct{}, 2) + + // TCP → UDP: read length-prefixed packets from TCP, send as raw UDP + go func() { + defer func() { done <- struct{}{} }() + buf := make([]byte, 65535) + for { + var length uint16 + if err := binary.Read(tcpConn, binary.BigEndian, &length); err != nil { + return + } + if _, err := io.ReadFull(tcpConn, buf[:length]); err != nil { + return + } + if _, err := udpConn.Write(buf[:length]); err != nil { + return + } + } + }() + + // UDP → TCP: read raw UDP, write as length-prefixed packets to TCP + go func() { + defer func() { done <- struct{}{} }() + buf := make([]byte, 65535) + for { + n, err := udpConn.Read(buf) + if err != nil { + return + } + if err := binary.Write(tcpConn, binary.BigEndian, uint16(n)); err != nil { + return + } + if _, err := tcpConn.Write(buf[:n]); err != nil { + return + } + } + }() + + <-done +} + +// UoTDialer returns a SOCKS5-compatible dial function that intercepts UoT magic +// addresses and handles them as UDP-over-TCP tunnels, while dialing everything +// else normally. +func UoTDialer() func(ctx context.Context, network, addr string) (net.Conn, error) { + return func(ctx context.Context, network, addr string) (net.Conn, error) { + if isUoTAddress(addr) { + common.Debugf("UoT: intercepting %v", addr) + client, server := net.Pipe() + go handleUoT(server) + return &tcpPipeConn{Conn: client}, nil + } + var d net.Dialer + return d.DialContext(ctx, network, addr) + } +} From 862e98486cdca5d8478f75eef2d5ad03db307ab9 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 27 Feb 2026 11:14:35 -0700 Subject: [PATCH 2/5] Address PR review feedback - Use net.DefaultResolver.LookupIPAddr with context for cancellation-aware DNS - Normalize UoT magic address matching (case-insensitive, trailing dot) - Add max UDP payload size check (65507) with oversized frame discard - Tie UoT pipe lifecycle to dialing context via ctx.Done() goroutine Co-Authored-By: Claude Opus 4.6 --- egress/uot.go | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/egress/uot.go b/egress/uot.go index bad99ea..74e8375 100644 --- a/egress/uot.go +++ b/egress/uot.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net" + "strings" "github.com/getlantern/broflake/common" ) @@ -18,6 +19,8 @@ const ( socksAddrTypeIPv4 = 0x01 socksAddrTypeFQDN = 0x03 socksAddrTypeIPv6 = 0x04 + + maxUDPPayload = 65507 ) // UoTResolver is a SOCKS5 DNS resolver that passes UoT magic addresses through @@ -26,16 +29,24 @@ const ( type UoTResolver struct{} func (r *UoTResolver) Resolve(ctx context.Context, name string) (context.Context, net.IP, error) { - if name == uotMagicAddress || name == uotLegacyMagicAddress { + if isUoTMagicName(name) { // Return nil IP so AddrSpec.Address() falls through to the FQDN, // allowing the Dial function to match on the magic address. return ctx, nil, nil } - addr, err := net.ResolveIPAddr("ip", name) + ips, err := net.DefaultResolver.LookupIPAddr(ctx, name) if err != nil { return ctx, nil, err } - return ctx, addr.IP, nil + if len(ips) == 0 { + return ctx, nil, fmt.Errorf("no IP addresses found for %s", name) + } + return ctx, ips[0].IP, nil +} + +func isUoTMagicName(name string) bool { + name = strings.TrimSuffix(name, ".") + return strings.EqualFold(name, uotMagicAddress) || strings.EqualFold(name, uotLegacyMagicAddress) } func isUoTAddress(addr string) bool { @@ -43,7 +54,7 @@ func isUoTAddress(addr string) bool { if h, _, err := net.SplitHostPort(addr); err == nil { host = h } - return host == uotMagicAddress || host == uotLegacyMagicAddress + return isUoTMagicName(host) } // tcpPipeConn wraps a net.Conn (from net.Pipe) so that LocalAddr() returns a @@ -173,6 +184,13 @@ func handleUoT(tcpConn net.Conn) { if err := binary.Read(tcpConn, binary.BigEndian, &length); err != nil { return } + if int(length) > maxUDPPayload { + common.Debugf("UoT: dropping oversized frame (%d bytes, max %d)", length, maxUDPPayload) + if _, err := io.CopyN(io.Discard, tcpConn, int64(length)); err != nil { + return + } + continue + } if _, err := io.ReadFull(tcpConn, buf[:length]); err != nil { return } @@ -211,6 +229,11 @@ func UoTDialer() func(ctx context.Context, network, addr string) (net.Conn, erro if isUoTAddress(addr) { common.Debugf("UoT: intercepting %v", addr) client, server := net.Pipe() + go func() { + <-ctx.Done() + client.Close() + server.Close() + }() go handleUoT(server) return &tcpPipeConn{Conn: client}, nil } From f9e0e4e536e44e3e464ac4de7aa5814a2146853a Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Fri, 27 Feb 2026 12:20:15 -0700 Subject: [PATCH 3/5] Address second round of PR review feedback - Log errors in relay goroutines instead of silently discarding - Close both TCP and UDP connections when either relay direction exits, preventing goroutine leaks - Use maxUDPPayload-sized buffers instead of 65535 for consistency - Validate UDP read size fits uint16 before casting - Block loopback destinations to prevent local network access - Use context-aware DNS with 10s timeout in readSocksAddr - Fix context cancellation goroutine leak by selecting on connDone channel so the goroutine exits when handleUoT completes naturally - Log CopyN errors when discarding oversized frames Co-Authored-By: Claude Opus 4.6 --- egress/uot.go | 58 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/egress/uot.go b/egress/uot.go index 74e8375..d2936f4 100644 --- a/egress/uot.go +++ b/egress/uot.go @@ -7,6 +7,7 @@ import ( "io" "net" "strings" + "time" "github.com/getlantern/broflake/common" ) @@ -136,11 +137,16 @@ func readSocksAddr(r io.Reader) (*net.UDPAddr, error) { } if host != "" { - resolved, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", host, port)) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { - return nil, err + return nil, fmt.Errorf("resolve %s: %w", host, err) + } + if len(ips) == 0 { + return nil, fmt.Errorf("no IP addresses found for %s", host) } - return resolved, nil + return &net.UDPAddr{IP: ips[0].IP, Port: int(port)}, nil } return &net.UDPAddr{IP: ip, Port: int(port)}, nil @@ -164,6 +170,11 @@ func handleUoT(tcpConn net.Conn) { return } + if req.Destination.IP.IsLoopback() { + common.Debugf("UoT: refusing to relay to loopback address %v", req.Destination) + return + } + udpConn, err := net.DialUDP("udp", nil, req.Destination) if err != nil { common.Debugf("UoT: failed to dial UDP %v: %v", req.Destination, err) @@ -175,26 +186,37 @@ func handleUoT(tcpConn net.Conn) { done := make(chan struct{}, 2) + // When one direction exits, close both connections to unblock the other goroutine. + cleanup := func() { + tcpConn.Close() + udpConn.Close() + done <- struct{}{} + } + // TCP → UDP: read length-prefixed packets from TCP, send as raw UDP go func() { - defer func() { done <- struct{}{} }() - buf := make([]byte, 65535) + defer cleanup() + buf := make([]byte, maxUDPPayload) for { var length uint16 if err := binary.Read(tcpConn, binary.BigEndian, &length); err != nil { + common.Debugf("UoT: TCP read length error: %v", err) return } if int(length) > maxUDPPayload { common.Debugf("UoT: dropping oversized frame (%d bytes, max %d)", length, maxUDPPayload) if _, err := io.CopyN(io.Discard, tcpConn, int64(length)); err != nil { + common.Debugf("UoT: TCP discard error: %v", err) return } continue } if _, err := io.ReadFull(tcpConn, buf[:length]); err != nil { + common.Debugf("UoT: TCP read payload error: %v", err) return } if _, err := udpConn.Write(buf[:length]); err != nil { + common.Debugf("UoT: UDP write error: %v", err) return } } @@ -202,17 +224,24 @@ func handleUoT(tcpConn net.Conn) { // UDP → TCP: read raw UDP, write as length-prefixed packets to TCP go func() { - defer func() { done <- struct{}{} }() - buf := make([]byte, 65535) + defer cleanup() + buf := make([]byte, maxUDPPayload) for { n, err := udpConn.Read(buf) if err != nil { + common.Debugf("UoT: UDP read error: %v", err) return } + if n > maxUDPPayload { + common.Debugf("UoT: dropping oversized UDP packet (%d bytes)", n) + continue + } if err := binary.Write(tcpConn, binary.BigEndian, uint16(n)); err != nil { + common.Debugf("UoT: TCP write length error: %v", err) return } if _, err := tcpConn.Write(buf[:n]); err != nil { + common.Debugf("UoT: TCP write payload error: %v", err) return } } @@ -229,12 +258,19 @@ func UoTDialer() func(ctx context.Context, network, addr string) (net.Conn, erro if isUoTAddress(addr) { common.Debugf("UoT: intercepting %v", addr) client, server := net.Pipe() + connDone := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + client.Close() + server.Close() + case <-connDone: + } + }() go func() { - <-ctx.Done() - client.Close() - server.Close() + handleUoT(server) + close(connDone) }() - go handleUoT(server) return &tcpPipeConn{Conn: client}, nil } var d net.Dialer From 56e1ed17be6efb4e77116150ed40ae4943e3c5e3 Mon Sep 17 00:00:00 2001 From: Noah Levenson Date: Sun, 1 Mar 2026 12:09:22 -0800 Subject: [PATCH 4/5] revert changes to socks5 egress server, move new sing-box egress server to its own home --- egress/cmd/sing-box/egress_sing_box.go | 61 ++++++++++++++++++++++++++ egress/{ => cmd/sing-box}/uot.go | 2 +- egress/cmd/socks5/egress_socks5.go | 5 +-- 3 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 egress/cmd/sing-box/egress_sing_box.go rename egress/{ => cmd/sing-box}/uot.go (99%) diff --git a/egress/cmd/sing-box/egress_sing_box.go b/egress/cmd/sing-box/egress_sing_box.go new file mode 100644 index 0000000..79c1a80 --- /dev/null +++ b/egress/cmd/sing-box/egress_sing_box.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "fmt" + "net" + "os" + + "github.com/armon/go-socks5" + + "github.com/getlantern/broflake/common" + "github.com/getlantern/broflake/egress" + egcmdcommon "github.com/getlantern/broflake/egress/cmd/common" +) + +func main() { + ctx := context.Background() + port := os.Getenv("PORT") + if port == "" { + port = "8000" + } + + l, err := net.Listen("tcp", fmt.Sprintf(":%v", port)) + if err != nil { + panic(err) + } + + common.Debugf("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@") + common.Debugf("@ DANGER @") + common.Debugf("@ DANGER @") + common.Debugf("@ DANGER @") + common.Debugf("@ @") + common.Debugf("@ This standalone egress server does not use secure TLS @") + common.Debugf("@ at the QUIC layer! @") + common.Debugf("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\n") + + // And here's why it doesn't use secure TLS at the QUIC layer + tlsConfig := egcmdcommon.GenerateSelfSignedTLSConfig(true) + + ll, err := egress.NewListener(ctx, l, tlsConfig) + if err != nil { + panic(err) + } + defer ll.Close() + + conf := &socks5.Config{ + Dial: UoTDialer(), + Resolver: &UoTResolver{}, + } + proxy, err := socks5.New(conf) + if err != nil { + panic(err) + } + + common.Debugf("Starting SOCKS5 UoT proxy...") + + err = proxy.Serve(ll) + if err != nil { + panic(err) + } +} diff --git a/egress/uot.go b/egress/cmd/sing-box/uot.go similarity index 99% rename from egress/uot.go rename to egress/cmd/sing-box/uot.go index d2936f4..a905286 100644 --- a/egress/uot.go +++ b/egress/cmd/sing-box/uot.go @@ -1,4 +1,4 @@ -package egress +package main import ( "context" diff --git a/egress/cmd/socks5/egress_socks5.go b/egress/cmd/socks5/egress_socks5.go index 6174088..cd092d1 100644 --- a/egress/cmd/socks5/egress_socks5.go +++ b/egress/cmd/socks5/egress_socks5.go @@ -43,10 +43,7 @@ func main() { } defer ll.Close() - conf := &socks5.Config{ - Dial: egress.UoTDialer(), - Resolver: &egress.UoTResolver{}, - } + conf := &socks5.Config{} proxy, err := socks5.New(conf) if err != nil { panic(err) From 774d2d110aa8e86353c38e1dd9fc7ab547d31b7f Mon Sep 17 00:00:00 2001 From: Noah Levenson Date: Sun, 1 Mar 2026 12:10:32 -0800 Subject: [PATCH 5/5] bump version --- common/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/version.go b/common/version.go index 71cd2a4..8b4d8fd 100644 --- a/common/version.go +++ b/common/version.go @@ -1,4 +1,4 @@ package common // Must be a valid semver -const Version = "v2.1.6" +const Version = "v2.1.7"