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
+465
View File
@@ -0,0 +1,465 @@
// kernel_process_test.dart — DesktopKernelProcess / DesktopVpnBridge 单元测试
//
// 验收条件(tsk_SLCsjNgtmng3):
// 1. ClashApiClient 构造参数正确存储
// 2. ClashApiClient.getConnections 请求正确(headers、解析)
// 3. ClashApiClient.selectProxy 发送 PUT + 正确 body
// 4. ClashApiClient.closeAllConnections 发送 DELETE
// 5. generateClashApiPort 生成合法高位端口(49152-65535
// 6. generateClashApiSecret 生成 64 位 hex
// 7. DesktopVpnBridge.injectClashApi 注入 Clash API 段(随机端口+secret
// 8. DesktopVpnBridge.injectClashApi 尊重已有 clash_api 配置
// 9. DesktopVpnBridge + FakeKernelProcess: start 触发 connecting→on 事件序列
// 10. DesktopVpnBridge: stop 触发 off
// 11. DesktopVpnBridge: statsStream 转发 kernel 事件
// 12. 意外退出推 error 状态(UI 不崩)
// 13. spawn 失败推 error 状态并抛异常
// ignore_for_file: avoid_print
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:pangolin_vpn/bridge/kernel_process.dart';
import 'package:pangolin_vpn/bridge/desktop_vpn_bridge.dart';
import 'package:pangolin_vpn/bridge/vpn_bridge.dart';
// ══════════════════════════════════════════════════════════════════
// FakeKernelProcess: implements KernelProcess,不依赖真实子进程
// ══════════════════════════════════════════════════════════════════
class FakeKernelProcess implements KernelProcess {
FakeKernelProcess({
this.shouldFailSpawn = false,
this.simulateUnexpectedExit = false,
});
final bool shouldFailSpawn;
final bool simulateUnexpectedExit;
bool _running = false;
String? lastConfigPath;
int killCalls = 0;
final _statusCtrl = StreamController<VpnStatus>.broadcast();
final _logCtrl = StreamController<String>.broadcast();
final _statsCtrl = StreamController<VpnStatsEvent>.broadcast();
@override
bool get isRunning => _running;
@override
ClashApiClient get clashApiClient =>
throw UnimplementedError('FakeKernelProcess.clashApiClient not needed in tests');
@override
Stream<String> get logStream => _logCtrl.stream;
@override
Stream<VpnStatus> get statusStream => _statusCtrl.stream;
@override
Stream<VpnStatsEvent> get statsStream => _statsCtrl.stream;
@override
Future<void> spawn(String configPath) async {
lastConfigPath = configPath;
if (shouldFailSpawn) {
_emitStatus(VpnStatus.error);
throw Exception('fake spawn failure');
}
_running = true;
_emitStatus(VpnStatus.connecting);
await Future<void>.delayed(const Duration(milliseconds: 10));
_emitStatus(VpnStatus.on);
if (simulateUnexpectedExit) {
Future<void>.delayed(const Duration(milliseconds: 60)).then((_) {
if (_running) {
_running = false;
_emitStatus(VpnStatus.error);
}
});
}
}
@override
Future<void> kill({Duration gracePeriod = const Duration(seconds: 5)}) async {
killCalls++;
if (_running) {
_running = false;
_emitStatus(VpnStatus.off);
}
}
// 测试辅助:向外注入统计帧
void emitStats(VpnStatsEvent e) {
if (!_statsCtrl.isClosed) _statsCtrl.add(e);
}
void disposeFake() {
_statusCtrl.close();
_logCtrl.close();
_statsCtrl.close();
}
void _emitStatus(VpnStatus s) {
if (!_statusCtrl.isClosed) _statusCtrl.add(s);
}
}
// ══════════════════════════════════════════════════════════════════
// 测试用 DesktopVpnBridge:覆盖 writeConfig,避免写真实文件系统
// ══════════════════════════════════════════════════════════════════
class _TestBridge extends DesktopVpnBridge {
_TestBridge(FakeKernelProcess kernel)
: fake = kernel,
super(kernel: kernel);
final FakeKernelProcess fake;
String? lastWrittenConfig;
@override
Future<String> writeConfig(String configJson) async {
lastWrittenConfig = configJson;
// 写到 systemTemp,避免权限问题(测试后清理)
final tmp = Directory.systemTemp.createTempSync('pangolin_test_');
final f = File('${tmp.path}/config.json')
..writeAsStringSync(configJson);
return f.path;
}
@override
void dispose() {
fake.disposeFake();
}
}
// ══════════════════════════════════════════════════════════════════
// 单元测试
// ══════════════════════════════════════════════════════════════════
void main() {
// ── 1. ClashApiClient ─────────────────────────────────────────
group('ClashApiClient', () {
test('stores baseUrl and secret', () {
const url = 'http://127.0.0.1:51234';
const secret = 'my-secret';
final c = ClashApiClient(baseUrl: url, secret: secret);
expect(c.baseUrl, url);
expect(c.secret, secret);
c.dispose();
});
test('getConnections sends Authorization header', () async {
final requests = <http.Request>[];
final mock = MockClient((req) async {
requests.add(req);
return http.Response(
jsonEncode({'downloadTotal': 100, 'uploadTotal': 50, 'connections': []}),
200,
);
});
final c = ClashApiClient(
baseUrl: 'http://127.0.0.1:9090',
secret: 'tok',
httpClient: mock);
await c.getConnections();
expect(requests, hasLength(1));
expect(requests[0].headers['Authorization'], 'Bearer tok');
expect(requests[0].url.path, '/connections');
c.dispose();
});
test('getConnections parses downloadTotal and uploadTotal', () async {
final mock = MockClient((_) async => http.Response(
jsonEncode({'downloadTotal': 12345, 'uploadTotal': 6789, 'connections': []}),
200,
));
final c = ClashApiClient(baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
final data = await c.getConnections();
expect(data['downloadTotal'], 12345);
expect(data['uploadTotal'], 6789);
c.dispose();
});
test('getConnections throws HttpException on non-200', () async {
final mock = MockClient((_) async => http.Response('Unauthorized', 401));
final c = ClashApiClient(
baseUrl: 'http://127.0.0.1:9090', secret: 'bad', httpClient: mock);
await expectLater(c.getConnections(), throwsA(isA<HttpException>()));
c.dispose();
});
test('getProxies sends correct GET request', () async {
final mock = MockClient((req) async {
expect(req.method, 'GET');
expect(req.url.path, '/proxies');
return http.Response(jsonEncode({'proxies': {}}), 200);
});
final c = ClashApiClient(baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
await c.getProxies();
c.dispose();
});
test('selectProxy sends PUT with correct body', () async {
Map<String, dynamic>? body;
final mock = MockClient((req) async {
expect(req.method, 'PUT');
expect(req.url.path, '/proxies/my-group');
body = jsonDecode(req.body) as Map<String, dynamic>;
return http.Response('', 204);
});
final c = ClashApiClient(baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
await c.selectProxy('my-group', 'sg-01');
expect(body?['name'], 'sg-01');
c.dispose();
});
test('closeAllConnections sends DELETE', () async {
final mock = MockClient((req) async {
expect(req.method, 'DELETE');
expect(req.url.path, '/connections');
return http.Response('', 204);
});
final c = ClashApiClient(baseUrl: 'http://127.0.0.1:9090', httpClient: mock);
await c.closeAllConnections();
c.dispose();
});
});
// ── 2. 随机生成工具函数 ───────────────────────────────────────
group('Port and secret generators', () {
test('generateClashApiPort is in range 4915265535', () {
for (var i = 0; i < 30; i++) {
final p = generateClashApiPort();
expect(p, greaterThanOrEqualTo(49152));
expect(p, lessThanOrEqualTo(65535));
}
});
test('generateClashApiSecret is 64 lowercase hex chars', () {
final s = generateClashApiSecret();
expect(s.length, 64);
expect(RegExp(r'^[0-9a-f]+$').hasMatch(s), isTrue);
});
test('generateClashApiSecret produces unique values', () {
final a = generateClashApiSecret();
final b = generateClashApiSecret();
expect(a, isNot(equals(b)));
});
});
// ── 3. DesktopVpnBridge.injectClashApi ───────────────────────
group('DesktopVpnBridge.injectClashApi', () {
test('injects clash_api when absent', () {
const cfg = '{"log":{"level":"info"}}';
final (json, port, secret) = DesktopVpnBridge.injectClashApi(cfg);
final decoded = jsonDecode(json) as Map<String, dynamic>;
final exp = decoded['experimental'] as Map<String, dynamic>;
final api = exp['clash_api'] as Map<String, dynamic>;
final ctrl = api['external_controller'] as String;
expect(ctrl, startsWith('127.0.0.1:'));
expect(int.parse(ctrl.split(':')[1]),
allOf(greaterThanOrEqualTo(49152), lessThanOrEqualTo(65535)));
expect(api['secret'], isA<String>());
expect((api['secret'] as String).length, 64);
expect(port, greaterThanOrEqualTo(49152));
expect(secret.length, 64);
});
test('respects existing clash_api config', () {
const existing = '{'
'"experimental":{"clash_api":{"external_controller":"127.0.0.1:12345","secret":"abc"}}'
'}';
final (json, port, secret) = DesktopVpnBridge.injectClashApi(existing);
// Should return original JSON unchanged
expect(json, equals(existing));
expect(port, 12345);
expect(secret, 'abc');
});
test('preserves other config fields', () {
const cfg = '{"log":{"level":"info"},"dns":{"servers":[]}}';
final (json, _, _) = DesktopVpnBridge.injectClashApi(cfg);
final decoded = jsonDecode(json) as Map<String, dynamic>;
expect((decoded['log'] as Map)['level'], 'info');
expect(decoded['dns'], isA<Map>());
});
test('throws FormatException on invalid JSON', () {
expect(
() => DesktopVpnBridge.injectClashApi('not json'),
throwsA(isA<FormatException>()),
);
});
test('preserves existing experimental fields when injecting clash_api', () {
const cfg = '{"experimental":{"cache_file":{"enabled":true}}}';
final (json, _, _) = DesktopVpnBridge.injectClashApi(cfg);
final decoded = jsonDecode(json) as Map<String, dynamic>;
final exp = decoded['experimental'] as Map<String, dynamic>;
// cache_file should be preserved
expect(exp.containsKey('cache_file'), isTrue);
// clash_api should be injected
expect(exp.containsKey('clash_api'), isTrue);
});
});
// ── 4. DesktopVpnBridge + FakeKernelProcess ──────────────────
group('DesktopVpnBridge with FakeKernelProcess', () {
_TestBridge makeBridge({
bool failSpawn = false,
bool unexpectedExit = false,
}) {
final fake = FakeKernelProcess(
shouldFailSpawn: failSpawn,
simulateUnexpectedExit: unexpectedExit,
);
return _TestBridge(fake);
}
test('start emits connecting then on', () async {
final bridge = makeBridge();
final events = <VpnStatus>[];
final sub = bridge.statusStream.listen(events.add);
await bridge.start('{"log":{"level":"info"}}');
await Future<void>.delayed(const Duration(milliseconds: 30));
expect(events, containsAllInOrder([VpnStatus.connecting, VpnStatus.on]));
await sub.cancel();
bridge.dispose();
});
test('stop emits off', () async {
final bridge = makeBridge();
final events = <VpnStatus>[];
final sub = bridge.statusStream.listen(events.add);
await bridge.start('{"log":{}}');
await Future<void>.delayed(const Duration(milliseconds: 20));
await bridge.stop();
expect(events.last, VpnStatus.off);
expect((bridge.fake).killCalls, 1);
await sub.cancel();
bridge.dispose();
});
test('getStatus returns on while running', () async {
final bridge = makeBridge();
expect(await bridge.getStatus(), VpnStatus.off);
await bridge.start('{"log":{}}');
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(await bridge.getStatus(), VpnStatus.on);
await bridge.stop();
expect(await bridge.getStatus(), VpnStatus.off);
bridge.dispose();
});
test('statsStream relays events from kernel', () async {
final bridge = makeBridge();
final stats = <VpnStatsEvent>[];
final sub = bridge.statsStream.listen(stats.add);
await bridge.start('{"log":{}}');
await Future<void>.delayed(const Duration(milliseconds: 20));
const frame = VpnStatsEvent(
uploadBytes: 1024,
downloadBytes: 4096,
uploadSpeed: 512.0,
downloadSpeed: 2048.0,
urltestResults: [],
);
bridge.fake.emitStats(frame);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(stats, hasLength(1));
expect(stats[0].uploadBytes, 1024);
expect(stats[0].downloadSpeed, 2048.0);
await sub.cancel();
bridge.dispose();
});
test('unexpected kernel exit emits error, UI does not crash', () async {
final bridge = makeBridge(unexpectedExit: true);
final events = <VpnStatus>[];
final sub = bridge.statusStream.listen(events.add);
await bridge.start('{"log":{}}');
await Future<void>.delayed(const Duration(milliseconds: 120));
expect(events, contains(VpnStatus.error));
// dispose must not throw
expect(() => bridge.dispose(), returnsNormally);
await sub.cancel();
});
test('failed spawn emits error and throws', () async {
final bridge = makeBridge(failSpawn: true);
final events = <VpnStatus>[];
final sub = bridge.statusStream.listen(events.add);
await expectLater(
() => bridge.start('{"log":{}}'),
throwsA(anything),
);
expect(events, contains(VpnStatus.error));
await sub.cancel();
bridge.dispose();
});
test('injectClashApi runs during start, enriched JSON written', () async {
final bridge = makeBridge();
await bridge.start('{"log":{"level":"info"}}');
final written = jsonDecode(bridge.lastWrittenConfig!) as Map<String, dynamic>;
final exp = written['experimental'] as Map<String, dynamic>?;
expect(exp, isNotNull);
expect(exp!.containsKey('clash_api'), isTrue);
bridge.dispose();
});
});
// ── 5. KernelProcess interface ───────────────────────────────
group('KernelProcess interface', () {
test('FakeKernelProcess satisfies KernelProcess', () {
// 静态类型检查:FakeKernelProcess implements KernelProcess
final KernelProcess kp = FakeKernelProcess();
expect(kp.isRunning, isFalse);
expect(kp.logStream, isA<Stream<String>>());
expect(kp.statusStream, isA<Stream<VpnStatus>>());
expect(kp.statsStream, isA<Stream<VpnStatsEvent>>());
});
});
}