322d806876
- 新增 client/windows/build.ps1:拉内核→带 --dart-define 构建→出安装包一键串联, 规避 Release 漏 --dart-define 导致默认连 localhost、登录「网络请求失败」的坑。 - README:Release 命令补回 --dart-define 并加显式警告,指向一键脚本。 - 清 4 个 lint warning:desktop_vpn_bridge 多余 cast→pattern match、去多余非空断言; nav_sidebar 删未用 import;vpn_bridge_mock 加 killSwitchEnabled getter。 - 同步 window_manager/tray_manager/screen_retriever 插件注册生成文件(win+macos)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
377 lines
14 KiB
Dart
377 lines
14 KiB
Dart
// desktop_vpn_bridge.dart — 桌面端 VpnBridge 实现(tsk_SLCsjNgtmng3)
|
||
//
|
||
// 实现 VpnBridge 接口,底层驱动 KernelProcess(默认使用 DesktopKernelProcess)。
|
||
//
|
||
// 关键职责:
|
||
// 1. start(configJson):
|
||
// - 注入 experimental.clash_api(随机高位端口 + 随机 secret)
|
||
// - 写 config 到 <AppSupport>/pangolin/kernel/config_<ts>.json(权限 0600)
|
||
// - 调 kernel.spawn(configPath)
|
||
// 2. stop(): kernel.kill() + 取消自动重连
|
||
// 3. statusStream / statsStream: 代理 KernelProcess 事件流
|
||
// 4. selectOutbound: 通过 Clash API PUT /proxies/{group} 切换出口(M4)
|
||
// 5. setKillSwitch: 修改 TUN strict_route 并重载内核(M5)
|
||
// 6. 自动退避重连: 内核崩溃后 1s/2s/4s/8s/16s/30s 上限自动重试(M5)
|
||
//
|
||
// macOS PoC 依赖:
|
||
// · sing-box 需能建立 TUN 接口(sudo 或 sudoers 免密白名单)
|
||
// · 环境变量 PANGOLIN_SINGBOX_BIN 可指定二进制路径
|
||
// · 实际 REALITY 参数由调用方(PoC 脚本)以静态 config 传入
|
||
//
|
||
// ignore_for_file: avoid_print
|
||
|
||
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'kernel_process.dart';
|
||
import 'log.dart';
|
||
import 'vpn_bridge.dart';
|
||
|
||
/// 桌面端 VPN 桥接实现(macOS / Linux / Windows PoC)。
|
||
///
|
||
/// 使用示例(PoC 命令行测试):
|
||
/// ```dart
|
||
/// final bridge = DesktopVpnBridge();
|
||
/// bridge.statusStream.listen(print);
|
||
/// bridge.statsStream.listen(print);
|
||
/// await bridge.start(jsonEncode(myConfig));
|
||
/// // ...
|
||
/// await bridge.stop();
|
||
/// bridge.dispose();
|
||
/// ```
|
||
class DesktopVpnBridge implements VpnBridge {
|
||
/// 构造函数。
|
||
///
|
||
/// [kernel] 可注入假实现,用于测试。默认创建 [DesktopKernelProcess]。
|
||
/// [configDirOverride] 仅供测试使用,覆盖配置写入目录(否则写到应用支持目录)。
|
||
DesktopVpnBridge({
|
||
KernelProcess? kernel,
|
||
bool useSudo = true,
|
||
String? configDirOverride,
|
||
}) : _kernel = kernel ??
|
||
DesktopKernelProcess(
|
||
readyTimeout: const Duration(seconds: 20),
|
||
statsPollInterval: const Duration(seconds: 1),
|
||
useSudo: useSudo,
|
||
),
|
||
_configDirOverride = configDirOverride;
|
||
|
||
final KernelProcess _kernel;
|
||
final String? _configDirOverride;
|
||
|
||
// ── M5: Kill-switch 状态 ──────────────────────────────────────
|
||
bool _killSwitchEnabled = false;
|
||
|
||
// ── M5: 自动退避重连状态 ─────────────────────────────────────
|
||
// 记录用户最初传入的 config(未注入 Clash API 之前的原始字符串),
|
||
// 重连时重用,保持每次重连都有新 port+secret。
|
||
String? _lastUserConfigJson;
|
||
bool _shouldAutoReconnect = false;
|
||
int _reconnectAttempt = 0;
|
||
Timer? _reconnectTimer;
|
||
StreamSubscription<VpnStatus>? _kernelStatusSub;
|
||
|
||
// ── 退避延迟序列(秒):1/2/4/8/16/30/30/…
|
||
static const _kRetryDelaysSec = [1, 2, 4, 8, 16, 30];
|
||
|
||
// ── VpnBridge: start ─────────────────────────────────────────
|
||
|
||
@override
|
||
Future<void> start(String configJson) async {
|
||
// 保存原始 config(未注入 Clash API),用于重连
|
||
_lastUserConfigJson = configJson;
|
||
_shouldAutoReconnect = true;
|
||
// 注意:不在此重置 _reconnectAttempt,由 stop() 和重连成功回调负责重置,
|
||
// 确保连续 start 失败时退避延迟单调递增。
|
||
|
||
// 订阅内核状态流:内核崩溃时自动触发退避重连
|
||
_kernelStatusSub?.cancel();
|
||
_kernelStatusSub = _kernel.statusStream.listen((status) {
|
||
if (status == VpnStatus.error && _shouldAutoReconnect) {
|
||
_scheduleReconnect();
|
||
}
|
||
});
|
||
|
||
// 1. 将 kill-switch 偏好写入 config(strict_route 字段)
|
||
final configWithKs = applyKillSwitchToConfig(configJson, _killSwitchEnabled);
|
||
|
||
// 2. 注入 Clash API 配置(随机端口 + secret)
|
||
final (enrichedJson, port, secret) = injectClashApi(configWithKs);
|
||
|
||
// 3. 写 config 到应用支持目录(0600 权限)
|
||
final configPath = await writeConfig(enrichedJson);
|
||
logLine('DesktopVpnBridge', 'config written: $configPath');
|
||
logLine('DesktopVpnBridge', 'clash_api port=$port secret_len=${secret.length}');
|
||
logLine('DesktopVpnBridge', 'killSwitch=$_killSwitchEnabled');
|
||
|
||
// 4. 启动内核子进程(blocking until Clash API ready or error)
|
||
await _kernel.spawn(configPath);
|
||
}
|
||
|
||
// ── VpnBridge: stop ──────────────────────────────────────────
|
||
|
||
@override
|
||
Future<void> stop() async {
|
||
// 取消自动重连,防止 kill 后再次被重连定时器触发
|
||
_shouldAutoReconnect = false;
|
||
_reconnectTimer?.cancel();
|
||
_reconnectTimer = null;
|
||
_kernelStatusSub?.cancel();
|
||
_kernelStatusSub = null;
|
||
_reconnectAttempt = 0;
|
||
await _kernel.kill(gracePeriod: const Duration(seconds: 5));
|
||
}
|
||
|
||
// ── VpnBridge: getStatus ─────────────────────────────────────
|
||
|
||
@override
|
||
Future<VpnStatus> getStatus() async {
|
||
return _kernel.isRunning ? VpnStatus.on : VpnStatus.off;
|
||
}
|
||
|
||
// ── VpnBridge: selectOutbound ─────────────────────────────────
|
||
|
||
@override
|
||
Future<void> selectOutbound(String tag) async {
|
||
if (!_kernel.isRunning) {
|
||
throw StateError('kernel not running; cannot selectOutbound');
|
||
}
|
||
// sing-box Selector 出口组名固定为 "proxy"(见 config 模板 outbounds[].tag)。
|
||
// tag 可以是单节点 tag(如 "reality-out")或 urltest 组 tag(如 "auto-select")。
|
||
try {
|
||
await _kernel.clashApiClient.selectProxy('proxy', tag);
|
||
} catch (e) {
|
||
logLine('DesktopVpnBridge', 'selectOutbound(tag=$tag) error: $e');
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
// ── VpnBridge: getActiveOutbound ─────────────────────────────
|
||
|
||
@override
|
||
Future<String> getActiveOutbound() async {
|
||
if (!_kernel.isRunning) return 'auto';
|
||
try {
|
||
final data = await _kernel.clashApiClient.getProxies();
|
||
final proxies = data['proxies'];
|
||
if (proxies is Map) {
|
||
final proxy = proxies['proxy'];
|
||
if (proxy is Map) {
|
||
return (proxy['now'] as String?) ?? 'auto';
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
return 'auto';
|
||
}
|
||
|
||
// ── VpnBridge: setKillSwitch ─────────────────────────────────
|
||
|
||
/// 设置 Kill-switch 开关。
|
||
///
|
||
/// Kill-switch 通过修改 TUN inbound 的 `strict_route` 字段实现:
|
||
/// - `on=true`: `strict_route: true` → 内核停止时流量被系统丢弃
|
||
/// - `on=false`: `strict_route: false` → 内核停止时流量走正常路由
|
||
///
|
||
/// 若内核当前正在运行,会触发一次静默重载(先 kill,再以新 config 重启)。
|
||
@override
|
||
Future<void> setKillSwitch({required bool on}) async {
|
||
if (_killSwitchEnabled == on) return;
|
||
_killSwitchEnabled = on;
|
||
logLine('DesktopVpnBridge', 'setKillSwitch=$on');
|
||
|
||
// 若内核正在运行,用新设置重载(会有约 1~2s 重连)
|
||
if (_kernel.isRunning && _lastUserConfigJson != null) {
|
||
logLine('DesktopVpnBridge', 'reloading kernel for killSwitch change');
|
||
// 暂停自动重连,避免 kill 触发重连定时器
|
||
final wasAutoReconnect = _shouldAutoReconnect;
|
||
_shouldAutoReconnect = false;
|
||
_reconnectTimer?.cancel();
|
||
_kernelStatusSub?.cancel();
|
||
_kernelStatusSub = null;
|
||
await _kernel.kill(gracePeriod: const Duration(seconds: 3));
|
||
_shouldAutoReconnect = wasAutoReconnect;
|
||
// start() 会重新订阅 _kernelStatusSub
|
||
await start(_lastUserConfigJson!);
|
||
}
|
||
}
|
||
|
||
// ── VpnBridge: 事件流 ────────────────────────────────────────
|
||
|
||
@override
|
||
Stream<VpnStatus> get statusStream => _kernel.statusStream;
|
||
|
||
@override
|
||
Stream<VpnStatsEvent> get statsStream => _kernel.statsStream;
|
||
|
||
// ── VpnBridge: dispose ───────────────────────────────────────
|
||
|
||
@override
|
||
void dispose() {
|
||
_shouldAutoReconnect = false;
|
||
_reconnectTimer?.cancel();
|
||
_reconnectTimer = null;
|
||
_kernelStatusSub?.cancel();
|
||
_kernelStatusSub = null;
|
||
if (_kernel case final DesktopKernelProcess k) {
|
||
k.dispose();
|
||
}
|
||
}
|
||
|
||
// ── M5: 退避重连调度 ─────────────────────────────────────────
|
||
|
||
/// 安排下一次退避重连。
|
||
/// 延迟序列:1s → 2s → 4s → 8s → 16s → 30s → 30s → …
|
||
void _scheduleReconnect() {
|
||
if (!_shouldAutoReconnect || _lastUserConfigJson == null) return;
|
||
_reconnectTimer?.cancel();
|
||
|
||
final delaySec = _kRetryDelaysSec[
|
||
_reconnectAttempt.clamp(0, _kRetryDelaysSec.length - 1)];
|
||
_reconnectAttempt++;
|
||
|
||
logLine('DesktopVpnBridge',
|
||
'auto-reconnect in ${delaySec}s (attempt $_reconnectAttempt)');
|
||
|
||
_reconnectTimer = Timer(Duration(seconds: delaySec), () async {
|
||
if (!_shouldAutoReconnect || _lastUserConfigJson == null) return;
|
||
try {
|
||
logLine('DesktopVpnBridge', 'auto-reconnect: attempting start...');
|
||
await start(_lastUserConfigJson!);
|
||
_reconnectAttempt = 0; // 成功后重置退避计数
|
||
logLine('DesktopVpnBridge', 'auto-reconnect: success');
|
||
} catch (e) {
|
||
logLine('DesktopVpnBridge', 'auto-reconnect: start failed: $e');
|
||
// start() 失败(内核未能启动)→ 手动调度下一次重试
|
||
if (_shouldAutoReconnect) _scheduleReconnect();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── 内部: Clash API 注入(@visibleForTesting)─────────────────
|
||
|
||
/// 检查 configJson 中是否有 experimental.clash_api;若无则注入随机端口+secret。
|
||
/// 返回 (修改后 JSON, 端口, secret)。
|
||
// @visibleForTesting
|
||
static (String, int, String) injectClashApi(String configJson) {
|
||
late Map<String, dynamic> cfg;
|
||
try {
|
||
cfg = jsonDecode(configJson) as Map<String, dynamic>;
|
||
} catch (e) {
|
||
throw FormatException('invalid configJson: $e');
|
||
}
|
||
|
||
// 若已有 clash_api,尊重现有值
|
||
final rawExp = cfg['experimental'];
|
||
final experimental = (rawExp is Map)
|
||
? rawExp.cast<String, dynamic>()
|
||
: <String, dynamic>{};
|
||
|
||
if (experimental.containsKey('clash_api')) {
|
||
final api =
|
||
(experimental['clash_api'] as Map).cast<String, dynamic>();
|
||
final ctrl =
|
||
(api['external_controller'] as String?) ?? '127.0.0.1:9090';
|
||
final secret = (api['secret'] as String?) ?? '';
|
||
final port = int.tryParse(ctrl.split(':').last) ?? 9090;
|
||
return (configJson, port, secret);
|
||
}
|
||
|
||
// 注入随机 port + secret
|
||
final port = generateClashApiPort();
|
||
final secret = generateClashApiSecret();
|
||
final updated = {
|
||
...cfg,
|
||
'experimental': {
|
||
...experimental,
|
||
'clash_api': {
|
||
'external_controller': '127.0.0.1:$port',
|
||
'secret': secret,
|
||
},
|
||
},
|
||
};
|
||
return (jsonEncode(updated), port, secret);
|
||
}
|
||
|
||
// ── 内部: Kill-switch 注入(@visibleForTesting)──────────────
|
||
|
||
/// 将 kill-switch 偏好写入 configJson 的 TUN inbound `strict_route` 字段。
|
||
///
|
||
/// - `killSwitch=true`: `strict_route: true`(所有流量绑定 TUN,内核停止即断流)
|
||
/// - `killSwitch=false`: `strict_route: false`(内核停止后流量走默认路由)
|
||
///
|
||
/// 若配置中没有 TUN inbound,原样返回(不修改)。
|
||
// @visibleForTesting
|
||
static String applyKillSwitchToConfig(String configJson, bool killSwitch) {
|
||
late Map<String, dynamic> cfg;
|
||
try {
|
||
cfg = jsonDecode(configJson) as Map<String, dynamic>;
|
||
} catch (_) {
|
||
return configJson; // 解析失败,原样返回
|
||
}
|
||
|
||
final inbounds = cfg['inbounds'];
|
||
if (inbounds is! List) return configJson;
|
||
|
||
bool changed = false;
|
||
final updatedInbounds = inbounds.map((ib) {
|
||
if (ib is Map && ib['type'] == 'tun') {
|
||
changed = true;
|
||
return <String, dynamic>{...Map<String, dynamic>.from(ib), 'strict_route': killSwitch};
|
||
}
|
||
return ib;
|
||
}).toList();
|
||
|
||
if (!changed) return configJson;
|
||
return jsonEncode(<String, dynamic>{...cfg, 'inbounds': updatedInbounds});
|
||
}
|
||
|
||
// ── 内部: 配置文件写入 ────────────────────────────────────────
|
||
|
||
// @visibleForTesting
|
||
Future<String> writeConfig(String configJson) async {
|
||
final dir = await _resolveConfigDir();
|
||
await dir.create(recursive: true);
|
||
|
||
final ts = DateTime.now().millisecondsSinceEpoch;
|
||
final file = File('${dir.path}/config_$ts.json');
|
||
await file.writeAsString(configJson, flush: true);
|
||
|
||
// 0600 权限(仅当前用户可读写)
|
||
if (!Platform.isWindows) {
|
||
try {
|
||
await Process.run('chmod', ['600', file.path]);
|
||
} catch (_) {
|
||
logLine('DesktopVpnBridge', 'warning: chmod 600 failed for ${file.path}');
|
||
}
|
||
}
|
||
return file.path;
|
||
}
|
||
|
||
Future<Directory> _resolveConfigDir() async {
|
||
if (_configDirOverride != null) {
|
||
return Directory(_configDirOverride);
|
||
}
|
||
return _defaultConfigDir();
|
||
}
|
||
|
||
static Future<Directory> _defaultConfigDir() async {
|
||
if (Platform.isMacOS) {
|
||
final home = Platform.environment['HOME'] ?? '/tmp';
|
||
return Directory(
|
||
'$home/Library/Application Support/com.pangolin.vpn/kernel');
|
||
}
|
||
if (Platform.isLinux) {
|
||
final base = Platform.environment['XDG_DATA_HOME'] ??
|
||
'${Platform.environment['HOME'] ?? '/tmp'}/.local/share';
|
||
return Directory('$base/pangolin-vpn/kernel');
|
||
}
|
||
if (Platform.isWindows) {
|
||
final appData =
|
||
Platform.environment['LOCALAPPDATA'] ?? r'C:\ProgramData';
|
||
return Directory('$appData\\Pangolin\\kernel');
|
||
}
|
||
return Directory('/tmp/pangolin/kernel');
|
||
}
|
||
}
|