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>
This commit is contained in:
wangjia
2026-06-13 17:53:50 +08:00
parent ed5eabea58
commit 3c6a8a517a
6 changed files with 1576 additions and 63 deletions
+239
View File
@@ -0,0 +1,239 @@
// 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');
}
}