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")
+}