Dart 💙 Tailscale
Build Dart and Flutter apps that talk to each other directly — no public servers, no VPN setup, no NAT punching code — over an encrypted Tailscale or Headscale tailnet.
package:tailscale embeds upstream Go tsnet and exposes typed Dart APIs for lifecycle, node identity, HTTP, TCP, UDP, TLS, Serve, Funnel, prefs, exit nodes, and diagnostics. Your app authenticates as its own node on the tailnet — users never install or configure a Tailscale client.
Status:
0.3.1, pre-1.0. The core API is stable enough to build on, but minor versions may include breaking changes until 1.0. Production users are welcome — please open an issue or start a discussion if something blocks you.
Documentation
The developer site is the canonical place to browse the package — full guide, examples, architecture diagrams, and a runtime model walkthrough.
| Where | What |
|---|---|
| Developer site | Guide, examples, architecture — start here for rich browsing |
| API reference | Generated dartdoc for every public symbol |
| pub.dev | Install, versions |
| CHANGELOG | Release notes and breaking changes |
example/ |
Runnable Dart snippets |
doc/ |
API status, roadmap, RFCs, and architecture notes |
test/README.md |
Test tiers, Headscale E2E, and live Tailscale suites |
What you can build
- A Flutter chat or collaboration app where peers reach each other directly when possible — without you running relay or signaling infrastructure.
- A headless Dart service that joins your tailnet and exposes private HTTPS without opening any public port.
- An on-device dashboard that calls private internal APIs (Grafana, Home Assistant, internal admin) without a corporate VPN.
- A shared Funnel endpoint — publish a local development server to the public internet, terminated with a real cert by Tailscale.
- Anything you'd reach for a WireGuard or libp2p library for, but you'd rather use Tailscale's identity, ACLs, and DERP fallback than build them yourself.
When this is the right choice
- You want app-level networking, scoped to one process — not system-wide tunnels users have to consent to.
- You want familiar Dart shapes (
http.Client, byte streams, datagrams) instead ofdart:io.Socketwrappers around a localhost proxy. - You're happy to delegate auth, WireGuard, ACLs, MagicDNS, DERP, HTTPS certs, Serve, and Funnel policy to upstream Tailscale.
When to use something else
- You need a system-wide VPN. Use the official Tailscale apps; this package is per-process userspace networking.
- Windows is a hard requirement today. v1 is POSIX-only — see Platform support.
- You can't run a Go toolchain at build time. This package compiles upstream tsnet on first build.
Install
dependencies:
tailscale: ^0.3.1
The first dart run, dart test, or flutter build triggers a native build hook that compiles the Go runtime for the target platform. Subsequent builds are cached and only recompile when Go source changes.
Prerequisites:
- Dart SDK 3.10.4 or newer.
- Go 1.25 or newer on
PATH. - Native toolchain for the target platform: Xcode for iOS/macOS, Android NDK through Flutter for Android, and a C toolchain for Linux.
Quick start
import 'package:tailscale/tailscale.dart';
Future<void> main() async {
Tailscale.init(stateDir: '/app/state');
final tailscale = Tailscale.instance;
final status = await tailscale.up(
hostname: 'dart-node',
authKey: 'tskey-auth-...',
);
print('node: ${status.stableNodeId}');
print('ipv4: ${status.ipv4}');
}
Subsequent launches can call up() without an auth key. The node identity is persisted in stateDir.
For short-lived CI jobs, preview environments, and disposable test nodes, pass
ephemeral: true to register a node that Tailscale removes after it goes
inactive:
await Tailscale.instance.up(
hostname: 'preview-pr-842',
authKey: 'tskey-auth-...',
ephemeral: true,
);
Use a fresh or cleared stateDir for each disposable identity. If stateDir
already contains node credentials, up(ephemeral: true) reconnects as that
existing node instead of registering a new ephemeral one.
Feature support
| Area | API | Status | Notes |
|---|---|---|---|
| Lifecycle | init, up, down, logout, status |
Supported | up(ephemeral: true) supports disposable CI/test nodes; up() resolves on the first stable state: running, needs login, or needs machine auth. |
| Reactive state | onStateChange, onError, onNodeChanges |
Supported | Go pushes updates to Dart; callers do not poll. |
| Node identity | nodes, nodeByIp, whois |
Supported | Use stable node IDs for durable references. |
| Outbound HTTP | http.client |
Supported | A normal package:http client routed through tsnet. |
| Inbound HTTP | http.bind |
Supported | Package-native request/response types backed by fd streams. |
| Raw TCP | tcp.dial, tcp.bind |
Supported | Explicit read/write halves and half-close. |
| Raw UDP | udp.bind |
Supported | Message-preserving datagrams with remote endpoint metadata. |
| TLS listener | tls.bind, tls.domains |
Supported | Requires MagicDNS and HTTPS enabled on the tailnet. |
| Serve | serve.forward, serve.clear |
Supported | Tailnet-only publication for an existing loopback HTTP server. |
| Funnel | funnel.forward, funnel.clear |
Supported | Public HTTPS publication through Tailscale Funnel policy. |
| Tailscale Services | N/A | Planned | Upstream tsnet.Server.ListenService is newer than the current tailscale.com v1.92.2 pin. |
| Routing controls | prefs, exitNode |
Supported | Subnet routes, Shields Up, tags, hostname, auto-update, and exit nodes. |
| Diagnostics | diag |
Supported | Ping, metrics, DERP map, and update checks. |
| Taildrop | taildrop |
Planned | Exported as a stub; not implemented in this release. |
| Profiles | profiles |
Planned | Exported as a stub; not implemented in this release. |
| Windows | N/A | Unsupported | v1 is POSIX-only while the Windows data-plane backend is designed. |
See doc/api-status.md for the full namespace-by-namespace API map.
Examples
A few canonical snippets below. The developer site hosts the full set covering raw TCP/UDP, TLS termination, Funnel, exit nodes, and routing controls; runnable variants live in example/.
All snippets assume the node has been initialized and started:
Tailscale.init(stateDir: '/app/state');
await Tailscale.instance.up(authKey: 'tskey-auth-...');
Call a private HTTP service
http.client is a standard package:http client. Requests resolve MagicDNS and route through the embedded node.
final response = await Tailscale.instance.http.client.get(
Uri.parse('http://api.tailnet.example.ts.net/health'),
);
if (response.statusCode != 200) {
throw StateError('health check failed: ${response.statusCode}');
}
Handle inbound HTTP directly
Use http.bind when the handler lives in this Dart process. No localhost proxy is opened.
final server = await Tailscale.instance.http.bind(port: 8080);
server.requests.listen((request) async {
await request.respond(
headers: {'content-type': 'text/plain; charset=utf-8'},
body: 'hello from ${request.local.address}',
);
});
Use Shelf middleware directly
The tested adapter in example/shelf_adapter.dart
adds a bindShelf extension for apps that want Shelf middleware and routing.
Add shelf to your app and copy or import the adapter; package:tailscale
does not take Shelf as a core dependency.
import 'package:shelf/shelf.dart';
import 'package:tailscale/tailscale.dart';
import 'shelf_adapter.dart';
Future<void> main() async {
final handler = const Pipeline()
.addMiddleware(logRequests())
.addHandler((Request request) {
return Response.ok(
'hello from Shelf over Tailscale',
headers: {'content-type': 'text/plain; charset=utf-8'},
);
});
final server = await Tailscale.instance.http.bindShelf(
port: 8080,
handler: handler,
);
print('Shelf listening on ${server.tailnet}');
}
Reuse an existing loopback server
Use serve.forward when your app already owns a local HTTP server and you want
to publish that existing loopback port.
final publication = await Tailscale.instance.serve.forward(
tailnetPort: 443,
localPort: 3000,
);
print('tailnet URL: ${publication.url}');
serve.forward traffic follows Tailscale Serve semantics, including Tailscale
identity headers for tailnet clients. funnel.forward follows the same local
server shape for public Funnel publication, but Funnel traffic is public and does
not include Tailscale identity headers.
Platform support
| Platform | Status | Notes |
|---|---|---|
| iOS | Supported | Userspace tsnet, no VPN entitlement. Validated with the Flutter smoke app. |
| Android | Supported | Userspace tsnet, no root. Validated with the Flutter smoke app. |
| macOS | Supported | Native asset and kqueue reactor path validated locally. |
| Linux | Supported | Native asset and epoll reactor path validated in Headscale E2E. |
| Windows | Unsupported | Excluded from the package platform list until a Windows-native backend is designed. |
The package is intentionally POSIX-first because owned transports use native descriptors plus a shared kqueue/epoll reactor. Windows needs a different transport backend rather than a thin port of the POSIX implementation.
Runtime model
Dart app
|
| typed API calls and streams
v
FFI worker isolate
|
| control ops + fd-backed data-plane handoff
v
Go tsnet runtime
|
| WireGuard, ACLs, MagicDNS, DERP
v
Tailnet peers
Control-plane calls go through a worker isolate so Dart's main isolate does not block on native work. Runtime events come back through Dart ports as streams.
Owned transports (http.bind, tcp.bind, udp.bind, tls.bind) use private fd-backed capabilities. That keeps listener ownership inside the package and avoids pretending that a localhost proxy is secure. Forwarding APIs (serve.forward, funnel.forward) intentionally use loopback because their purpose is to publish an existing local HTTP server the application already owns.
Roadmap
The core package path is implemented: lifecycle, node identity, HTTP, TCP, UDP, TLS, Serve/Funnel, prefs, exit nodes, diagnostics, Headscale E2E, and hosted-Tailscale live validation. Remaining launch and post-launch work is tracked in the design docs under doc/ — see Documentation for the index.
Contributing
Issues, bug reports, and PRs are welcome.
- Found a bug or have a feature request? Open an issue.
- Have a question or want to share what you're building? Start a discussion.
- Want to send a PR? Run
dart analyze,dart test, andtool/test_pr_gate.shbefore pushing. The full test setup — including the Headscale E2E suite and opt-in live Tailscale runs — is documented in test/README.md.
If you're using package:tailscale in production, I'd love to hear about it — open a discussion and let me know.