flutter-tsnet/lib/tsnet_flutter.dart
Mathias Beaulieu-Duncan f070bcdb86 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>
2026-03-14 07:49:11 -04:00

199 lines
5.5 KiB
Dart

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.
///
/// - iOS/macOS: Swift MethodChannel → C bridge → Go static library
/// - Android/Linux: Dart FFI → Go shared library (.so)
///
/// Usage:
/// ```dart
/// final ts = TsnetFlutter();
/// await ts.start(authKey: 'tskey-auth-...');
/// final port = await ts.startProxy('100.64.0.5');
/// // Connect your client to localhost:port — traffic tunnels via WireGuard
/// ```
class TsnetFlutter {
static const _channel = MethodChannel('tsnet_flutter');
/// 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<String> _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!;
}
/// Join the Tailnet with the given auth key.
Future<void> 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<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.
Future<void> stopProxy() async {
if (_useFfi) {
await _ensureFfi.stopProxy();
} else {
await _channel.invokeMethod('stopProxy');
}
}
/// Disconnect from the Tailnet and clean up.
Future<void> stop() async {
if (_useFfi) {
await _ensureFfi.stop();
} else {
await _channel.invokeMethod('stop');
}
}
/// Get the current Tailscale connection status.
Future<TailscaleStatus> status() async {
String? json;
if (_useFfi) {
json = await _ensureFfi.status();
} else {
json = await _channel.invokeMethod<String>('status');
}
if (json == null) {
return TailscaleStatus(state: TailscaleState.stopped);
}
return TailscaleStatus.fromJson(jsonDecode(json) as Map<String, dynamic>);
}
/// Get this device's Tailscale IPv4 address (100.x.x.x).
Future<String> tailscaleIP() async {
if (_useFfi) {
return await _ensureFfi.tailscaleIP();
} else {
final ip = await _channel.invokeMethod<String>('tailscaleIP');
return ip!;
}
}
}
enum TailscaleState {
stopped,
starting,
running,
needsLogin,
}
class TailscaleStatus {
final TailscaleState state;
final String? selfIP;
final String? backendState;
final List<TailscalePeer> peers;
TailscaleStatus({
required this.state,
this.selfIP,
this.backendState,
this.peers = const [],
});
factory TailscaleStatus.fromJson(Map<String, dynamic> json) {
final backendState = json['BackendState'] as String? ?? '';
final state = switch (backendState) {
'Running' => TailscaleState.running,
'Starting' => TailscaleState.starting,
'NeedsLogin' || 'NeedsMachineAuth' => TailscaleState.needsLogin,
_ => TailscaleState.stopped,
};
final selfNode = json['Self'] as Map<String, dynamic>?;
final selfIP = selfNode?['TailscaleIPs'] is List
? (selfNode!['TailscaleIPs'] as List).firstOrNull as String?
: null;
final peerMap = json['Peer'] as Map<String, dynamic>? ?? {};
final peers = peerMap.values
.map((p) => TailscalePeer.fromJson(p as Map<String, dynamic>))
.toList();
return TailscaleStatus(
state: state,
selfIP: selfIP,
backendState: backendState,
peers: peers,
);
}
bool get isRunning => state == TailscaleState.running;
}
class TailscalePeer {
final String hostname;
final List<String> tailscaleIPs;
final bool online;
TailscalePeer({
required this.hostname,
required this.tailscaleIPs,
required this.online,
});
factory TailscalePeer.fromJson(Map<String, dynamic> json) {
final ips = json['TailscaleIPs'] as List? ?? [];
return TailscalePeer(
hostname: json['HostName'] as String? ?? '',
tailscaleIPs: ips.map((ip) => ip as String).toList(),
online: json['Online'] as bool? ?? false,
);
}
}