diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..e878465 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,36 @@ +group 'io.svrnty.tsnet_flutter' +version '1.0' + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.1.0' + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' + +android { + namespace 'io.svrnty.tsnet_flutter' + compileSdk 34 + + defaultConfig { + minSdk 21 + } + + sourceSets { + main { + jniLibs.srcDirs = ['src/main/jniLibs'] + } + } +} diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6d08bc2 --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + diff --git a/android/src/main/jniLibs/arm64-v8a/libtailscale.so b/android/src/main/jniLibs/arm64-v8a/libtailscale.so new file mode 100644 index 0000000..0e00e3d Binary files /dev/null and b/android/src/main/jniLibs/arm64-v8a/libtailscale.so differ diff --git a/android/src/main/jniLibs/x86_64/libtailscale.so b/android/src/main/jniLibs/x86_64/libtailscale.so new file mode 100644 index 0000000..8ab36bf Binary files /dev/null and b/android/src/main/jniLibs/x86_64/libtailscale.so differ diff --git a/build_go.sh b/build_go.sh index 02c41af..608290e 100755 --- a/build_go.sh +++ b/build_go.sh @@ -187,12 +187,85 @@ build_macos() { du -sh "$OUTPUT" } +# ============================================================ +# Android (c-shared → .so in jniLibs) +# ============================================================ +build_android() { + local NDK_HOME="${ANDROID_NDK_HOME:-$HOME/Library/Android/sdk/ndk/$(ls $HOME/Library/Android/sdk/ndk/ 2>/dev/null | sort -V | tail -1)}" + + if [ ! -d "$NDK_HOME" ]; then + echo "Error: Android NDK not found. Set ANDROID_NDK_HOME." + exit 1 + fi + + local TOOLCHAIN="$NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64" + local OUTPUT_DIR="$SCRIPT_DIR/android/src/main/jniLibs" + mkdir -p "$OUTPUT_DIR/arm64-v8a" "$OUTPUT_DIR/x86_64" + + # Android needs the "android" build tag for proper platform detection + # (e.g., netmon_polling.go instead of netmon_linux.go which requires CAP_NET_ADMIN) + local ANDROID_TAGS="android,$OMIT_TAGS" + + # Android arm64 + echo "==> [Android] Building for arm64-v8a..." + CGO_ENABLED=1 GOOS=android GOARCH=arm64 \ + CC="$TOOLCHAIN/bin/aarch64-linux-android21-clang" \ + go build -buildmode=c-shared -tags "$ANDROID_TAGS" -ldflags="-s -w" \ + -o "$OUTPUT_DIR/arm64-v8a/libtailscale.so" . + + # Android x86_64 (emulator) + echo "==> [Android] Building for x86_64..." + CGO_ENABLED=1 GOOS=android GOARCH=amd64 \ + CC="$TOOLCHAIN/bin/x86_64-linux-android21-clang" \ + go build -buildmode=c-shared -tags "$ANDROID_TAGS" -ldflags="-s -w" \ + -o "$OUTPUT_DIR/x86_64/libtailscale.so" . + + # Clean up generated headers (not needed for .so) + rm -f "$OUTPUT_DIR"/*/libtailscale.h + + echo "==> [Android] Done:" + ls -lh "$OUTPUT_DIR/arm64-v8a/libtailscale.so" + ls -lh "$OUTPUT_DIR/x86_64/libtailscale.so" +} + +# ============================================================ +# Linux (c-shared → .so) +# ============================================================ +build_linux() { + local OUTPUT_DIR="$SCRIPT_DIR/linux" + + # Linux amd64 — requires a Linux cross-compiler or building on Linux. + # On macOS, use Docker: docker run --rm -v $PWD:/src -w /src golang:1.23 ./build_go.sh linux + echo "==> [Linux] Building for amd64..." + if [ "$(uname)" = "Linux" ]; then + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \ + go build -buildmode=c-shared -tags "$OMIT_TAGS" -ldflags="-s -w" \ + -o "$OUTPUT_DIR/libtailscale.so" . + else + echo " Cross-compiling Linux from $(uname) via Docker..." + docker run --rm --platform linux/amd64 -v "$SCRIPT_DIR:/src" -w /src/ios/Go \ + golang:latest bash -c " + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \ + go build -buildmode=c-shared -tags '$OMIT_TAGS' -ldflags='-s -w' \ + -o /src/linux/libtailscale.so . + " + fi + + rm -f "$OUTPUT_DIR/libtailscale.h" + + echo "==> [Linux] Done:" + ls -lh "$OUTPUT_DIR/libtailscale.so" +} + # ============================================================ # Main # ============================================================ case "$PLATFORM" in - ios) build_ios ;; - macos) build_macos ;; - all) build_ios; build_macos ;; - *) echo "Usage: $0 [ios|macos|all]"; exit 1 ;; + ios) build_ios ;; + macos) build_macos ;; + android) build_android ;; + linux) build_linux ;; + all) build_ios; build_macos; build_android; build_linux ;; + apple) build_ios; build_macos ;; + *) echo "Usage: $0 [ios|macos|android|linux|apple|all]"; exit 1 ;; esac diff --git a/ios/Go/android_interfaces.go b/ios/Go/android_interfaces.go new file mode 100644 index 0000000..dd856b1 --- /dev/null +++ b/ios/Go/android_interfaces.go @@ -0,0 +1,39 @@ +//go:build android + +package main + +import ( + "net" + + "tailscale.com/net/netmon" +) + +func init() { + // Register an alternate interface getter for Android. + // Go's net.Interfaces() uses netlink which requires CAP_NET_ADMIN, + // and /sys/class/net is restricted on modern Android. + // Provide a minimal loopback interface to satisfy tsnet's startup. + // tsnet uses userspace netstack and doesn't need real interface data. + netmon.RegisterInterfaceGetter(func() ([]netmon.Interface, error) { + return []netmon.Interface{ + { + Interface: &net.Interface{ + Index: 1, + MTU: 65536, + Name: "lo", + HardwareAddr: nil, + Flags: net.FlagUp | net.FlagLoopback | net.FlagRunning, + }, + }, + { + Interface: &net.Interface{ + Index: 2, + MTU: 1500, + Name: "wlan0", + HardwareAddr: net.HardwareAddr{0x02, 0x00, 0x00, 0x00, 0x00, 0x01}, + Flags: net.FlagUp | net.FlagRunning | net.FlagMulticast, + }, + }, + }, nil + }) +} diff --git a/ios/Go/bridge.go b/ios/Go/bridge.go index b6ad49b..3474a41 100644 --- a/ios/Go/bridge.go +++ b/ios/Go/bridge.go @@ -20,6 +20,8 @@ import ( "fmt" "io" "net" + "os" + "runtime" "sync" "unsafe" @@ -50,6 +52,13 @@ func TailscaleStart(stateDir, authKey, hostname *C.char) *C.char { return nil // already started — idempotent } + // On Android, Go's net.Interfaces() uses netlink which requires + // CAP_NET_ADMIN. Set GODEBUG to use Go's pure DNS resolver and + // suppress network interface enumeration errors. + if runtime.GOOS == "android" { + os.Setenv("GODEBUG", "netdns=go") + } + s := &tsnet.Server{ Dir: C.GoString(stateDir), Hostname: C.GoString(hostname), diff --git a/lib/tsnet_ffi.dart b/lib/tsnet_ffi.dart new file mode 100644 index 0000000..eac60fc --- /dev/null +++ b/lib/tsnet_ffi.dart @@ -0,0 +1,125 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; +import 'package:flutter/services.dart'; + +// C function typedefs from bridge.go +typedef _StartNative = Pointer Function(Pointer, Pointer, Pointer); +typedef _StartProxyNative = Pointer Function(Pointer, Int32, Pointer); +typedef _StartProxyDart = Pointer Function(Pointer, int, Pointer); +typedef _SimpleNative = Pointer Function(); +typedef _OutParamNative = Pointer Function(Pointer>); +typedef _FreeNative = Void Function(Pointer); +typedef _FreeDart = void Function(Pointer); + +/// FFI-based implementation for Android and Linux. +/// Loads the Go shared library and calls C functions directly. +class TsnetFfi { + late final DynamicLibrary _lib; + late final _StartNative _start; + late final _StartProxyDart _startProxy; + late final _SimpleNative _stopProxy; + late final _SimpleNative _stop; + late final _OutParamNative _status; + late final _OutParamNative _tailscaleIP; + late final _FreeDart _free; + + TsnetFfi() { + _lib = _loadLibrary(); + _start = _lib.lookupFunction<_StartNative, _StartNative>('TailscaleStart'); + _startProxy = _lib.lookupFunction<_StartProxyNative, _StartProxyDart>('TailscaleStartProxy'); + _stopProxy = _lib.lookupFunction<_SimpleNative, _SimpleNative>('TailscaleStopProxy'); + _stop = _lib.lookupFunction<_SimpleNative, _SimpleNative>('TailscaleStop'); + _status = _lib.lookupFunction<_OutParamNative, _OutParamNative>('TailscaleStatus'); + _tailscaleIP = _lib.lookupFunction<_OutParamNative, _OutParamNative>('TailscaleIP'); + _free = _lib.lookupFunction<_FreeNative, _FreeDart>('TailscaleFreeString'); + } + + static DynamicLibrary _loadLibrary() { + if (Platform.isAndroid) { + return DynamicLibrary.open('libtailscale.so'); + } else if (Platform.isLinux) { + // Look in the app's lib directory (where Flutter bundles native libs) + return DynamicLibrary.open('libtailscale.so'); + } + throw UnsupportedError('FFI not supported on ${Platform.operatingSystem}'); + } + + String? _consumeString(Pointer ptr) { + if (ptr == nullptr) return null; + final str = ptr.toDartString(); + _free(ptr); + return str; + } + + void _checkError(Pointer errPtr, String code) { + final err = _consumeString(errPtr); + if (err != null) { + throw PlatformException(code: code, message: err); + } + } + + Future start(String stateDir, String authKey, String hostname) async { + final dirC = stateDir.toNativeUtf8(); + final keyC = authKey.toNativeUtf8(); + final hostC = hostname.toNativeUtf8(); + try { + final err = _start(dirC, keyC, hostC); + _checkError(err, 'START_FAILED'); + } finally { + malloc.free(dirC); + malloc.free(keyC); + malloc.free(hostC); + } + } + + Future startProxy(String remoteIP, int remotePort) async { + final ipC = remoteIP.toNativeUtf8(); + final portOut = malloc(); + try { + final err = _startProxy(ipC, remotePort, portOut); + _checkError(err, 'PROXY_FAILED'); + return portOut.value; + } finally { + malloc.free(ipC); + malloc.free(portOut); + } + } + + Future stopProxy() async { + final err = _stopProxy(); + _checkError(err, 'STOP_PROXY_FAILED'); + } + + Future stop() async { + final err = _stop(); + _checkError(err, 'STOP_FAILED'); + } + + Future status() async { + final errOut = malloc>(); + try { + errOut.value = nullptr; + final json = _status(errOut); + final err = _consumeString(errOut.value); + if (err != null) throw PlatformException(code: 'STATUS_FAILED', message: err); + return _consumeString(json); + } finally { + malloc.free(errOut); + } + } + + Future tailscaleIP() async { + final errOut = malloc>(); + try { + errOut.value = nullptr; + final ip = _tailscaleIP(errOut); + final err = _consumeString(errOut.value); + if (err != null) throw PlatformException(code: 'IP_FAILED', message: err); + return _consumeString(ip) ?? ''; + } finally { + malloc.free(errOut); + } + } +} diff --git a/lib/tsnet_flutter.dart b/lib/tsnet_flutter.dart index ef243ab..67d07af 100644 --- a/lib/tsnet_flutter.dart +++ b/lib/tsnet_flutter.dart @@ -1,13 +1,16 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/services.dart'; +import 'package:tsnet_flutter/tsnet_ffi.dart'; /// Embedded Tailscale tsnet client for Flutter. /// /// Provides a userspace WireGuard tunnel via Tailscale's tsnet library, /// without requiring the user to install the Tailscale app or a VPN entitlement. /// -/// Architecture: Go static library (tsnet) ↔ Swift C bridge ↔ MethodChannel ↔ Dart +/// - iOS/macOS: Swift MethodChannel → C bridge → Go static library +/// - Android/Linux: Dart FFI → Go shared library (.so) /// /// Usage: /// ```dart @@ -19,52 +22,93 @@ import 'package:flutter/services.dart'; class TsnetFlutter { static const _channel = MethodChannel('tsnet_flutter'); - /// Join the Tailnet with the given auth key. - /// - /// [authKey] is a Tailscale auth key (reusable, tag-scoped). - /// [hostname] is the name this device will appear as in the Tailnet. - /// - /// The auth key is typically received from the heater during BLE provisioning. - /// State is persisted to the app's documents directory so the device - /// identity survives app restarts. - Future start({ - required String authKey, - String hostname = 'constellation-phone', - }) async { - await _channel.invokeMethod('start', { - 'authKey': authKey, - 'hostname': hostname, - }); + /// FFI backend for Android/Linux — initialized lazily. + TsnetFfi? _ffi; + + bool get _useFfi => Platform.isAndroid || Platform.isLinux; + + TsnetFfi get _ensureFfi => _ffi ??= TsnetFfi(); + + /// State directory for persisting Tailscale identity. + String? _stateDir; + + Future _getStateDir() async { + if (_stateDir != null) return _stateDir!; + + // Use a directory relative to the current working directory or temp + // For proper production use, pass a path from path_provider + late final Directory dir; + if (Platform.isAndroid) { + // Android: use the app's internal storage via the temp directory parent + final temp = Directory.systemTemp; + dir = Directory('${temp.parent.path}/tailscale_state'); + } else if (Platform.isLinux) { + final home = Platform.environment['HOME'] ?? '/tmp'; + dir = Directory('$home/.local/share/tailscale_state'); + } else { + dir = Directory('${Directory.systemTemp.path}/tailscale_state'); + } + await dir.create(recursive: true); + _stateDir = dir.path; + return _stateDir!; } - /// Create a localhost TCP proxy to a heater's Tailscale IP. - /// - /// Returns the local port number. Connect gRPC to `127.0.0.1:`. - /// Traffic is forwarded through the Tailscale WireGuard tunnel to - /// [heaterTailscaleIP]:[port] on the heater. - /// - /// Uses userspace netstack — no VPN entitlement needed on iOS. - Future startProxy(String heaterTailscaleIP, {int port = 5050}) async { - final localPort = await _channel.invokeMethod('startProxy', { - 'ip': heaterTailscaleIP, - 'port': port, - }); - return localPort!; + /// Join the Tailnet with the given auth key. + Future start({ + required String authKey, + String hostname = 'tsnet-flutter', + }) async { + if (_useFfi) { + final dir = await _getStateDir(); + await _ensureFfi.start(dir, authKey, hostname); + } else { + await _channel.invokeMethod('start', { + 'authKey': authKey, + 'hostname': hostname, + }); + } + } + + /// Create a localhost TCP proxy to a remote Tailscale IP. + /// Returns the local port number. + Future startProxy(String remoteIP, {int port = 5050}) async { + if (_useFfi) { + return await _ensureFfi.startProxy(remoteIP, port); + } else { + final localPort = await _channel.invokeMethod('startProxy', { + 'ip': remoteIP, + 'port': port, + }); + return localPort!; + } } /// Stop the local TCP proxy. Future stopProxy() async { - await _channel.invokeMethod('stopProxy'); + if (_useFfi) { + await _ensureFfi.stopProxy(); + } else { + await _channel.invokeMethod('stopProxy'); + } } /// Disconnect from the Tailnet and clean up. Future stop() async { - await _channel.invokeMethod('stop'); + if (_useFfi) { + await _ensureFfi.stop(); + } else { + await _channel.invokeMethod('stop'); + } } /// Get the current Tailscale connection status. Future status() async { - final json = await _channel.invokeMethod('status'); + String? json; + if (_useFfi) { + json = await _ensureFfi.status(); + } else { + json = await _channel.invokeMethod('status'); + } if (json == null) { return TailscaleStatus(state: TailscaleState.stopped); } @@ -73,8 +117,12 @@ class TsnetFlutter { /// Get this device's Tailscale IPv4 address (100.x.x.x). Future tailscaleIP() async { - final ip = await _channel.invokeMethod('tailscaleIP'); - return ip!; + if (_useFfi) { + return await _ensureFfi.tailscaleIP(); + } else { + final ip = await _channel.invokeMethod('tailscaleIP'); + return ip!; + } } } diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..2cdb758 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.10) +project(tsnet_flutter_library VERSION 0.2.0 LANGUAGES C) + +# Bundle the pre-built Go shared library with the Flutter app. +# The .so is built by build_go.sh and placed in this directory. +install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/libtailscale.so" + DESTINATION "${CMAKE_INSTALL_PREFIX}/lib" + COMPONENT Runtime) diff --git a/linux/libtailscale.so b/linux/libtailscale.so new file mode 100644 index 0000000..ec44333 Binary files /dev/null and b/linux/libtailscale.so differ diff --git a/pubspec.lock b/pubspec.lock index 3012409..caf540e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -49,6 +49,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: "direct main" + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" flutter: dependency: "direct main" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 1b3fcb4..80a4b37 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,7 @@ environment: dependencies: flutter: sdk: flutter + ffi: ^2.1.0 dev_dependencies: flutter_test: @@ -25,3 +26,8 @@ flutter: pluginClass: TsnetFlutterPlugin macos: pluginClass: TsnetFlutterPlugin + android: + package: io.svrnty.tsnet_flutter + ffiPlugin: true + linux: + ffiPlugin: true