From 06b22352a5d7a12c45404e849168461bb2c98961 Mon Sep 17 00:00:00 2001 From: Shashank Singh Date: Sun, 30 Aug 2026 04:25:35 +0530 Subject: [PATCH] tailcat: add application-layer UDP support Add client and server UDP APIs, filtering, NAT64 forwarding, tests, and documentation. Signed-off-by: Shashank Singh --- README.md | 29 ++++++++ tailcat.go | 191 +++++++++++++++++++++++++++++++++++++++++++----- tailcat_test.go | 190 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 393 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 112edb2f0..7daf8fc59 100644 --- a/README.md +++ b/README.md @@ -411,6 +411,35 @@ $ ./client tcomFwWCAWf933BLELdzd3RkHiOufJ... hello from port 80 ``` +UDP uses a connected packet connection for each client flow, preserving +datagram boundaries and both endpoint addresses: + +```go +s.OnUDP = func(port uint16) func(tailcat.ConnPacketConn) { + if port != 53 { + return nil + } + return func(c tailcat.ConnPacketConn) { + defer c.Close() + buf := make([]byte, tailcat.MaxUDPPayload) + for { + n, err := c.Read(buf) + if err != nil { + return + } + c.Write(buf[:n]) + } + } +} + +pc, err := cl.DialUDPPort(context.Background(), 53) +``` + +`ConnPacketConn` implements both `net.Conn` and `net.PacketConn`. Keep payloads +at or below `tailcat.MaxUDPPayload` (1232 bytes) to fit the IPv6 tunnel MTU +without fragmentation. Use `OnUDPForward` and `DialUDP` for exit-node traffic; +`ProxyPacketConns` provides datagram-safe bidirectional forwarding. + ## How it works ### Connection tokens diff --git a/tailcat.go b/tailcat.go index a0eba13c4..b9f935dca 100644 --- a/tailcat.go +++ b/tailcat.go @@ -13,8 +13,9 @@ // just like the normal Tailscale data plane. DERP remains available as a // fallback relay if a direct path cannot be established. // -// Once connected, the two sides exchange arbitrary TCP traffic over the -// WireGuard tunnel with no Tailscale account or coordination server required. +// Once connected, the two sides exchange arbitrary TCP streams and UDP +// datagrams over the WireGuard tunnel with no Tailscale account or coordination +// server required. // Optionally, the server can run an auth-free SSH server on port 22, providing // remote shell access over the tunnel. // @@ -73,6 +74,7 @@ import ( "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/types/netmap" + "tailscale.com/types/nettype" "tailscale.com/types/views" "tailscale.com/util/eventbus" "tailscale.com/util/mak" @@ -255,9 +257,9 @@ func (b *locoBackend) Close() error { } // Server listens for clients over a WireGuard tunnel relayed through DERP. -// Incoming TCP connections are dispatched via [Server.OnTCP] (for connections -// addressed to the server itself) and [Server.OnTCPForward] (for connections -// the server relays to other addresses, acting as an exit node). +// Incoming TCP connections and UDP flows are dispatched via the OnTCP/OnUDP +// callbacks (for traffic addressed to the server itself) and their Forward +// counterparts (for traffic the server relays to other addresses). // // The zero value is a usable server: optionally populate the // configuration fields, then call [Server.Start], which picks @@ -323,6 +325,25 @@ type Server struct { // destination, not just the server's own address. OnTCPForward func(netip.AddrPort) (handler func(net.Conn)) + // OnUDP, if non-nil, specifies a func that returns a handler for an + // incoming UDP flow to the provided port. Each handler receives a connected + // packet connection for one client source IP:port. Datagram boundaries are + // preserved, and LocalAddr and RemoteAddr report the destination and source + // of the flow. If nil or if it returns nil, the flow is dropped. + // + // This only applies to packets addressed directly to the server node and not + // when being a subnet router. See OnUDPForward for relayed packets. + // + // It must be set before calling Start. + OnUDP func(port uint16) (handler func(ConnPacketConn)) + + // OnUDPForward is like OnUDP for UDP flows addressed through the server to + // another IP:port. Setting it also widens the packet filter installed at + // Start to admit UDP traffic to any destination. + // + // It must be set before calling Start. + OnUDPForward func(netip.AddrPort) (handler func(ConnPacketConn)) + // ServedTCPPorts, if non-nil, restricts which TCP ports on the // server's own address the packet filter admits new inbound // connections to. If nil, connections to all ports reach OnTCP, @@ -335,8 +356,30 @@ type Server struct { // // It must be set before calling Start. ServedTCPPorts []filter.PortRange + + // ServedUDPPorts, if non-nil, restricts which UDP ports on the server's own + // address the packet filter admits. If nil, packets to all ports reach + // OnUDP, which remains the per-flow gate either way. + // + // It must be set before calling Start. + ServedUDPPorts []filter.PortRange +} + +// ConnPacketConn is a connected datagram socket. Read and Write preserve UDP +// datagram boundaries, while the net.PacketConn methods are available to code +// that prefers packet-oriented APIs. LocalAddr and RemoteAddr identify the +// destination and source endpoints of an incoming server flow. +type ConnPacketConn interface { + net.Conn + net.PacketConn } +// MaxUDPPayload is the largest UDP payload that fits the tunnel's 1280-byte +// IPv6 MTU without IP fragmentation (1280 minus 40 bytes of IPv6 header and 8 +// bytes of UDP header). Applications should keep datagrams at or below this +// size; larger writes are not guaranteed to reach the peer. +const MaxUDPPayload = 1232 + // Start connects to the DERP relay and begins accepting clients, // first picking defaults for any unset configuration fields: a new // ephemeral key, log.Printf for logging, and the nearest region of @@ -449,6 +492,26 @@ func (s *Server) Start() error { } return s.OnTCPForward(dst), true } + ns.GetUDPHandlerForFlow = func(src, dst netip.AddrPort) (handler func(nettype.ConnPacketConn), intercept bool) { + var h func(ConnPacketConn) + if dst.Addr() == lb.addr { + if s.OnUDP != nil { + h = s.OnUDP(dst.Port()) + } + } else if s.OnUDPForward != nil { + if nat64Prefix.Contains(dst.Addr()) { + var a4 [4]byte + d6 := dst.Addr().As16() + copy(a4[:], d6[12:16]) + dst = netip.AddrPortFrom(netip.AddrFrom4(a4), dst.Port()) + } + h = s.OnUDPForward(dst) + } + if h == nil { + return nil, true + } + return func(c nettype.ConnPacketConn) { h(c) }, true + } lb.ns = ns sys.Set(ns) @@ -460,7 +523,11 @@ func (s *Server) Start() error { return ns.DialContextTCP(ctx, dst) } dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) { - panic("unreachable from tailcat") // but required by Dialer currently + c, err := ns.DialContextUDPWithBind(ctx, lb.addr, dst) + if err != nil { + return nil, err + } + return c, nil } sys.Tun.Get().Start() @@ -470,19 +537,19 @@ func (s *Server) Start() error { return lb.Start() } -var allTCPPorts = filter.PortRange{First: 0, Last: 65535} +var allPorts = filter.PortRange{First: 0, Last: 65535} -// buildFilter returns the packet filter enforcing what the server is -// configured to serve: new inbound TCP connections are admitted only -// to the server's own address (limited to ServedTCPPorts if set), -// plus to any destination when OnTCPForward is set (exit node mode). +// buildFilter returns the packet filter enforcing what the server is configured +// to serve: inbound TCP connections and UDP flows are admitted only to the +// server's own configured ports, plus to any destination for protocols whose +// Forward callback is set (exit node mode). // Everything else from the tunnel is dropped before reaching // netstack; the OnTCP/OnTCPForward callbacks remain the // per-connection gates behind it. func (s *Server) buildFilter() *filter.Filter { lb := s.lb - selfPorts := []filter.PortRange{allTCPPorts} + selfPorts := []filter.PortRange{allPorts} if s.ServedTCPPorts != nil { selfPorts = s.ServedTCPPorts } @@ -495,15 +562,39 @@ func (s *Server) buildFilter() *filter.Filter { Srcs: []netip.Prefix{allIPv6}, Dsts: selfDsts, }} + if s.OnUDP != nil { + udpPorts := []filter.PortRange{allPorts} + if s.ServedUDPPorts != nil { + udpPorts = s.ServedUDPPorts + } + udpDsts := make([]filter.NetPortRange, 0, len(udpPorts)) + for _, pr := range udpPorts { + udpDsts = append(udpDsts, filter.NetPortRange{Net: lb.addrPrefix, Ports: pr}) + } + matches = append(matches, filter.Match{ + IPProto: views.SliceOf([]ipproto.Proto{ipproto.UDP}), + Srcs: []netip.Prefix{allIPv6}, + Dsts: udpDsts, + }) + } var localNets netipx.IPSetBuilder localNets.AddPrefix(lb.addrPrefix) - if s.OnTCPForward != nil { + if s.OnTCPForward != nil || s.OnUDPForward != nil { localNets.AddPrefix(allIPv6) + } + if s.OnTCPForward != nil { matches = append(matches, filter.Match{ IPProto: views.SliceOf([]ipproto.Proto{ipproto.TCP}), Srcs: []netip.Prefix{allIPv6}, - Dsts: []filter.NetPortRange{{Net: allIPv6, Ports: allTCPPorts}}, + Dsts: []filter.NetPortRange{{Net: allIPv6, Ports: allPorts}}, + }) + } + if s.OnUDPForward != nil { + matches = append(matches, filter.Match{ + IPProto: views.SliceOf([]ipproto.Proto{ipproto.UDP}), + Srcs: []netip.Prefix{allIPv6}, + Dsts: []filter.NetPortRange{{Net: allIPv6, Ports: allPorts}}, }) } local, _ := localNets.IPSet() @@ -1371,8 +1462,8 @@ func createEngine(logf logger.Logf, lb *locoBackend) (err error) { // Client connects to a [Server] over a WireGuard tunnel relayed through DERP. // Populate Server (the only required field, or use the [NewClient] -// shorthand), then just dial: [Client.Dial], [Client.DialTCPPort], -// and [Client.DialTCP] lazily establish the tunnel on first use, +// shorthand), then just dial: [Client.Dial], the DialTCP methods, and the +// DialUDP methods lazily establish the tunnel on first use, // picking defaults for any unset fields. [Client.Ping] does the same // and is useful to test connectivity first or to measure the relay // round-trip time. @@ -1526,7 +1617,11 @@ func (c *Client) initLocked() error { return ns.DialContextTCP(ctx, dst) } dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) { - panic("unreachable from tailcat") // but required by Dialer currently + udpConn, err := ns.DialContextUDPWithBind(ctx, lb.addr, dst) + if err != nil { + return nil, err + } + return udpConn, nil } sys.Tun.Get().Start() @@ -1757,6 +1852,42 @@ func (c *Client) DialTCP(ctx context.Context, ap netip.AddrPort) (net.Conn, erro return c.lb.ns.DialContextTCP(ctx, ap) } +// DialUDPPort opens a connected UDP packet connection to the given port on the +// server. Each Write sends one datagram and each Read receives one datagram. +// See [Client.Dial] for the lazy startup behavior. +func (c *Client) DialUDPPort(ctx context.Context, port uint16) (ConnPacketConn, error) { + if err := c.up(ctx); err != nil { + return nil, err + } + return c.dialUDP(ctx, netip.AddrPortFrom(c.serverAddr, port)) +} + +// DialUDP opens a connected UDP packet connection to an arbitrary IP:port +// through the server, which must be configured to forward UDP (see +// [Server.OnUDPForward]). IPv4 addresses are mapped into the NAT64 prefix for +// transport over the IPv6-only WireGuard tunnel. +// See [Client.Dial] for the lazy startup behavior. +func (c *Client) DialUDP(ctx context.Context, ap netip.AddrPort) (ConnPacketConn, error) { + if err := c.up(ctx); err != nil { + return nil, err + } + if ap.Addr().Is4() { + a := nat64PrefixBytes + a4 := ap.Addr().As4() + copy(a[12:], a4[:]) + ap = netip.AddrPortFrom(netip.AddrFrom16(a), ap.Port()) + } + return c.dialUDP(ctx, ap) +} + +func (c *Client) dialUDP(ctx context.Context, ap netip.AddrPort) (ConnPacketConn, error) { + udpConn, err := c.lb.ns.DialContextUDPWithBind(ctx, c.lb.addr, ap) + if err != nil { + return nil, err + } + return udpConn, nil +} + func pfxOf(a netip.Addr) netip.Prefix { return netip.PrefixFrom(a, a.BitLen()) } @@ -1796,6 +1927,32 @@ func ProxyConns(a, b net.Conn) { b.Close() } +// ProxyPacketConns copies whole datagrams between a and b until either socket +// fails or is closed, then closes both sockets. The maximum-size UDP buffer +// avoids turning a large datagram into multiple writes or truncating it. +func ProxyPacketConns(a, b ConnPacketConn) { + done := make(chan struct{}, 2) + pump := func(dst, src ConnPacketConn) { + buf := make([]byte, 65535) + for { + n, err := src.Read(buf) + if err != nil { + break + } + if _, err := dst.Write(buf[:n]); err != nil { + break + } + } + done <- struct{}{} + } + go pump(a, b) + go pump(b, a) + <-done + a.Close() + b.Close() + <-done +} + // Status returns the current WireGuard and DERP connection status. func (s *Server) Status() *ipnstate.Status { return s.lb.Status() diff --git a/tailcat_test.go b/tailcat_test.go index f6089a855..6aacb6f73 100644 --- a/tailcat_test.go +++ b/tailcat_test.go @@ -4,6 +4,7 @@ package tailcat import ( + "bytes" "context" "errors" "fmt" @@ -112,6 +113,195 @@ func TestTailcat(t *testing.T) { } +func TestUDP(t *testing.T) { + dm := integration.RunDERPAndSTUN(t, mkLogger(t, "derpstun"), "127.0.0.1") + reg := dm.Regions[1] + if reg == nil { + t.Fatal("no region 1 in derpmap") + } + + type flow struct { + local, remote netip.AddrPort + } + flows := make(chan flow, 2) + handlerErr := make(chan error, 2) + var onUDPCalls atomic.Int32 + + s := &Server{Logf: mkLogger(t, "server"), Region: reg} + t.Cleanup(func() { s.Close() }) + s.OnUDP = func(port uint16) func(ConnPacketConn) { + onUDPCalls.Add(1) + if port != 53 { + return nil + } + return func(c ConnPacketConn) { + defer c.Close() + local, err := netip.ParseAddrPort(c.LocalAddr().String()) + if err != nil { + handlerErr <- err + return + } + remote, err := netip.ParseAddrPort(c.RemoteAddr().String()) + if err != nil { + handlerErr <- err + return + } + flows <- flow{local, remote} + for range 2 { + buf := make([]byte, 65535) + n, err := c.Read(buf) + if err != nil { + handlerErr <- err + return + } + if _, err := c.Write(buf[:n]); err != nil { + handlerErr <- err + return + } + } + handlerErr <- nil + } + } + s.ServedUDPPorts = []filter.PortRange{{First: 53, Last: 53}} + if err := s.Start(); err != nil { + t.Fatalf("server Start: %v", err) + } + + clients := []*Client{ + {Server: s.ConnBlob(), Logf: mkLogger(t, "client1")}, + {Server: s.ConnBlob(), Logf: mkLogger(t, "client2")}, + } + for _, c := range clients { + t.Cleanup(func() { c.Close() }) + PingForTest(t, s, c) + pc, err := c.DialUDPPort(t.Context(), 53) + if err != nil { + t.Fatalf("DialUDPPort: %v", err) + } + defer pc.Close() + if err := pc.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + // MaxUDPPayload is the largest datagram that fits Tailcat's 1280-byte + // IPv6 tunnel MTU without fragmentation. + for _, payload := range [][]byte{[]byte("small datagram"), bytes.Repeat([]byte("m"), MaxUDPPayload)} { + if n, err := pc.Write(payload); err != nil || n != len(payload) { + t.Fatalf("UDP Write = %d, %v; want %d, nil", n, err, len(payload)) + } + got := make([]byte, len(payload)+1) + n, err := pc.Read(got) + if err != nil { + t.Fatalf("UDP Read: %v", err) + } + if !bytes.Equal(got[:n], payload) { + t.Fatalf("UDP echo = %d bytes; want %d-byte datagram", n, len(payload)) + } + } + gotFlow := <-flows + if gotFlow.local != netip.AddrPortFrom(s.Addr(), 53) { + t.Errorf("server flow local address = %v; want %v:53", gotFlow.local, s.Addr()) + } + clientLocal, err := netip.ParseAddrPort(pc.LocalAddr().String()) + if err != nil { + t.Fatal(err) + } + if gotFlow.remote != clientLocal { + t.Errorf("server flow remote address = %v; want client %v", gotFlow.remote, clientLocal) + } + if err := <-handlerErr; err != nil { + t.Fatalf("UDP handler: %v", err) + } + } + + // Dialing a filtered UDP port creates a local socket immediately, but its + // datagrams must be silently dropped before reaching OnUDP. + pc, err := clients[0].DialUDPPort(t.Context(), 54) + if err != nil { + t.Fatalf("DialUDPPort(54): %v", err) + } + defer pc.Close() + pc.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) + if _, err := pc.Write([]byte("drop me")); err != nil { + t.Fatal(err) + } + if n, err := pc.Read(make([]byte, 32)); err == nil { + t.Fatalf("filtered UDP read = %d, nil; want timeout", n) + } else if ne, ok := err.(net.Error); !ok || !ne.Timeout() { + t.Fatalf("filtered UDP read error = %v; want timeout", err) + } + if got := onUDPCalls.Load(); got != int32(len(clients)) { + t.Fatalf("OnUDP called %d times; want %d served flows and no filtered flow", got, len(clients)) + } +} + +func TestUDPForward(t *testing.T) { + dm := integration.RunDERPAndSTUN(t, mkLogger(t, "derpstun"), "127.0.0.1") + reg := dm.Regions[1] + if reg == nil { + t.Fatal("no region 1 in derpmap") + } + + backend, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { backend.Close() }) + go func() { + buf := make([]byte, 65535) + for { + n, src, err := backend.ReadFromUDP(buf) + if err != nil { + return + } + if _, err := backend.WriteToUDP(buf[:n], src); err != nil { + return + } + } + }() + backendAddr := backend.LocalAddr().(*net.UDPAddr).AddrPort() + + s := &Server{Logf: mkLogger(t, "server"), Region: reg} + t.Cleanup(func() { s.Close() }) + s.OnUDPForward = func(dst netip.AddrPort) func(ConnPacketConn) { + if dst != backendAddr { + return nil + } + return func(c ConnPacketConn) { + upstream, err := net.DialUDP("udp4", nil, net.UDPAddrFromAddrPort(dst)) + if err != nil { + c.Close() + return + } + ProxyPacketConns(c, upstream) + } + } + if err := s.Start(); err != nil { + t.Fatalf("server Start: %v", err) + } + + c := &Client{Server: s.ConnBlob(), Logf: mkLogger(t, "client")} + t.Cleanup(func() { c.Close() }) + PingForTest(t, s, c) + forwarded, err := c.DialUDP(t.Context(), backendAddr) + if err != nil { + t.Fatalf("DialUDP(%v): %v", backendAddr, err) + } + defer forwarded.Close() + forwarded.SetDeadline(time.Now().Add(5 * time.Second)) + const payload = "forwarded datagram" + if _, err := forwarded.Write([]byte(payload)); err != nil { + t.Fatal(err) + } + buf := make([]byte, 64) + n, err := forwarded.Read(buf) + if err != nil { + t.Fatalf("reading forwarded UDP echo: %v", err) + } + if got := string(buf[:n]); got != payload { + t.Fatalf("forwarded UDP echo = %q; want %q", got, payload) + } +} + // TestHalfClose tests that a client's write shutdown (CloseWrite) // propagates through the server's TCP proxying as a half-close // rather than tearing down the whole connection: the backend must