// desktop_vpn_bridge.dart — 桌面端 VpnBridge 实现(tsk_SLCsjNgtmng3) // // 实现 VpnBridge 接口,底层驱动 KernelProcess(默认使用 DesktopKernelProcess)。 // // 关键职责: // 1. start(configJson): // - 注入 experimental.clash_api(随机高位端口 + 随机 secret) // - 写 config 到 /pangolin/kernel/config_.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? _kernelStatusSub; // ── 退避延迟序列(秒):1/2/4/8/16/30/30/… static const _kRetryDelaysSec = [1, 2, 4, 8, 16, 30]; // ── VpnBridge: start ───────────────────────────────────────── @override Future 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 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 getStatus() async { return _kernel.isRunning ? VpnStatus.on : VpnStatus.off; } // ── VpnBridge: selectOutbound ───────────────────────────────── @override Future 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 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 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 get statusStream => _kernel.statusStream; @override Stream 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 cfg; try { cfg = jsonDecode(configJson) as Map; } catch (e) { throw FormatException('invalid configJson: $e'); } // 若已有 clash_api,尊重现有值 final rawExp = cfg['experimental']; final experimental = (rawExp is Map) ? rawExp.cast() : {}; if (experimental.containsKey('clash_api')) { final api = (experimental['clash_api'] as Map).cast(); 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 cfg; try { cfg = jsonDecode(configJson) as Map; } 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 {...Map.from(ib), 'strict_route': killSwitch}; } return ib; }).toList(); if (!changed) return configJson; return jsonEncode({...cfg, 'inbounds': updatedInbounds}); } // ── 内部: 配置文件写入 ──────────────────────────────────────── // @visibleForTesting Future 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 _resolveConfigDir() async { if (_configDirOverride != null) { return Directory(_configDirOverride); } return _defaultConfigDir(); } static Future _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'); } }