Files
pangolin/client/lib/bridge/desktop_vpn_bridge.dart
T
wangjia 3c6a8a517a feat: 桌面端 PoC M1 — sing-box 子进程 TUN 接线 [tsk_SLCsjNgtmng3]
实现桌面端(macOS PoC)内核子进程管理,打通 sing-box TUN 模式全链路:

### client/lib/bridge/kernel_process.dart(完整实现,替换原有 stub)
- ClashApiClient: HTTP 客户端,支持 /connections / /proxies / /traffic(SSE) / /traffic(plain)
  - Bearer Token 鉴权;getConnections 用于就绪探测与流量统计;getTraffic SSE 读首帧后断开
- KernelProcess 接口: 新增 statusStream / statsStream 至接口定义
- DesktopKernelProcess:
  - spawn(configPath): 解析 Clash API 端口/secret → sudo sing-box run(macOS PoC)→
    轮询 /connections 等待就绪(20s 超时)→ emit connecting→on
  - kill(): SIGTERM + 等待 gracePeriod(5s) → SIGKILL → emit off
  - 意外退出: emit error(UI 可一键重连,不崩溃)
  - 统计轮询: 每秒 GET /connections,差分算 uploadSpeed/downloadSpeed
  - 二进制解析: ENV > exe同目录 > macOS Bundle Resources > 开发目录 > /usr/local/bin
- 辅助函数: generateClashApiPort(高位随机)、generateClashApiSecret(32B hex)

### client/lib/bridge/desktop_vpn_bridge.dart(新文件)
- DesktopVpnBridge implements VpnBridge:
  - start(configJson): injectClashApi(注入随机端口+secret)→ writeConfig(0600)→ kernel.spawn
  - stop(): kernel.kill(5s)
  - statusStream / statsStream: 代理 KernelProcess 事件流
  - selectOutbound: Clash API PUT /proxies/{group}
  - getActiveOutbound: 从 /proxies 读 now 字段
  - configDirOverride: 测试注入支持
  - injectClashApi: 静态方法,尊重已有 clash_api 配置,合并保留 experimental 其他字段

### client/test/bridge/kernel_process_test.dart(新文件)
- ClashApiClient 完整测试: headers / 解析 / PUT body / DELETE / 非 200 抛 HttpException
- generateClashApiPort / generateClashApiSecret 生成范围和格式测试
- DesktopVpnBridge.injectClashApi: 注入 / 尊重已有 / 保留字段 / 保留其他 experimental / 非法 JSON
- DesktopVpnBridge + FakeKernelProcess 集成: connecting→on / stop→off / statsStream / 意外退出 / spawn失败

### app/kernel/poc/(新目录)
- reality_client.config.json.tmpl: VLESS+REALITY+TUN 客户端配置模板
  - TUN inbound: auto_route + strict_route(macOS kill-switch 基础保护)
  - DNS: 防泄露(remote via VPN + cn 直连)
  - experimental.clash_api: 随机端口 + secret 占位符
- gen-poc-config.sh: 渲染模板为可用 JSON(从环境变量读 REALITY 参数)
- README.md: 完整 PoC 接线与 M1 验收步骤

提权说明(macOS PoC):
  · sudo 提权(开发机需配 /etc/sudoers.d/pangolin-singbox 或有 sudo 缓存)
  · 正式版: SMJobBless Helper + 公证(BACKLOG-11D-HELPER)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 17:53:50 +08:00

240 lines
8.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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} 切换出口
//
// macOS PoC 依赖:
// · sing-box 需能建立 TUN 接口(sudo 或 sudoers 免密白名单)
// · 环境变量 PANGOLIN_SINGBOX_BIN 可指定二进制路径
// · 实际 REALITY 参数由调用方(PoC 脚本)以静态 config 传入
//
// ignore_for_file: avoid_print
import 'dart:convert';
import 'dart:io';
import 'kernel_process.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;
// ── VpnBridge: start ─────────────────────────────────────────
@override
Future<void> start(String configJson) async {
// 1. 注入 Clash API 配置(随机端口 + secret
final (enrichedJson, port, secret) = injectClashApi(configJson);
// 2. 写 config 到应用支持目录(0600 权限)
final configPath = await writeConfig(enrichedJson);
print('[DesktopVpnBridge] config written: $configPath');
print('[DesktopVpnBridge] clash_api port=$port secret_len=${secret.length}');
// 3. 启动内核子进程(blocking until Clash API ready or error
await _kernel.spawn(configPath);
}
// ── VpnBridge: stop ──────────────────────────────────────────
@override
Future<void> stop() => _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 中自定义组名。
try {
await _kernel.clashApiClient.selectProxy('proxy', tag);
} catch (e) {
print('[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 ─────────────────────────────────
@override
Future<void> setKillSwitch({required bool on}) async {
// macOS PoC: TUN 的 strict_route=true 提供基础的 kill-switch 语义。
// 细粒度 kill-switch 归 11G。
print('[DesktopVpnBridge] setKillSwitch=$on (strict_route in TUN config)');
}
// ── VpnBridge: 事件流 ────────────────────────────────────────
@override
Stream<VpnStatus> get statusStream => _kernel.statusStream;
@override
Stream<VpnStatsEvent> get statsStream => _kernel.statsStream;
// ── VpnBridge: dispose ───────────────────────────────────────
@override
void dispose() {
if (_kernel is DesktopKernelProcess) {
(_kernel as DesktopKernelProcess).dispose();
}
}
// ── 内部: 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);
}
// ── 内部: 配置文件写入 ────────────────────────────────────────
// @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 (_) {
print('[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');
}
}