libtailscale 1.0.0
libtailscale: ^1.0.0 copied to clipboard
Headless Tailscale / Headscale node for Dart and Flutter, embedded through dart:ffi bindings to the official libtailscale C library.
libtailscale #
A headless Tailscale / Headscale node for Dart and Flutter, embedded in your
process through dart:ffi bindings to the official
libtailscale C library.
The host application supplies a control-server URL and a credential; the library joins the tailnet and exposes just enough to control the node and operate over it. It renders nothing, opens no browser and has no platform channels.
import 'package:libtailscale/libtailscale.dart';
final node = TailscaleNode(TailscaleConfig(
controlUrl: Uri.parse('https://headscale.example.com'), // omit for Tailscale
credential: const TailscaleCredential.authKey('tskey-auth-…'),
hostname: 'inventory-agent',
stateDir: '/path/to/writable/state',
ephemeral: true,
));
await node.start();
await node.waitUntilRunning(timeout: const Duration(seconds: 60));
print(node.addresses.ipv4); // 100.x.y.z
final server = await node.listen(port: 8080); // Stream<TailscaleSocket>
server.listen((c) => c.addStream(c)); // echo service on the tailnet
final socket = await node.connect('files', 443); // dart:io Socket via SOCKS5
final http = node.httpClient(); // HttpClient over the tailnet
final peers = (await node.status()).peers; // name, IPs, online, tags, OS
node.stateChanges.listen((s) => print('tailscale: $s'));
await node.close();
Scope #
| Area | Included | Deliberately excluded |
|---|---|---|
| Configure | control URL (Tailscale or Headscale); auth / pre-auth key, Tailscale OAuth client, existing state, or interactive login; hostname; state directory; ephemeral | web client, WireGuard port, custom stores |
| Control | start(), waitUntilRunning(), close(), logout(), stateChanges, health, authUrls |
any UI, browser launching |
| Observe | addresses, status() with peers, whoIs(ip), logs |
metrics, bug reports, profiles |
| Operate | connect() → Socket, connectSecure(), httpClient(), listen()/accept, dial(), half-close |
UDP, Taildrop, Serve, SSH, exit-node switching |
| Platforms | macOS 15+, iOS 15+, Android 15+ (API 35), Linux with glibc 2.39+ | Windows (libtailscale is Unix-only), web |
Anything else in tailscaled's LocalAPI stays reachable through
node.localApi.raw(...), without compatibility guarantees.
Credentials #
| Credential | Works with | Notes |
|---|---|---|
TailscaleCredential.authKey(key) |
Tailscale auth keys, Headscale pre-auth keys | Reusable / ephemeral / tags are properties of the key. |
TailscaleCredential.oauthClient(clientId:, clientSecret:, tags:) |
Tailscale only | The library mints a short-lived tagged auth key through the Tailscale API before starting. |
TailscaleCredential.existingState() |
both | Reuses the node key in stateDir; fails fast with TailscaleAuthRequiredException if a login is needed. |
TailscaleCredential.interactive() |
both | Publishes the login URL on node.authUrls; the app decides what to do with it. On Headscale an admin approves the node with headscale auth register --auth-id <id from the URL> --user <user> (nodes register --key before 0.29). |
Headscale #
Point controlUrl at the Headscale base URL; nothing else changes. Headscale
has no OAuth client API (use pre-auth keys), issues no TLS certificates
(status.certDomains is empty) and does not support Funnel or Serve.
enableFunnelToLocalhost refuses to run without a certificate domain because
the underlying C call would crash the process.
An unreachable or wrong control URL produces no error from tsnet; the node just
stays in Starting/NeedsLogin. waitUntilRunning therefore always takes a
timeout and reports the last state and health messages in the exception.
Sockets and TLS #
connect()returns aSocks5Socket, a realdart:ioSockettunnelled through the node's loopback SOCKS5 proxy. Because the SOCKS handshake consumes the raw stream, usesocket.secure(...)(ornode.connectSecure) for TLS instead ofSecureSocket.secure.httpClient()setsconnectionFactorysopackage:http'sIOClient,dio, gRPC andWebSocket.connect(customClient:)work unchanged. Pass aSecurityContext/ bad-certificate callback tohttpClient()itself.listen()anddial()use libtailscale's file-descriptor path and yieldTailscaleSocket(aStream<Uint8List>+IOSink). Dart cannot wrap a foreign fd in aSocket, so TLS is not available on that path.
Everything that can block runs on helper isolates (Isolate.run); a dedicated
isolate drives all fds with a single poll(2) loop.
Build configuration #
The package ships no binaries in the pub archive. hook/build.dart provides
the native library at build time, in this order:
prebuilt_dir: a directory containinglibtailscale.{dylib,so}(or the architecture-specificlibtailscale-<os>-<arch>[-<sdk>].<ext>).build_from_source:go build -buildmode=c-sharedfrom a pinned upstream checkout (any installed Go; the NDK/Xcode toolchains for mobile). The hook setsGOTOOLCHAIN=go1.25.5+autosogodownloads and uses the exact version upstream builds with, because newer Go releases break tailscale's pinned dependencies; setgo_toolchain: localto use the installed one. iOS is built as a c-archive and linked into a dylib because Go has no c-shared mode forGOOS=ios.- Download of the prebuilt library from this repository's native release,
verified against the SHA-256 pinned in
lib/src/hook/native_manifest.dart.
With no configuration the hook takes option 3 and downloads the library for
the pinned release. To build from source or use a local copy instead, configure
it in the application's pubspec.yaml:
hooks:
user_defines:
libtailscale:
build_from_source: true
# prebuilt_dir: native/libs
# source_dir: ../libtailscale
# go: /usr/local/go/bin/go
Hooks run with a filtered environment, so environment variables cannot
configure the build. While developing this package itself, a git-ignored
hook/local_config.json takes the same keys and overrides the pubspec
values:
{"build_from_source": true, "build_test_control": true}
The file is only read when the root package names it with the
local_config user-define, which this package's own pubspec.yaml does.
Applications, including the examples in this repository, never inherit it and
configure the hook in their own pubspec as shown above.
Android #
Minimum API 35. The library is linked with 16 KB page alignment and a soname.
Add the INTERNET permission to your manifest.
Two Android-only accommodations are built in. Apps may not use netlink
RTM_GETLINK since Android 11, which breaks Go's net.Interfaces() inside
tsnet; the hook therefore compiles lib/src/hook/android_interfaces.go into the
library (through go build -overlay, the upstream tree stays untouched),
registering a getifaddrs(3)-based interface getter with tailscale's netmon.
And because an app process has no $HOME or $TMPDIR, which makes tsnet's
log policy panic, start() sets both to the state directory when missing, in
the C environment and, through a helper exported by that same Go file, in the
Go runtime's copy of it.
iOS #
Minimum iOS 15. Flutter wraps the dylib in tailscale.framework (it drops the lib prefix). Userspace
WireGuard stops while the app is suspended; upstream reports the loopback
listener can become stale afterwards, in which case the node falls back to
tailscale_status_json for status and dial() keeps working. Set
NSLocalNetworkUsageDescription (peers on the same LAN are reached directly)
and ship a privacy manifest; the one in examples/node_console/ios/Runner
declares the file-timestamp and system-boot-time API categories the Go
runtime and tsnet use.
Flutter versions #
The hook depends on package:hooks and package:code_assets with ranges that
include the versions pinned by the current Flutter stable (hooks 1.0.x,
code_assets 1.0.x) as well as the 2.x releases used with a plain Dart SDK.
Linux #
Built on Ubuntu 24.04; requires glibc ≥ 2.39.
Examples #
example/example.dart: the whole API in one short program: join with an auth key, print node and peers, echo service, dial a peer.examples/tsnode/:tsnode, a headless command-line node built on the public API:join,info,peers,echo,sendandfetch. Its README shows how to try it against Tailscale, Headscale, or the in-process test control server without any account.examples/node_console/: a Flutter app for macOS, iOS, Android and Linux with three screens (Connect, Node, Communicate) on top of one controller; chat and HTTP fetch interoperate withtsnode. See its README for the device checklist and the platform settings (entitlements, permissions, privacy manifest) an embedding app needs. Both directories underexamples/are repository-only; the pub archive shipsexample/example.dart.
Development #
git submodule update --init # upstream sources (header, Go code)
dart test # unit + libc/reactor tests, no Go needed
dart test -x native # pure Dart only
echo '{"build_from_source": true, "build_test_control": true}' > hook/local_config.json
LIBTAILSCALE_INTEGRATION=1 dart test -t integration # hermetic e2e (Go)
(cd tool/ffigen && dart run bin/generate.dart) # regenerate bindings
dart run tool/build_native.dart --os macos --arch arm64 --out build/native
The package's own pubspec.yaml sets allow_missing_native: true so the
pure-Dart tests run on machines without Go or network access. That setting only applies when the
package is the root package.
Cutting a native release #
-
Bump the submodule if upstream moved and update
upstreamCommit,upstreamTailscaleVersionandminimumGoVersioninlib/src/hook/native_manifest.dart; run the hermetic tests. -
Start the Native release workflow (Actions tab, input
native-vX.Y.Z, or push that tag). It builds the ten libraries inNativeTarget.releaseTargetson macOS, Linux x64/arm64 and Android runners, publishes them withSHA256SUMSas a GitHub release, downloads every asset back to verify it, and opens a pull request that pins the digests inlib/src/hook/native_manifest.dart(tool/update_manifest.dart). Opening the pull request needs "Allow GitHub Actions to create and approve pull requests" in the repository's Actions settings (and, for an organization, in the organization's). Without it the step fails softly and thenative-manifest/native-vX.Y.Zbranch is still pushed: open the pull request by hand, or pin the checksums locally:gh release download native-vX.Y.Z --pattern SHA256SUMS dart run tool/update_manifest.dart --sums SHA256SUMS --tag native-vX.Y.Z -
Merge that pull request, bump
version:inpubspec.yaml, update the changelog and push avX.Y.Ztag;publish.ymlpublishes to pub.flutter-io.cn.
dart run tool/update_manifest.dart --sums SHA256SUMS --tag native-vX.Y.Z --check
verifies a checkout against a release's checksum file.
Repository layout #
| Path | Contents |
|---|---|
lib/, hook/, test/ |
The package: bindings, runtime, public API, build hook, tests. |
tool/ |
build_native.dart (release builds), update_manifest.dart (pins release checksums) and the ffigen tool package. |
example/ |
example.dart, the single-file example shown on pub.flutter-io.cn. |
examples/tsnode/, examples/node_console/ |
The tsnode CLI and the Flutter app (repository only, not in the pub archive). |
third_party/libtailscale/ |
Upstream sources as a git submodule, pinned to the commit the native libraries are built from. Excluded from the pub archive. |
specs/ |
Design plan and research notes. |
Known limitations #
tailscale_upis never used: it blocks and cannot be cancelled.waitUntilRunningimplements the same condition in Dart.tailscale_closedoes not close listeners or connections; the node tracks and closes every fd itself before calling it.- UDP is not exposed in this version (the fd path loses datagram boundaries;
SOCKS5
UDP ASSOCIATEis planned). - One pipe write end is intentionally leaked per node started with
captureLogs: true, because Go owns that fd number.
License #
BSD-3-Clause, like libtailscale itself.