diff --git a/README.md b/README.md index 344ed8e..d629871 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,19 @@ Embed [Tailscale's tsnet](https://pkg.go.dev/tailscale.com/tsnet) in Flutter app ## How it works ``` -Flutter (Dart) → MethodChannel → Swift Plugin → Go static library (tsnet) - ↓ - WireGuard tunnel (userspace) - ↓ - Remote device (100.x.x.x) +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) ``` -The Go layer runs a local TCP proxy: your app connects to `localhost:PORT`, and traffic is forwarded through a WireGuard tunnel to the target device's Tailscale IP. Flutter doesn't know about Tailscale — it just sees a localhost port. +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 @@ -35,13 +40,27 @@ await tsnet.stopProxy(); await tsnet.stop(); ``` +## API + +| Method | Description | +|--------|-------------| +| `start(authKey, hostname)` | Join a Tailnet with an auth key. Idempotent — safe to call multiple times. | +| `startProxy(ip, port)` | Create a localhost TCP proxy to a remote Tailscale IP. Returns the local port. | +| `stopProxy()` | Stop the localhost proxy. | +| `stop()` | Disconnect from the Tailnet. | +| `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 | Status | Min version | -|----------|--------|-------------| -| iOS | Supported (arm64 device + simulator) | 14.0 | -| macOS | Supported (arm64 + x86_64 universal) | 12.0 | -| Android | Planned | — | +| 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](https://github.com/tailscale/tailscale-android). ## Platform setup @@ -60,23 +79,54 @@ macOS apps run sandboxed. Add these entitlements to both `DebugProfile.entitleme ``` -`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. +`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`: + +```xml + +``` + +### Linux + +No special setup needed. The shared library is bundled automatically. ## Requirements -- Tailscale auth key (generate at [login.tailscale.com](https://login.tailscale.com/admin/settings/keys)) +- Tailscale auth key — generate at [login.tailscale.com/admin/settings/keys](https://login.tailscale.com/admin/settings/keys) +- Auth keys can be reusable and tag-scoped for ACL isolation ## Building from source -The pre-built xcframeworks are included in the package. To rebuild from Go source: +The pre-built binaries are included in the package. To rebuild from Go source: ```bash -# Prerequisites: Go 1.23+, Xcode with iOS + macOS SDKs +# 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. See [LICENSE](LICENSE). +BSD-3-Clause — compatible with Tailscale's license. See [LICENSE](LICENSE). diff --git a/test/linux_e2e.go b/test/linux_e2e.go new file mode 100644 index 0000000..217139b --- /dev/null +++ b/test/linux_e2e.go @@ -0,0 +1,116 @@ +// Linux test for tsnet — loads the shared library via CGo and tests the tunnel. +// +// Run from macOS via Docker: +// docker run --rm --platform linux/amd64 \ +// -v /path/to/flutter-tsnet:/src -w /src/test \ +// golang:latest go run linux_test.go \ +// -authkey=tskey-auth-xxx \ +// -heater-ip=100.x.x.x +package main + +/* +#cgo LDFLAGS: -L${SRCDIR}/../linux -ltailscale -lresolv -lpthread +#include + +extern char* TailscaleStart(char*, char*, char*); +extern char* TailscaleStartProxy(char*, int, int*); +extern char* TailscaleStopProxy(); +extern char* TailscaleStop(); +extern char* TailscaleStatus(char**); +extern char* TailscaleIP(char**); +extern void TailscaleFreeString(char*); +*/ +import "C" +import ( + "flag" + "fmt" + "net" + "os" + "time" + "unsafe" +) + +func goString(cs *C.char) string { + if cs == nil { + return "" + } + s := C.GoString(cs) + C.TailscaleFreeString(cs) + return s +} + +func main() { + authKey := flag.String("authkey", "", "Tailscale auth key") + heaterIP := flag.String("heater-ip", "", "Heater's Tailscale IP") + heaterPort := flag.Int("heater-port", 5050, "Heater's port") + flag.Parse() + + if *authKey == "" || *heaterIP == "" { + fmt.Fprintf(os.Stderr, "Usage: go run linux_test.go -authkey=tskey-auth-xxx -heater-ip=100.x.x.x\n") + os.Exit(1) + } + + // State dir + stateDir := "/tmp/tsnet-linux-test" + os.MkdirAll(stateDir, 0o700) + + // 1. Start + fmt.Println("[1/4] Starting tsnet...") + start := time.Now() + dirC := C.CString(stateDir) + keyC := C.CString(*authKey) + hostC := C.CString("tsnet-linux-test") + err := goString(C.TailscaleStart(dirC, keyC, hostC)) + C.free(unsafe.Pointer(dirC)) + C.free(unsafe.Pointer(keyC)) + C.free(unsafe.Pointer(hostC)) + if err != "" { + fmt.Printf("[1/4] FAIL: %s\n", err) + os.Exit(1) + } + fmt.Printf("[1/4] Started in %dms\n", time.Since(start).Milliseconds()) + + // 2. Get IP (with retry) + fmt.Println("[2/4] Getting Tailscale IP...") + var ip string + for i := 0; i < 30; i++ { + var errOut *C.char + ipC := C.TailscaleIP(&errOut) + e := goString(errOut) + if e == "" { + ip = goString(ipC) + break + } + C.TailscaleFreeString(ipC) + time.Sleep(500 * time.Millisecond) + } + fmt.Printf("[2/4] Our IP: %s\n", ip) + + // 3. Start proxy + fmt.Println("[3/4] Starting proxy...") + ipC := C.CString(*heaterIP) + var localPort C.int + errStr := goString(C.TailscaleStartProxy(ipC, C.int(*heaterPort), &localPort)) + C.free(unsafe.Pointer(ipC)) + if errStr != "" { + fmt.Printf("[3/4] FAIL: %s\n", errStr) + os.Exit(1) + } + fmt.Printf("[3/4] Proxy: localhost:%d → %s:%d\n", int(localPort), *heaterIP, *heaterPort) + + // 4. Test TCP + fmt.Println("[4/4] Testing TCP connection...") + conn, connErr := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", int(localPort)), 10*time.Second) + if connErr != nil { + fmt.Printf("[4/4] TCP FAILED: %v\n", connErr) + } else { + fmt.Println("[4/4] TCP SUCCESS") + conn.Close() + } + + // Cleanup + C.TailscaleStopProxy() + C.TailscaleStop() + + fmt.Println("\nALL TESTS PASSED") +}