Embedding Tailscale in Rust: What tailscale-rs Actually Has to Solve
Source: lobsters
Tailscale’s early preview of tailscale-rs landed on Lobsters recently, and it’s worth spending some time on what the project actually has to solve. The surface-level pitch is simple: tsnet for Rust. But the implementation space between that pitch and a usable crate is dense with interesting problems.
What tsnet actually does
Before getting into Rust specifics, it helps to be clear about what tsnet is and why it matters. The conventional way to use Tailscale in an application is to run tailscaled as a system daemon and then have your program use normal OS networking. tsnet flips that: it embeds the entire Tailscale node, including WireGuard key management, the control plane connection, and the virtual network interface, directly inside your process.
The resulting Server type exposes net.Listener, net.Conn, and net.PacketConn interfaces that map one-to-one with Go’s standard library networking. You get a Listen method for accepting connections from the tailnet, a Dial method for connecting outward, and a ListenFunnel variant for exposing services through Tailscale Funnel to the public internet. The whole thing integrates with Go’s context.Context for cancellation.
srv := &tsnet.Server{
Hostname: "myservice",
AuthKey: os.Getenv("TS_AUTHKEY"),
Ephemeral: true,
}
defer srv.Close()
ln, err := srv.Listen("tcp", ":8080")
if err != nil {
log.Fatal(err)
}
http.Serve(ln, myHandler)
This model is genuinely useful. A service that embeds tsnet doesn’t need a tailscaled daemon present, doesn’t need NET_ADMIN privileges to create a TUN device in many environments, and can register as an ephemeral node that disappears from the tailnet when the process exits. Sidecar-free deployment in a container becomes straightforward.
The bridge problem
Tailscale’s codebase is Go. All of it. The WireGuard implementation, the control plane client, the state machine, the DNS resolver: Go. Building a Rust library on top of that means one of three things: rewrite it in Rust, build a subprocess boundary, or bridge the two runtimes via FFI.
A full rewrite is a years-long project. A subprocess approach (spawn tailscaled, talk to its local API) exists today and works, but it requires the daemon to be present, which defeats the embedding story. So tailscale-rs, like libtailscale before it, takes the FFI route.
libtailscale is Tailscale’s existing C-ABI wrapper around tsnet, built with CGO_ENABLED=1 to produce a shared library that exposes tsnet through a C interface. It handles the Go-to-C boundary: starting the Go runtime, marshaling types across the FFI seam, and managing lifetimes of Go-allocated objects from C. tailscale-rs sits above this layer, wrapping the C interface in safe Rust.
This architecture means a Rust binary linking against tailscale-rs also carries the Go runtime. The Go garbage collector runs in its own set of goroutines inside your Rust process. If you’ve never linked a Go shared library into a Rust binary before, that’s a meaningful thing to internalize: go tool controls a non-trivial chunk of your process’s memory and threading behavior, and you have essentially no visibility into it from Rust’s side.
The async mismatch
Go’s concurrency model is goroutines: lightweight green threads multiplexed onto OS threads by the Go scheduler. When you call tsnet.Server.Listen, the returned net.Listener is backed by goroutines doing async I/O internally, but the API surface is synchronous and blocking from the caller’s perspective.
Rust async is different. The dominant async runtime, tokio, uses an event loop and Future-based cooperative scheduling. Blocking a tokio worker thread is an antipattern that degrades throughput for every other task on that runtime. So naive wrapping of tsnet’s blocking C API into Rust async doesn’t work: you can’t just await a call that blocks internally on a Go goroutine without stalling the tokio executor.
The standard Rust solution for this is tokio::task::spawn_blocking, which offloads blocking work onto a dedicated thread pool separate from the async workers. tailscale-rs almost certainly uses this pattern for any tsnet operation that can block: accepting connections, dialing peers, waiting for the node to come up. The resulting Rust API looks async to callers, but each operation dispatches onto a blocking thread pool behind the scenes.
There’s overhead here. Each accept() call crossing the tokio-to-thread-pool-to-CGo boundary carries more latency than a native async network operation would. For most Tailscale use cases, throughput isn’t the bottleneck: you’re connecting services inside a tailnet, not running a CDN. But it’s worth knowing that TcpListener::accept in tailscale-rs is doing more work than the same call in Rust’s standard tokio::net::TcpListener.
What the Rust API looks like
Based on the preview, tailscale-rs exposes a Node or Server type that mirrors the Go tsnet.Server configuration surface. You provide a hostname, optionally an auth key, a state directory, and an ephemeral flag. The node starts asynchronously, negotiates with the Tailscale control plane, and then exposes methods for listening and dialing.
The primitives map onto familiar types: a TcpListener type that implements the tokio listener interface, a TcpStream for connections, and enough surface to use the node as an HTTP client over the tailnet. The goal is that existing Rust networking code should be easy to adapt: if your service already accepts a tokio::net::TcpListener, swapping in the tailscale-rs equivalent should require minimal changes.
This is the same philosophy tsnet had for Go: make the integration point look like standard library networking so that existing abstractions compose naturally.
What you can actually build
The embedding model opens up a specific class of Rust programs that were awkward before. Consider:
CLI tools that expose a service. A Rust CLI that needs to share data with a colleague’s machine can spin up an ephemeral tsnet node, serve over HTTP on the tailnet, and clean up when the process exits. No daemon configuration required on either machine beyond having Tailscale installed.
Sidecar-free containerized services. A Rust web service in a Docker container can join the tailnet directly at startup. It shows up in tailscale status, participates in ACLs, and exposes itself only to authorized nodes, without requiring a NET_ADMIN capability or a tailscaled sidecar.
Embedded devices and edge nodes. Rust is common on resource-constrained hardware where running a full tailscaled daemon might be impractical. An embedded Rust application that needs to phone home can embed the node directly, register as ephemeral, push its data, and disconnect.
Internal tooling over Tailscale Funnel. The ListenFunnel equivalent in tailscale-rs would let a Rust program expose an HTTPS endpoint to the public internet through Tailscale’s infrastructure without any reverse-proxy configuration.
Current state and what’s missing
The preview label is accurate. At this stage, tailscale-rs covers the core embedding workflow: join a tailnet, listen, dial, and leave. The more advanced tsnet surface, specifically ListenTLS for automatic certificate handling, ListenFunnel for public exposure, ListenPacket for UDP, LocalClient for querying node state, and RegisterFallbackTCPHandler for subnet route handling, may not all be present yet.
The libtailscale layer it builds on is also not a stable API. Tailscale considers it experimental, which means tailscale-rs inherits that instability. The crate version should be treated as a 0.x dependency: useful for experimentation and internal tooling, not yet something to stake a production service on without watching upstream closely.
Platform support is the other question. Go compiles to a wide range of targets, but CGo narrows that significantly. Cross-compiling a CGo binary for aarch64-unknown-linux-musl, for example, requires a cross-toolchain that many CI environments don’t have by default. The Rust build tooling around tailscale-rs will need to handle this gracefully, either by providing prebuilt Go shared libraries for common targets or by documenting the cross-compilation requirements clearly.
An alternative approach worth watching
There is a competing direction: rewriting Tailscale client functionality natively in Rust rather than bridging the Go implementation. wireguard-rs and Cloudflare’s BoringTun are examples of WireGuard implementations in Rust that don’t carry the Go runtime. A native Rust Tailscale client would eliminate the CGo overhead and the two-runtime complexity entirely.
That’s a much larger undertaking, and Tailscale has no public plan to pursue it. But the community has demonstrated appetite for it, and a project like tailscale-rs lowering the barrier for Rust developers to use Tailscale at all may accelerate demand for a native implementation over time.
For now, tailscale-rs is the pragmatic path: ship something that works by standing on the existing Go implementation, get Rust developers using it, and evolve from there. That’s exactly the right call for an early preview, and the tsnet model has already proven itself in Go. Getting that model into Rust’s async ecosystem, even with FFI overhead, is a meaningful step.