Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
191 changes: 174 additions & 17 deletions tailcat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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()
Expand All @@ -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
}
Expand All @@ -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()
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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()
Expand Down
Loading