How Tailcat Uses Netstack (gVisor) to Enable Inbound Connections
Tailcat embeds a full gVisor TCP/IP stack inside the process, wiring it to a WireGuard engine that routes encrypted DERP packets to user-defined callbacks when inbound TCP connections arrive on the server's virtual IPv6 address.
The tailscale/tailcat repository implements a lightweight networking layer that enables inbound connections without external control planes by running a complete TCP/IP stack in userspace. By leveraging Tailscale's gVisor-based netstack, Tailcat processes inbound TCP SYN packets directly inside the process, decrypting WireGuard traffic and dispatching connections to registered handlers. This architecture allows servers to accept connections over encrypted tunnels while maintaining full control over the networking stack.
What Is the Tailcat Netstack?
Tailcat's netstack is an instance of the gVisor TCP/IP implementation maintained by Tailscale, instantiated when newNetstack is called during server initialization. Unlike traditional socket-based networking, this approach creates a virtual network interface entirely within the process memory, allowing the application to intercept and handle packets before they reach the host operating system.
The netstack is attached to a WireGuard engine that receives encrypted UDP packets from DERP relays. When the backend starts via locoBackend.Start, the stack begins processing IP packets and matching them against configured handlers.
How Inbound Connections Work in Tailcat
Inbound connections follow a deterministic path from DERP relay to user callback, managed by three core mechanisms.
The TCP Handler Selector
The netstack uses GetTCPHandlerForFlow to determine how to handle each inbound TCP connection. In tailcat.go (lines 49-66), the server configures this selector to distinguish between direct connections and forwarded traffic:
ns.GetTCPHandlerForFlow = func(src, dst netip.AddrPort) (handler func(net.Conn), intercept bool) {
if dst.Addr() == lb.addr {
// Direct connections to the server's own address
if s.OnTCP == nil {
return nil, true // send RST
}
return s.OnTCP(dst.Port()), true
}
// Relayed "exit-node" connections
if s.OnTCPForward == nil {
return nil, true // send RST
}
return s.OnTCPForward(dst), true
}
When the destination address matches the server's virtual IPv6 address—derived from the node key via tcAddrForKey—the selector invokes Server.OnTCP. If the destination differs and Server.OnTCPForward is configured, the connection enters exit-node mode and forwards to the target address. Returning nil with intercept=true causes the stack to send a TCP RST, rejecting the connection cleanly.
Packet Filtering and Admission Control
Before packets reach the TCP stack, Tailcat builds a packet filter in Server.buildFilter (lines 95-102) to admit only expected traffic:
matches := []filter.Match{{
IPProto: views.SliceOf([]ipproto.Proto{ipproto.TCP}),
Srcs: []netip.Prefix{allIPv6},
Dsts: selfDsts,
}}
This filter permits TCP traffic from any IPv6 source to the server's own address when ServedTCPPorts are specified. When OnTCPForward is enabled, the filter expands to allow all TCP traffic, supporting exit-node functionality. By filtering at the network layer, Tailcat prevents unwanted packets from consuming resources in the TCP stack.
The Connection Lifecycle
A complete inbound connection follows this sequence:
- DERP Handshake: The client sends a Meow packet; the server responds with Meowed via
locoBackend.onMeow - WireGuard Peer Creation: The server adds the client's node key as a peer with allowed IPs through
peerAllowedIPs - Packet Delivery: Encrypted UDP packets arrive at the WireGuard engine, decrypt, and pass to the netstack
- TCP Processing: The netstack matches the SYN to the server's IPv6 address and invokes the
OnTCPhandler with an establishednet.Conn
The stack maintains connection state until Server.DrainTCP signals graceful shutdown, waiting for all TCP endpoints to close before process termination.
Implementing Inbound Connection Handlers
Applications register callbacks to handle inbound traffic. The following example implements an SSH-style server accepting connections on port 22:
import (
"log"
"net"
"tailscale.com/tailcat"
)
func main() {
srv := &tailcat.Server{
OnTCP: func(port uint16) func(net.Conn) {
if port != 22 {
return nil // reject other ports
}
return func(c net.Conn) {
defer c.Close()
log.Printf("incoming SSH connection from %s", c.RemoteAddr())
// …handle SSH session…
}
},
}
if err := srv.Start(); err != nil {
log.Fatalf("server start: %v", err)
}
log.Printf("server address: %s", srv.Addr())
select {} // keep running
}
The corresponding client dials the server using the connection blob:
import (
"context"
"log"
"net"
"tailscale.com/tailcat"
)
func main() {
// Assume `blob` is the ConnBlob printed by the server
client := tailcat.NewClient(blob)
conn, err := client.DialTCPPort(context.Background(), 22)
if err != nil {
log.Fatalf("dial error: %v", err)
}
defer conn.Close()
log.Printf("connected to %s", conn.RemoteAddr())
}
These handlers demonstrate how Tailcat abstracts the complexity of DERP relays and WireGuard encryption, presenting a standard net.Conn interface to application code.
Graceful Shutdown with DrainTCP
Proper resource management requires draining active connections before shutdown. The Server.DrainTCP method iterates through all TCP endpoints in the netstack, ensuring each connection closes completely before the process exits. This prevents data loss on inflight connections and allows clean peer removal from the WireGuard engine.
Summary
- Tailcat embeds gVisor netstack to process TCP/IP packets entirely in userspace, avoiding host network configuration
- Inbound routing relies on
GetTCPHandlerForFlowintailcat.goto dispatch connections toOnTCPorOnTCPForwardcallbacks based on destination address - Security filtering occurs in
buildFilter, admitting only TCP traffic destined for the server's virtual IPv6 address or exit-node ranges - Connection lifecycle spans DERP handshake, WireGuard peer setup, and netstack TCP processing, culminating in a
net.Connpassed to user handlers - Graceful shutdown via
DrainTCPensures all connections close properly before process termination
Frequently Asked Questions
What is gVisor netstack and why does Tailcat use it?
gVisor netstack is a pure-Go TCP/IP implementation originally developed by Google that runs in userspace. Tailcat uses it to avoid requiring root privileges or host network configuration while still processing raw IP packets. This allows the application to intercept inbound connections at the network layer and route them through WireGuard encryption without kernel-level networking changes.
How does Tailcat handle forwarded connections (exit-node mode)?
When Server.OnTCPForward is configured, the GetTCPHandlerForFlow selector in tailcat.go returns the forward handler for any destination address that does not match the server's own IPv6 address. The packet filter expands to allow all TCP traffic, and the netstack forwards the connection to the target destination, enabling the server to act as an encrypted exit node for client traffic.
What happens if OnTCP returns nil?
If OnTCP returns nil for a given port, the TCP handler selector returns nil, true to the netstack. The boolean true indicates that the stack should intercept the packet rather than pass it through, resulting in a TCP RST (reset) being sent to the client. This cleanly rejects the connection attempt without leaving the client hanging.
How does the packet filter improve security?
The packet filter constructed in Server.buildFilter (lines 95-102) creates an allowlist at the IP layer before packets reach the TCP stack. By restricting inbound traffic to specific IPv6 destinations and TCP protocols, Tailcat prevents spoofed packets or unexpected protocols from consuming TCP stack resources or triggering unnecessary handler invocations.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →