For most of Tailscale’s life, the assumption was that you run the daemon and your application talks to the network through it. The daemon handles key exchange with the coordination server, manages the WireGuard interface, and routes packets. Your application gets a regular network interface that looks like any other. This works fine for most cases, but it creates a hard dependency: if the daemon isn’t running, your application has no tailnet access. More importantly, your application isn’t really on the tailnet as a distinct node; it’s just a process on a machine that happens to be.
tsnet changed this for Go in early 2022. Instead of depending on an external daemon, tsnet lets a Go application embed a full Tailscale node directly in-process. The application registers as its own node on the tailnet, gets its own MagicDNS hostname, and can listen for connections and dial other nodes, all without any external daemon. The Go API is deliberately minimal:
s := &tsnet.Server{
Hostname: "my-service",
AuthKey: os.Getenv("TS_AUTHKEY"),
}
defer s.Close()
ln, err := s.Listen("tcp", ":8080")
if err != nil {
log.Fatal(err)
}
http.Serve(ln, myHandler)
That’s mostly it. The server authenticates with the coordination service, announces itself, and hands you back something that satisfies net.Listener. To dial out, you call s.Dial(ctx, "tcp", "other-host:80"). It returns a net.Conn. The integration points are standard library interfaces, so any code that works with those interfaces works on the tailnet without modification.
The announcement of tailscale-rs brings this same model to Rust, and this is a more significant engineering challenge than it might appear. tsnet is built on top of Tailscale’s existing Go codebase. The coordination protocol, DERP relay client, NAT traversal logic, and WireGuard key management are all Go. Porting that to Rust from scratch would be an enormous undertaking and would immediately create a maintenance burden: any change to Tailscale’s control plane protocol would need to be applied in two places.
The FFI Bridge Problem
The practical approach, and almost certainly what tailscale-rs uses under the hood, is a C-compatible FFI layer over the Go implementation. Tailscale maintains libtailscale, a shared library that exposes Tailscale’s functionality through a C API. This lets any language with C FFI call into the Go runtime. Rust’s FFI story with C is mature; the bindgen crate can generate bindings from C headers automatically, and unsafe blocks make the boundary explicit without being unusable.
This architecture has real implications. When you depend on tailscale-rs in a Rust project, you’re pulling in a Go runtime, because the shared library embeds one. The binary size impact is non-trivial, typically several megabytes for the Go runtime and stdlib alone, before any Tailscale code. For CLI tools or embedded applications where binary size matters, this is a consideration worth knowing up front. Static linking is possible but complicates the build process; dynamic linking requires the .so or .dylib to be present at runtime.
Go’s goroutine scheduler and Rust’s async executor also don’t automatically cooperate. When a Go function blocks in the shared library, it blocks a thread. When a Rust async task wants to await a connection from the tailnet, it needs a way to bridge the Go callback or polling model into a Rust Future. The idiomatic solution is to spawn a dedicated thread for the Go callbacks and use a channel or tokio::sync::oneshot to bridge back into the async world. This is a common pattern when integrating C libraries with Tokio, but it means the tailscale-rs API surface has to think carefully about where blocking happens.
What Was Available Before
Rust developers who wanted tailnet access before tailscale-rs had three real options. First, shell out to the tailscale CLI for operations like tailscale ip or tailscale status. This is brittle and limited to read operations on existing tailnet state; you can’t really listen on the tailnet this way. Second, talk to the local Tailscale daemon via its HTTP API, which is served on a platform-specific Unix socket. This gives you more capability but requires the daemon to be running, requires careful socket path detection across operating systems, and the API is not officially documented as stable. Third, use WireGuard directly via something like boringtun, Cloudflare’s userspace WireGuard implementation in Rust.
boringtun is worth examining because it shows both what’s possible and where the gap is. It implements the WireGuard protocol in pure Rust, passing the WireGuard test suite, and Cloudflare uses it in production for their 1.1.1.1 WARP client. But WireGuard by itself is just a cryptographic tunnel. You still need to handle key distribution (WireGuard has no built-in PKI or key exchange beyond the initial manual configuration), peer discovery, NAT traversal, and relay when direct connections fail. Tailscale handles all of that on top of WireGuard, via its coordination server, DERP relay network, and the magicsock layer that implements ICE-like NAT traversal. Implementing that stack yourself in Rust, correctly and securely, is not a weekend project.
Nebula, from Slack’s infrastructure team, takes a different approach: it’s a mesh overlay network with its own PKI and certificate-based node identity. The Go library can be embedded similarly to tsnet. But Nebula requires you to run your own infrastructure and has different trust model assumptions. It’s not a drop-in for Tailscale’s managed coordination.
What the API Looks Like
From the preview, tailscale-rs targets an async Rust API that feels natural to Tokio users. The broad shape is:
use tailscale_rs::Tailscale;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let ts = Tailscale::new()
.hostname("my-rust-service")
.auth_key(&std::env::var("TS_AUTHKEY")?)
.connect()
.await?;
let listener = ts.listen("tcp", ":8080").await?;
while let Ok((stream, _)) = listener.accept().await {
tokio::spawn(handle(stream));
}
Ok(())
}
The listener and stream types are designed to implement tokio::io::AsyncRead and tokio::io::AsyncWrite, so they slot into any Tokio-based HTTP server or protocol handler without friction. That’s the same design philosophy as tsnet’s use of net.Listener and net.Conn: lean on the ecosystem’s standard abstractions rather than introducing new ones.
Dialing out follows the same pattern, returning something compatible with Tokio’s async IO traits. This matters because libraries like hyper, reqwest, and tonic all accept custom connectors, so you can route HTTP or gRPC traffic over the tailnet with minimal code changes.
Current Status and What to Watch
The preview label is doing real work here. The library is not yet production-ready, and the Tailscale team has been explicit that the API may change. Platform coverage is likely limited at launch; Linux is the safe bet, with macOS and Windows support coming later as the platform-specific network plumbing gets sorted out. The build story for cross-compilation, particularly for targets like ARM or MUSL libc, will require some effort given the Go runtime dependency.
The project to watch alongside tailscale-rs is whether Tailscale eventually invests in a pure-Rust implementation of their coordination client, rather than the Go-via-FFI approach. The boringtun precedent shows the Rust community can implement cryptographic network protocols correctly; the harder part is the coordination protocol, DERP client, and NAT traversal logic, none of which have obvious Rust equivalents yet.
For my own work, the immediate use case is clear: any Rust-based service that needs to be reachable from other machines without firewall configuration or VPN client setup on the machine itself. Discord bots that phone home to a monitoring backend, internal HTTP APIs, one-off tunnels for debugging. The daemon model always required some external state to be running; tailscale-rs makes the networking self-contained inside the binary.
The Go ecosystem has had this capability for three years. The Rust version arriving now, even in preview, removes one of the cleaner justifications for reaching for Go when building network services that need private mesh connectivity.