We run self-hosted GitHub Actions runners at work. These runners need access to our Tailnet, but I don’t want to give my CI machines full Tailscale access and the workflows don’t have root access. The recommended solution is to use Tailscale in userspace networking mode but that requires tools to support SOCKS or HTTP proxies and doesn’t support UDP and ICMP. So I built tsexec.
tsexec lets you run any command with its network traffic routed through Tailscale, without needing root:
tsexec -auth-key tskey-auth-xxx ping my-server
How it works
The trick is using pasta, a tool that creates network namespaces without requiring root by using user namespaces. tsexec spawns two processes:
- The parent runs in the host namespace and handles all the Tailscale stuff: it basically runs the same things as
tailscaled, spawns the child and waits to receive a file descriptor to a TUN device. - The child runs inside the pasta-created namespace, creates a TUN device and passes it back to the parent. This is where your command actually executes.
The magic that makes it work: the child creates the TUN device and passes its file descriptor to the parent over a Unix socket. The parent then reads/writes packets on that TUN device while running the WireGuard tunnel. When packets come in from your command, they go through the TUN to the parent, get encrypted, and sent over the Tailscale network. The network namespace created by pasta then has a tailscale0 TUN device that is only usable by the command.
Parent (host namespace) Child (pasta namespace)
┌─────────────────────┐ ┌─────────────────────┐
│ Tailscale control │ │ TUN device │
│ WireGuard engine │◄───────────►│ Your command │
│ │ │ │
└─────────────────────┘ └─────────────────────┘
Each invocation creates an ephemeral Tailscale node that gets cleaned up when the command exits. This is perfect for CI: the runner gets temporary, scoped access to the Tailnet without any persistent state or elevated privileges.
The code is mostly figured out by Claude Code, I created an initial prototype bash script with userspace networking and then had Claude iterate on the Go version until it works. The important thing that made it succeed is giving it a test script that actually tries to connect to the tailnet and giving it some examples e.g. can you reach something on the tailnet but also curl google.com. User namespaces are amazing, I recently used bubblewrap to get RealityScan for Linux working in NixOS without Docker for example.
Hopefully Tailscale will integrate something like this into tailscale itself and I can throw away this code again!
EDIT
I realized I grossly overcomplicated things, we can just run tailscaled inside the pasta namespace and get rid of this whole file descriptor passing thing. The whole script is now just 99 LoC much better!
Jan 2026