Go layer (c-archive) provides a localhost TCP proxy through a WireGuard tunnel. Flutter connects gRPC to localhost:PORT, Go forwards via tsnet to heater's Tailscale IP. No VPN entitlement needed — uses userspace netstack. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
151 lines
4.3 KiB
Dart
151 lines
4.3 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
/// Embedded Tailscale tsnet client for Flutter.
|
|
///
|
|
/// Provides a WireGuard tunnel to Constellation heaters via Tailscale,
|
|
/// without requiring the user to install the Tailscale app.
|
|
///
|
|
/// Architecture: Go xcframework (tsnet) ↔ Swift bridge ↔ MethodChannel ↔ Dart
|
|
///
|
|
/// Usage:
|
|
/// ```dart
|
|
/// final ts = TailscaleKit();
|
|
/// await ts.start(authKey: 'tskey-auth-...');
|
|
/// final port = await ts.startProxy('100.64.0.5');
|
|
/// grpcService.connect('127.0.0.1', port: port);
|
|
/// ```
|
|
class TailscaleKit {
|
|
static const _channel = MethodChannel('tailscale_kit');
|
|
|
|
/// 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<void> start({
|
|
required String authKey,
|
|
String hostname = 'constellation-phone',
|
|
}) async {
|
|
await _channel.invokeMethod('start', {
|
|
'authKey': authKey,
|
|
'hostname': hostname,
|
|
});
|
|
}
|
|
|
|
/// Create a localhost TCP proxy to a heater's Tailscale IP.
|
|
///
|
|
/// Returns the local port number. Connect gRPC to `127.0.0.1:<port>`.
|
|
/// Traffic is forwarded through the Tailscale WireGuard tunnel to
|
|
/// [heaterTailscaleIP]:[port] on the heater.
|
|
///
|
|
/// Uses userspace netstack — no VPN entitlement needed on iOS.
|
|
Future<int> startProxy(String heaterTailscaleIP, {int port = 5050}) async {
|
|
final localPort = await _channel.invokeMethod<int>('startProxy', {
|
|
'ip': heaterTailscaleIP,
|
|
'port': port,
|
|
});
|
|
return localPort!;
|
|
}
|
|
|
|
/// Stop the local TCP proxy.
|
|
Future<void> stopProxy() async {
|
|
await _channel.invokeMethod('stopProxy');
|
|
}
|
|
|
|
/// Disconnect from the Tailnet and clean up.
|
|
Future<void> stop() async {
|
|
await _channel.invokeMethod('stop');
|
|
}
|
|
|
|
/// Get the current Tailscale connection status.
|
|
Future<TailscaleStatus> status() async {
|
|
final 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 {
|
|
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,
|
|
);
|
|
}
|
|
}
|