- 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>
199 lines
5.5 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|