mathias 5f4b975280 Add concurrent per-remote proxy handles, bump to 0.4.0
The Go bridge now keeps a registry of independent localhost proxies keyed
by monotonically increasing 64-bit handles instead of a single global
proxy, so one proxy per heater can run concurrently. New C exports
TailscaleStartProxyHandle/TailscaleStopProxyHandle, new Dart API
startProxyHandle()/stopProxyHandle() (MethodChannel on iOS/macOS, lazy FFI
on Android/Linux). The legacy startProxy()/stopProxy() keep their
one-at-a-time semantics on their own slot and no longer disturb
handle-based proxies; stop() tears down every proxy. Binaries rebuilt for
all four platforms. Go proxy-layer tests (go test -race) cover concurrent
handles, independent stop, restart, same-remote ports, legacy interplay,
and stress; Dart tests cover the channel contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 11:17:45 -04:00

tsnet_flutter

Embed Tailscale's tsnet in Flutter apps. Provides a userspace WireGuard tunnel with a localhost TCP proxy — no VPN entitlement needed on iOS.

How it works

Flutter App (Dart)
  └── tsnet_flutter plugin
        ├── iOS/macOS: Swift MethodChannel → C bridge → Go static library (.a)
        └── Android/Linux: Dart FFI → Go shared library (.so)
              ↓
         Go tsnet (WireGuard + userspace netstack)
              ↓
         localhost TCP proxy (127.0.0.1:PORT)
              ↓
         WireGuard tunnel → remote device (100.x.x.x)

Your app connects to localhost:PORT. Traffic is forwarded through a WireGuard tunnel to the target device's Tailscale IP. Your app doesn't know about Tailscale — it just sees a localhost port.

Usage

import 'package:tsnet_flutter/tsnet_flutter.dart';

final tsnet = TsnetFlutter();

// Join the Tailnet
await tsnet.start(authKey: 'tskey-auth-...');

// Create a local proxy to the remote device
final localPort = await tsnet.startProxy('100.64.0.5', port: 5050);

// Connect your client to the proxy
yourClient.connect('127.0.0.1', port: localPort);

// Clean up
await tsnet.stopProxy();
await tsnet.stop();

Multiple concurrent proxies

startProxy() runs one proxy at a time — starting it again replaces the previous one. To reach several remote devices concurrently, use handle-based proxies: each is independent, and stopping one never disturbs the others.

final heaterA = await tsnet.startProxyHandle('100.64.0.5', port: 5050);
final heaterB = await tsnet.startProxyHandle('100.64.0.7', port: 5050);

clientA.connect('127.0.0.1', port: heaterA.localPort);
clientB.connect('127.0.0.1', port: heaterB.localPort);

await tsnet.stopProxyHandle(heaterA); // heaterB keeps running

Local ports are always kernel-assigned, so they cannot collide — even for two proxies to the same remote. Handles are never reused: restarting a proxy yields a new handle and a new local port. stop() tears down every proxy, legacy and handle-based.

API

Method Description
start(authKey, hostname) Join a Tailnet with an auth key. Idempotent — safe to call multiple times.
startProxy(ip, port) Create the single legacy localhost TCP proxy to a remote Tailscale IP, replacing the previous one. Returns the local port.
stopProxy() Stop the legacy localhost proxy. Handle-based proxies are unaffected.
startProxyHandle(ip, port) Create an independent localhost TCP proxy. Any number can run concurrently (one per remote device). Returns a TsnetProxyHandle with the handle id and local port.
stopProxyHandle(handle) Stop one handle-based proxy; the others keep running. Idempotent.
stop() Disconnect from the Tailnet. Stops every proxy first.
status() Get the current Tailscale connection status (state, peers, IPs).
tailscaleIP() Get this device's Tailscale IPv4 address (100.x.x.x).

Platform support

Platform Tailscale tunnel Binary type Architectures
iOS Supported c-archive (.a in xcframework) arm64 device + arm64 simulator
macOS Supported c-archive (.a in xcframework) arm64 + x86_64 universal
Linux Supported c-shared (.so) amd64
Android Local only (WIP) c-shared (.so in jniLibs) arm64-v8a + x86_64

Android note: Local TCP connections work. Tailscale tunnel is blocked by Go's net.Interfaces() requiring CAP_NET_ADMIN on Android. Full tunnel support will require libtailscale integration from tailscale-android.

Platform setup

iOS

No special entitlements or permissions needed. No VPN entitlement required.

macOS

macOS apps run sandboxed. Add these entitlements to both DebugProfile.entitlements and Release.entitlements:

<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>

network.client allows the app to connect to the localhost proxy and external networks. network.server allows the Go layer to open a localhost listener for the proxy.

Android

Add the INTERNET permission to your AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET" />

Linux

No special setup needed. The shared library is bundled automatically.

Requirements

Building from source

The pre-built binaries are included in the package. To rebuild from Go source:

# Prerequisites: Go 1.23+, Xcode (for Apple platforms), Android NDK (for Android)

./build_go.sh          # build all platforms
./build_go.sh ios      # iOS only
./build_go.sh macos    # macOS only
./build_go.sh android  # Android only (requires NDK)
./build_go.sh linux    # Linux only (uses Docker on macOS)
./build_go.sh apple    # iOS + macOS

Binary sizes

Platform Size Notes
iOS (device) ~23 MB After App Store compression: ~14 MB
iOS (simulator) ~23 MB Development only
macOS (universal) ~49 MB arm64 + x86_64
Android (arm64) ~20 MB
Android (x86_64) ~22 MB Emulator only
Linux (amd64) ~24 MB

Size is dominated by WireGuard + gVisor netstack + Go runtime. Feature tags strip ~35 unused Tailscale subsystems (SSH, Drive, Serve, etc.).

License

BSD-3-Clause — compatible with Tailscale's license. See LICENSE.

S
Description
No description provided
Readme BSD-3-Clause 105 MiB
Languages
Go 33.5%
Swift 22.2%
Dart 22%
Shell 11.7%
C++ 6%
Other 4.6%