tailscale 0.8.0
tailscale: ^0.8.0 copied to clipboard
Embed Tailscale userspace networking in any Dart or Flutter app. Join a tailnet, discover peers, and communicate over encrypted WireGuard tunnels — no Tailscale app required.
0.8.0 #
Cleanup and consistency release following an architecture-health review.
Behavior changes:
udp.send()now throwsArgumentError/RangeErrorfor an invalid argument — a bad remote port or address, or an oversize payload — instead ofTailscaleUdpException. This matches the argument-validation behavior ofserve,funnel, and the HTTP client. Errors parsing datagrams received from the network still throwTailscaleUdpException.
Improvements:
- Status and peer-list failures from a slow or misbehaving backend now carry a structured error code, instead of surfacing as an unclassified error.
- The HTTP client's request file-descriptor cleanup was unified so a failure mid-request can no longer leak a descriptor or pin a native goroutine (internal hardening; no API change).
Internal:
- Deduplicated the Serve/Funnel argument validators into one module; pruned dead LocalAPI error-classification patterns; CI now runs the Go suite under the race detector.
0.7.1 #
Correctness fixes from a follow-up review of the public API, worker lifecycle, and non-transport runtime surfaces.
Bug fixes:
- A slow or wedged tailscaled could freeze the entire binding: the serve/funnel teardown, and the status/peers reads, made unbounded LocalAPI calls (some while holding the internal lock). Every LocalAPI call is now bounded (default 30s), completing the "no unbounded native call" guarantee begun in 0.7.0.
- Funnel forwarding to a non-root mount (e.g.
funnel.forward(path: '/api')) now strips the mount prefix before proxying, so the backend receives paths relative to its mount — matchingServe. - An HTTP client request whose body failed to finalize no longer leaks the request file descriptor or pins a native goroutine.
TailscaleNode.lastSeennow returnsnullfor online / never-tracked peers instead of a placeholder year-1 date.nodes(),whois(), andexitNode.useById()now report argument/state errors through the returnedFuture(so.catchError/awaithandle them) instead of throwing synchronously.exitNode.suggest()now returnsnull(not an exception) during the brief startup window before the first network check completes.- Corrected the
funnel.clear()doc: Funnel publications are independent ofServeand clearing one does not touch the other.
0.7.0 #
The robustness release. Teardown/lifecycle unification across the native runtime, bounded native calls, and internal-registry hardening. Supersedes the never-published 0.6.0 below, so this is the first published release since 0.5.0 and carries that section's changes too.
Behavior change to note: net.dial and diag.ping without an explicit
timeout now fail after ~30 seconds instead of waiting indefinitely. Pass a
timeout to choose your own bound.
New:
diag.nodeState()— a census of the native runtime's internal registries (live listeners, bindings, forwarders, plus the teardown epoch). Intended for leak diagnostics and bug reports: after closing everything, all counts should be zero.
Bug fixes:
- A
udp.bindthat fails during Dart-side adoption (e.g. under file- descriptor pressure) no longer leaks its native bridge and tailnet port. - Teardown races unified under a single node-epoch gate: a stop/start cycle can no longer resurrect a just-closed listener, binding, or forwarder from a stale asynchronous completion, in any registry.
Internal:
- UDP bridge registry keyed by an opaque monotonic id instead of the Dart fd, making fd-reuse displacement structurally impossible.
- The reactor's peer-reset (RST) delivery contract is now pinned by tests on both platforms: epoll delivers errors unmaskably even with reads paused; kqueue rides them on the read filter. kqueue events now stamp the socket error (diagnostic) from the correct kevent field.
unsafe.Pointereliminated from the kqueue poller; CI gates on a fully cleango vet; the write-only funnel publication registry is gone.
0.6.0 (not published; included in 0.7.0) #
The performance/correctness audit release. Crash-safety and leak fixes across the UDP/TCP/HTTP data planes, HTTP client/server correctness, funnel/serve robustness, a responsive control API (long native calls no longer stall it), HTTP connection reuse, and internal reactor fixes. Supersedes the never-published 0.5.1, so this carries every change since 0.5.0.
Behavior changes to note: inbound http.bind request header names and
http.client response header names are now lowercased (dart:io / shelf
convention); outbound HTTP requests now reuse pooled connections.
Bug fixes:
- UDP datagrams larger than ~2 KiB no longer fail and tear down the binding on macOS/iOS. The datagram socketpair now sizes its buffers like the TCP path, so the advertised 60 KiB payload works on every platform.
- Closing a UDP binding now reclaims the tailnet port, bridge goroutines, and OS threads instead of leaking them; rebinding the same port immediately after close works.
- Outbound UDP datagram addresses are parsed as IP literals only, so a hostname can no longer trigger a blocking DNS lookup that stalls the datagram pump.
- A remote peer resetting a TCP/HTTP connection can no longer crash a root-zone Dart process via an unobserved transport error.
http.clientresponses now expose lowercase header names, soresponse.bodydecodes UTF-8 (and other charsets) correctly instead of defaulting to latin1, and case-insensitive header lookups work.- Inbound
http.bindrequest headers are likewise exposed with lowercase names, sorequest.headers['content-type']works instead of missing the canonical-cased key. - Inbound
http.bindstreaming responses (SSE, long-poll) now flush each chunk to the wire instead of stalling in the server's buffer until it fills. - Outbound HTTP responses now carry a real
Content-Length: 0(was dropped to null) and a bare reason phrase (OK, not200 OK). TailscaleHttpRequest.respond()now closes the request/response transports even if writing the body fails, instead of leaking the fds and Go handler goroutine.- Unawaited sequential
TailscaleHttpResponse.write()calls now reach the wire in call order rather than racing and reordering the body. - Hardened the
http.bindaccept loop (crash-safe worker, dead-isolate detection, and fd cleanup for accepts that raceclose()), matching the TCP listener; a TCP accept that loses toclose()no longer leaks an uncaught error. funnel.forwardcan no longer wedge the whole native API surface (includingstop()) when the node changes state mid-call.- Concurrent
serve.forward/serve.clearcalls no longer lose updates (the ServeConfig get-modify-set is serialized); concurrent same-portfunnel.forwardcalls fold together instead of one failing with "address in use". - A
funnel.forwardracing a concurrentdown()/logout()can no longer strand a funnel forwarder whose listener has died; forwarders are reaped when their listener closes, so a later same-portfunnel.forwardcan't attach to a dead listener and silently fail to serve. - A
serve.forwardracing a concurrentdown()/logout()can no longer persist a serve mount that survives the stop and silently re-exposes the service on the nextup(); a forward that arrives once teardown has begun is now refused. - The inbound-connection/request identity is dropped if the state watcher dies, so a reassigned tailnet address is never misattributed to the previous node.
up(timeout:)now bounds the native start step too, not just the wait for a stable state, so it can't block past its budget on a slow/wedged bring-up.exitNode.onCurrentChangeno longer emits a duplicate first value or a stale value under rapid changes.- A socketpair-wrap failure no longer double-closes an fd, and a reactor wake can no longer write into a just-closed poller fd.
Performance:
tcp.dial,diag.ping, andserve/funnel.forwardno longer block the shared worker isolate for their (potentially multi-second) duration. They run on a short-lived helper isolate, so a slow dial or a funnel waiting on the node to reach Running no longer stalls concurrentstatus()/nodes()calls or delays state/peer events. (Measured: a concurrentstatus()during an 8 s dial went from ~7.75 s to ~0 ms.) Fast local calls and awaited lifecycle steps (up/down/logout) stay on the worker. Concurrent offloaded calls are capped so a burst can't spawn unbounded helper isolates.- The HTTP client (
http.client) now reuses one connection pool across requests to the same peer instead of building a fresh transport — a new connection plus a full TLS handshake — on every request. The cached transport is keyed on the node's identity and dropped on teardown, so a pooled connection never outlives a logout/login as a different identity.
Internal:
- Reactor shards are assigned round-robin rather than by fd parity (which pinned concurrent flows to one shard); a shard whose first registration fails no longer keeps the process alive; and on Linux a peer half-close no longer spins epoll at 100% CPU while reads are disabled.
0.5.0 #
A feature and performance release. Inbound connections and HTTP requests now
carry the calling node's Tailscale identity, resolved at accept time, and the
LocalAPI loopback that backs whois() is dramatically faster.
Features:
- Inbound TCP/TLS connections accepted from a listener now expose the calling
node's identity as
TailscaleConnection.identity(TailscaleNodeIdentity?) — host name, stable node ID, tags, and tailnet IPs. It isnullfor outboundtcp.dial(you chose the target) and when the caller can't be resolved; resolution is best-effort and never blocks or fails an accept. - Inbound
Http.bindrequests expose the same viaTailscaleHttpRequest.identity— the in-process counterpart to theTailscale-User-Loginheaders thatserve.forwardadds.nullfor public Funnel callers, which originate outside the tailnet.
Performance:
whois()(and every other LocalAPI call) no longer forkslsofper request. The loopback auth path was hunting a macOS GUI credential file that an embedded tsnet process can never have; skipping it cuts awhois()round-trip from ~40 ms to ~0.3 ms.- Attaching identity to an inbound connection is a ~80 ns read from an in-memory index mirrored from the netmap, not a per-accept LocalAPI round-trip. The index falls back to a live lookup while cold and is dropped on teardown, so identity is never stale.
0.4.0 #
A security and reliability release. It hardens the embedded-tsnet data plane, tightens credential handling, and updates the bundled Tailscale stack. No public API shape changes.
Security:
- Tailscale Funnel forwarding now strips reserved
Tailscale-*identity headers (Tailscale-User-Login, etc.) from inbound public requests before proxying to the loopback backend, and pinsX-Forwarded-Proto/-Host. Previously a public Funnel client could spoof these headers — which are only trustworthy on the authenticated Serve path — straight through to the backend. logout()now best-effort revokes the node key with the control plane before wiping local state (same on re-auth viaup()with a new auth key). A surviving copy of the state database (e.g. a backup) is therefore no longer a live credential, and the device is deregistered rather than lingering until key expiry.- The state database (node and machine private keys) is created owner-only
(
0600, including the WAL sidecars), and the state directory is enforced0700even when it already exists. - The public Funnel listener now bounds concurrent connections to limit resource exhaustion from the open internet.
Reliability and resource hygiene:
- Established a single-owner rule for fd capabilities across the shared reactor and the main isolate, eliminating a deterministic cross-isolate double-close on registration failure (which under fd reuse could sever an unrelated live descriptor), plus a related response-fd over-close and an inbound-accept leak.
- Closing an HTTP binding now drains its accept backlog, releasing queued descriptors and unblocking their handler goroutines.
- Inbound UDP datagrams larger than 60 KiB are now dropped (and logged) instead of being silently truncated and delivered as a short datagram.
- The worker now fails API calls fast if its background isolate terminates, instead of hanging indefinitely.
- Additional fixes: TCP accept-loop descriptor leak and silent-failure handling,
native-memory cleanup on decode errors, eager (fail-at-parse) string-list
decoding, and closing the prior HTTP client on repeated
up().
Dependencies and build:
- Bumped
tailscale.comfrom v1.92.2 to v1.100.0. - Now requires the Go 1.26.4 toolchain (up from 1.25.5), enforced by the Go
module directive. With the default
GOTOOLCHAIN=auto, Go fetches it automatically — including on a Go 1.25+ base — so no manual toolchain install is needed unlessGOTOOLCHAINdisables auto-upgrade. - Added Dependabot (Go modules, pub, and GitHub Actions) and a least-privilege CI token.
Documentation:
init()and the README now recommend storing state in a backup-excluded, app-private directory (the node's WireGuard key must not leak into iCloud or Google backups), andWaitingFile.namedocuments that the sender-chosen filename is attacker-controlled and must be sanitized before use in a path.
Validation:
- Verified against the existing unit, FFI, fd, runtime, Go, and Headscale E2E suites. The bundled-stack bump passes the full two-node Headscale E2E (node lifecycle, TCP/UDP/HTTP, persisted-credential reconnect, and logout revocation) identically to the prior version.
0.3.1 #
- Adds
Tailscale.up(ephemeral: true)for disposable CI jobs, preview environments, and tests. - Adds
example/shelf_adapter.dart, a tested adapter showing how to run Shelf handlers directly onhttp.bindwithout making Shelf a core dependency. - Updates the README, developer site, API status, and architecture notes to point Shelf users at the tested adapter example.
0.3.0 #
This release is a major API and transport rebuild for public POSIX usage. It keeps the embedded-tsnet lifecycle model, but replaces the old loopback transport helpers with package-native APIs backed by private fd capabilities and a shared POSIX reactor.
Platform contract:
pubspec.yamldeclares Android, iOS, Linux, and macOS support. Windows is intentionally unsupported until a Windows-native data-plane backend or fallback carrier is designed.- Linux CI runs Headscale E2E against the epoll reactor path; macOS, iOS, and Android have been validated through the demo/smoke harness.
Breaking — public API shape:
Tailscale.httpis now the HTTP namespace. UseTailscale.http.clientfor a standardpackage:httpclient routed through the tailnet.- The old
Tailscale.listen(localPort, {tailnetPort})reverse-proxy helper was removed. UseTailscale.http.bind(port: ...)for in-process HTTP handling, orTailscale.serve.forward(...)when forwarding an existing loopback HTTP server. - Inventory APIs now use Tailscale's node terminology:
Tailscale.nodes(),Tailscale.nodeByIp(ip),Tailscale.onNodeChanges,TailscaleNode, andTailscaleNodeIdentity. Tailscale.up()now returnsFuture<TailscaleStatus>and resolves on the first stable state (running,needsLogin, orneedsMachineAuth).PingResult.directis nowPingResult.path(PingPath.direct,derp, orunknown). The.directgetter remains as a convenience for the positive case.ClientVersionnow mirrors upstream fields:latestVersion,urgentSecurityUpdate, and optionalnotifyText.
Core lifecycle and observation:
TailscaleClientis the testable app-facing interface implemented byTailscale.instance.onStateChange,onError, andonNodeChangesare pushed from Go; node updates are debounced and newonNodeChangessubscribers receive the current snapshot.- Structured
TailscaleErrorCodeand per-namespace operation exceptions now preserve known LocalAPI error categories (notFound,forbidden,conflict,preconditionFailed,featureDisabled,unknown).
fd-backed transport APIs:
http.clientstreams outbound request/response bodies over private fd-backed channels while Go ownstsnet.Server.HTTPClient()semantics.http.bind({port})returnsTailscaleHttpServerwith package-native request/response objects and fd-backed request/response bodies.tcp.dial(...)andtcp.bind(...)provide package-native raw TCP streams and listeners via Go-ownedtsnet.Server.Dial/Listenconnections handed to Dart as private fd capabilities.tls.bind(...)accepts TLS-terminated tailnet connections as plaintextTailscaleConnections; certificate acquisition and renewal remain in Go.udp.bind(...)provides message-preserving datagrams with remote endpoint metadata and rejects payloads over 60 KiB.- The POSIX data plane uses a shared kqueue/epoll reactor instead of spawning reader/writer isolates per fd.
Tailscale feature namespaces:
whois(ip)andnodeByIp(ip)are implemented for identity-aware authorization flows.tls.domains()exposes auto-provisioned Tailscale certificate SANs.diag.ping,diag.metrics,diag.derpMap, anddiag.checkUpdateare implemented.prefs.get, single-field prefs setters, andprefs.updateMaskedare implemented.exitNode.current,suggest,use,useById,useAuto,clear, andonCurrentChangeare implemented.serve.forward/clearpublishes an existing loopback HTTP service inside the tailnet using LocalAPI ServeConfig.funnel.forward/clearpublishes an existing loopback HTTP service through Tailscale Funnel usingtsnet.ListenFunnelplus a package-owned reverse proxy. Forwarding targets are loopback-only.taildropandprofilesremain declared roadmap namespaces and throwUnimplementedErrorin this release.
Validation:
- Unit, FFI, fd, runtime, Go, and Headscale E2E suites cover the core feature spine.
- Live Tailscale tests cover hosted-control-plane behavior Headscale cannot
model: routing controls, TLS serving, Serve forwarding, Funnel forwarding,
and Serve cleanup on
down()/restart.
Release hardening:
- HTTP fd response-head envelopes are capped at 256 KiB on both the Dart and Go sides.
- fd transport write/close dispatch failures, listener/server close failures, unread HTTP request bodies, and UDP binding teardown paths now deterministically close local resources.
- Serve/Funnel forwarding canonicalizes
localhostto127.0.0.1before creating loopback proxy targets. - Smoke-matrix tooling redacts bearer credentials from logs and stores generated runner tokens with owner-only file permissions.
0.2.0 #
- tsnet.Server.Close() doesn't fire a terminal state through the IPN bus, so onStateChange subscribers drifted from the engine — stuck at the pre-stop value (usually Running) and their UI routing went stale.
- Stop() now publishes Stopped, gated on srv != nil so a no-op stop stays silent. Logout() follows up with NoState after wiping creds (full sequence on logout from a running node: Stopped → NoState).
- Rewrites the onStateChange lifecycle e2e group around a new _recordUntil helper that captures full emitted sequences, and adds coverage for the no-op-down guard, broadcast delivery to multiple subscribers, and the ordered Stopped → NoState emit on logout.
0.1.0 #
- Initial release.
- Embed a Tailscale node directly in any Dart or Flutter application.
Tailscale.init()— configure once at startup with state directory and log level.up()— start the embedded node and connect to a Tailscale or Headscale network.http— a standardhttp.Clientthat routes requests through the WireGuard tunnel.listen()— accept incoming traffic from the tailnet, forwarded to a local port.status()— typedTailscaleStatuswithNodeStateenum, local IPs, and health.nodes()— typedTailscaleNodesnapshots, separate from status for lightweight polling.onStateChange/onError— real-time streams pushed from Go via NativePort (no polling).down()— disconnect, preserving state for reconnection.logout()— disconnect and clear persisted state.NodeStateenum:noState,needsLogin,needsMachineAuth,starting,running,stopped.- Automatic native Go compilation via Dart build hook — no manual build steps.
- Zero main-isolate jank: all FFI calls run on a background isolate.
- Supports iOS, Android, macOS, Linux, and Windows.
- Works with Tailscale and self-hosted Headscale control servers.
- Full test suite: unit, FFI integration, and E2E against Headscale in Docker.