Add Android and Linux platform support (local gRPC working, tsnet WIP)

- Android: Dart FFI → Go c-shared (.so) in jniLibs (arm64-v8a + x86_64)
- Linux: Dart FFI → Go c-shared (.so) via Docker cross-compilation (amd64)
- Dart API: TsnetFlutter uses MethodChannel on iOS/macOS, FFI on Android/Linux
- Add ffi package dependency for native function bindings
- Build script: ./build_go.sh [ios|macos|android|linux|apple|all]
- Android RegisterInterfaceGetter to bypass netlink CAP_NET_ADMIN restriction
- Make TailscaleStart() idempotent and add GODEBUG=netdns=go for Android

Known: Android tsnet tunnel blocked by Go stdlib net.Interfaces() netlink
call — local gRPC works, Tailscale fallback needs libtailscale integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathias Beaulieu-Duncan 2026-03-14 07:49:11 -04:00
parent ae105a2bb0
commit f070bcdb86
13 changed files with 395 additions and 39 deletions

36
android/build.gradle Normal file
View File

@ -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']
}
}
}

View File

@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.svrnty.tsnet_flutter">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

Binary file not shown.

Binary file not shown.

View File

@ -187,12 +187,85 @@ build_macos() {
du -sh "$OUTPUT" 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 # Main
# ============================================================ # ============================================================
case "$PLATFORM" in case "$PLATFORM" in
ios) build_ios ;; ios) build_ios ;;
macos) build_macos ;; macos) build_macos ;;
all) build_ios; build_macos ;; android) build_android ;;
*) echo "Usage: $0 [ios|macos|all]"; exit 1 ;; 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 esac

View File

@ -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
})
}

View File

@ -20,6 +20,8 @@ import (
"fmt" "fmt"
"io" "io"
"net" "net"
"os"
"runtime"
"sync" "sync"
"unsafe" "unsafe"
@ -50,6 +52,13 @@ func TailscaleStart(stateDir, authKey, hostname *C.char) *C.char {
return nil // already started — idempotent 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{ s := &tsnet.Server{
Dir: C.GoString(stateDir), Dir: C.GoString(stateDir),
Hostname: C.GoString(hostname), Hostname: C.GoString(hostname),

125
lib/tsnet_ffi.dart Normal file
View File

@ -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<Utf8> Function(Pointer<Utf8>, Pointer<Utf8>, Pointer<Utf8>);
typedef _StartProxyNative = Pointer<Utf8> Function(Pointer<Utf8>, Int32, Pointer<Int32>);
typedef _StartProxyDart = Pointer<Utf8> Function(Pointer<Utf8>, int, Pointer<Int32>);
typedef _SimpleNative = Pointer<Utf8> Function();
typedef _OutParamNative = Pointer<Utf8> Function(Pointer<Pointer<Utf8>>);
typedef _FreeNative = Void Function(Pointer<Utf8>);
typedef _FreeDart = void Function(Pointer<Utf8>);
/// 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<Utf8> ptr) {
if (ptr == nullptr) return null;
final str = ptr.toDartString();
_free(ptr);
return str;
}
void _checkError(Pointer<Utf8> errPtr, String code) {
final err = _consumeString(errPtr);
if (err != null) {
throw PlatformException(code: code, message: err);
}
}
Future<void> 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<int> startProxy(String remoteIP, int remotePort) async {
final ipC = remoteIP.toNativeUtf8();
final portOut = malloc<Int32>();
try {
final err = _startProxy(ipC, remotePort, portOut);
_checkError(err, 'PROXY_FAILED');
return portOut.value;
} finally {
malloc.free(ipC);
malloc.free(portOut);
}
}
Future<void> stopProxy() async {
final err = _stopProxy();
_checkError(err, 'STOP_PROXY_FAILED');
}
Future<void> stop() async {
final err = _stop();
_checkError(err, 'STOP_FAILED');
}
Future<String?> status() async {
final errOut = malloc<Pointer<Utf8>>();
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<String> tailscaleIP() async {
final errOut = malloc<Pointer<Utf8>>();
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);
}
}
}

View File

@ -1,13 +1,16 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:tsnet_flutter/tsnet_ffi.dart';
/// Embedded Tailscale tsnet client for Flutter. /// Embedded Tailscale tsnet client for Flutter.
/// ///
/// Provides a userspace WireGuard tunnel via Tailscale's tsnet library, /// Provides a userspace WireGuard tunnel via Tailscale's tsnet library,
/// without requiring the user to install the Tailscale app or a VPN entitlement. /// 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: /// Usage:
/// ```dart /// ```dart
@ -19,52 +22,93 @@ import 'package:flutter/services.dart';
class TsnetFlutter { class TsnetFlutter {
static const _channel = MethodChannel('tsnet_flutter'); static const _channel = MethodChannel('tsnet_flutter');
/// Join the Tailnet with the given auth key. /// FFI backend for Android/Linux initialized lazily.
/// TsnetFfi? _ffi;
/// [authKey] is a Tailscale auth key (reusable, tag-scoped).
/// [hostname] is the name this device will appear as in the Tailnet. bool get _useFfi => Platform.isAndroid || Platform.isLinux;
///
/// The auth key is typically received from the heater during BLE provisioning. TsnetFfi get _ensureFfi => _ffi ??= TsnetFfi();
/// State is persisted to the app's documents directory so the device
/// identity survives app restarts. /// State directory for persisting Tailscale identity.
Future<void> start({ String? _stateDir;
required String authKey,
String hostname = 'constellation-phone', Future<String> _getStateDir() async {
}) async { if (_stateDir != null) return _stateDir!;
await _channel.invokeMethod('start', {
'authKey': authKey, // Use a directory relative to the current working directory or temp
'hostname': hostname, // 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. /// Join the Tailnet with the given auth key.
/// Future<void> start({
/// Returns the local port number. Connect gRPC to `127.0.0.1:<port>`. required String authKey,
/// Traffic is forwarded through the Tailscale WireGuard tunnel to String hostname = 'tsnet-flutter',
/// [heaterTailscaleIP]:[port] on the heater. }) async {
/// if (_useFfi) {
/// Uses userspace netstack no VPN entitlement needed on iOS. final dir = await _getStateDir();
Future<int> startProxy(String heaterTailscaleIP, {int port = 5050}) async { await _ensureFfi.start(dir, authKey, hostname);
final localPort = await _channel.invokeMethod<int>('startProxy', { } else {
'ip': heaterTailscaleIP, await _channel.invokeMethod('start', {
'port': port, 'authKey': authKey,
}); 'hostname': hostname,
return localPort!; });
}
}
/// Create a localhost TCP proxy to a remote Tailscale IP.
/// Returns the local port number.
Future<int> startProxy(String remoteIP, {int port = 5050}) async {
if (_useFfi) {
return await _ensureFfi.startProxy(remoteIP, port);
} else {
final localPort = await _channel.invokeMethod<int>('startProxy', {
'ip': remoteIP,
'port': port,
});
return localPort!;
}
} }
/// Stop the local TCP proxy. /// Stop the local TCP proxy.
Future<void> stopProxy() async { Future<void> stopProxy() async {
await _channel.invokeMethod('stopProxy'); if (_useFfi) {
await _ensureFfi.stopProxy();
} else {
await _channel.invokeMethod('stopProxy');
}
} }
/// Disconnect from the Tailnet and clean up. /// Disconnect from the Tailnet and clean up.
Future<void> stop() async { Future<void> stop() async {
await _channel.invokeMethod('stop'); if (_useFfi) {
await _ensureFfi.stop();
} else {
await _channel.invokeMethod('stop');
}
} }
/// Get the current Tailscale connection status. /// Get the current Tailscale connection status.
Future<TailscaleStatus> status() async { Future<TailscaleStatus> status() async {
final json = await _channel.invokeMethod<String>('status'); String? json;
if (_useFfi) {
json = await _ensureFfi.status();
} else {
json = await _channel.invokeMethod<String>('status');
}
if (json == null) { if (json == null) {
return TailscaleStatus(state: TailscaleState.stopped); return TailscaleStatus(state: TailscaleState.stopped);
} }
@ -73,8 +117,12 @@ class TsnetFlutter {
/// Get this device's Tailscale IPv4 address (100.x.x.x). /// Get this device's Tailscale IPv4 address (100.x.x.x).
Future<String> tailscaleIP() async { Future<String> tailscaleIP() async {
final ip = await _channel.invokeMethod<String>('tailscaleIP'); if (_useFfi) {
return ip!; return await _ensureFfi.tailscaleIP();
} else {
final ip = await _channel.invokeMethod<String>('tailscaleIP');
return ip!;
}
} }
} }

8
linux/CMakeLists.txt Normal file
View File

@ -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)

BIN
linux/libtailscale.so Normal file

Binary file not shown.

View File

@ -49,6 +49,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
ffi:
dependency: "direct main"
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter

View File

@ -12,6 +12,7 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
ffi: ^2.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -25,3 +26,8 @@ flutter:
pluginClass: TsnetFlutterPlugin pluginClass: TsnetFlutterPlugin
macos: macos:
pluginClass: TsnetFlutterPlugin pluginClass: TsnetFlutterPlugin
android:
package: io.svrnty.tsnet_flutter
ffiPlugin: true
linux:
ffiPlugin: true