diff --git a/app/kernel/poc/README.md b/app/kernel/poc/README.md new file mode 100644 index 0000000..70a817e --- /dev/null +++ b/app/kernel/poc/README.md @@ -0,0 +1,133 @@ +# Pangolin 桌面端 PoC M1 — 接线指南 + +**任务**: tsk_SLCsjNgtmng3 · M1 首发打通(macOS TUN + REALITY) + +## 前置条件 + +### 1. 下载 sing-box 二进制 + +```bash +# macOS Apple Silicon +cd app/kernel +./fetch-desktop-bin.sh darwin arm64 + +# macOS Intel +./fetch-desktop-bin.sh darwin amd64 +``` + +产物: `app/kernel/dist/desktop/darwin-arm64/sing-box` + +### 2. 配置 TUN 提权(macOS PoC) + +sing-box TUN 模式需要创建 `utun` 接口,必须有 root 权限。PoC 阶段使用 `sudo`: + +```bash +# 方式 A: sudoers 免密白名单(推荐,避免每次输密码) +SINGBOX_PATH="$(pwd)/app/kernel/dist/desktop/darwin-arm64/sing-box" +echo "$(whoami) ALL=(root) NOPASSWD: ${SINGBOX_PATH}" | sudo tee /etc/sudoers.d/pangolin-singbox +sudo chmod 440 /etc/sudoers.d/pangolin-singbox + +# 方式 B: 用 setuid(不推荐用于正式版) +sudo chown root "${SINGBOX_PATH}" +sudo chmod u+s "${SINGBOX_PATH}" +``` + +> **正式版说明**: macOS 正式版应使用 SMJobBless 注册特权 Helper Daemon, +> 并完成 Apple 公证 (notarization)。此 PoC 路径记录在 BACKLOG-11D-HELPER。 + +### 3. 获取 REALITY 服务端参数 + +从部署节点的 EC2 拿 REALITY 公钥和 UUID: + +```bash +# EC2 上 Xray REALITY 密钥对(deploy/xray/secrets/ 或 Bitwarden) +ssh ec2 "cat ~/pangolin/xray/secrets/reality_keys.json 2>/dev/null || echo '不存在,需手动生成'" + +# 若未生成,在 EC2 上运行: +# docker run --rm ghcr.io/xtls/xray-core x25519 | tee /tmp/reality_keys.txt +``` + +### 4. 渲染 PoC 配置 + +```bash +export SERVER_HOST="18.136.60.128" # EC2 公网 IP +export SERVER_PORT="11443" # Xray VLESS 端口 +export REALITY_UUID="your-uuid-here" +export REALITY_PUBLIC_KEY="your-x25519-public-key" +export REALITY_SHORT_ID="your-short-id" +export REALITY_SNI="www.apple.com" + +./app/kernel/poc/gen-poc-config.sh --out /tmp/pangolin-poc-config.json +``` + +## 验收测试 + +### M1 验收步骤 + +```bash +# 1. 设置二进制路径 +export PANGOLIN_SINGBOX_BIN="$(pwd)/app/kernel/dist/desktop/darwin-arm64/sing-box" + +# 2. 用渲染后的 config 启动(PoC 会自动 sudo) +sudo "${PANGOLIN_SINGBOX_BIN}" run -c /tmp/pangolin-poc-config.json & +KERNEL_PID=$! + +# 3. 等待就绪(Clash API) +sleep 3 + +# 4. 验证出口 IP +curl -s https://ifconfig.me # 应显示节点 IP(18.136.60.128 或接近) + +# 5. DNS 泄露检测 +dig @8.8.8.8 google.com # 通过 VPN 解析 +dig google.com # 应走 sing-box DNS(不泄露本地 ISP) + +# 6. 流量统计(Clash API) +CLASH_PORT="$(cat /tmp/pangolin-poc-config.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['experimental']['clash_api']['external_controller'].split(':')[1])")" +CLASH_SECRET="$(cat /tmp/pangolin-poc-config.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['experimental']['clash_api']['secret'])")" + +curl -s -H "Authorization: Bearer ${CLASH_SECRET}" "http://127.0.0.1:${CLASH_PORT}/connections" + +# 7. 停止(还原路由/DNS) +kill "${KERNEL_PID}" +sleep 2 + +# 8. 验证还原 +netstat -rn | grep utun # 不应出现 pangolin TUN 条目 +scutil --dns | head -20 # DNS 应回到系统默认 +``` + +### Dart 集成测试 + +```bash +cd client +flutter test test/bridge/kernel_process_test.dart -v +``` + +## 架构说明 + +``` +DesktopVpnBridge.start(configJson) + │ + ├─ _injectClashApi() 注入随机端口+secret 到 experimental.clash_api + ├─ _writeConfig() 写 ~/Library/Application Support/com.pangolin.vpn/kernel/ + │ + └─ DesktopKernelProcess.spawn(configPath) + │ + ├─ _resolveBinaryPath() PANGOLIN_SINGBOX_BIN / 开发目录 / /usr/local/bin + ├─ Process.start(['sudo', binPath, 'run', '-c', configPath]) + ├─ _waitForClashApi() 轮询 GET /connections 直到就绪(20s 超时) + ├─ _startStatsPoll() 每秒轮询 /connections → VpnStatsEvent + └─ emit VpnStatus.on +``` + +## 已知限制(PoC 阶段) + +| 限制 | 原因 | 正式版方案 | +|------|------|------------| +| sudo 提权 | TUN 需 root | SMJobBless Helper + 公证 (BACKLOG-11D-HELPER) | +| 单 REALITY outbound | 节点选优未实现 | URLTest 多节点 (11G) | +| 统计用 /connections 轮询 | SSE 流实现更复杂 | /traffic SSE 订阅 | +| Windows Wintun 未测 | 主验收平台 macOS | M1 验收后补 | +| 无 kill-switch | strict_route 做基础保护 | 11G | +| Android/iOS | 归 11E/11F | NEPacket/VpnService | diff --git a/app/kernel/poc/gen-poc-config.sh b/app/kernel/poc/gen-poc-config.sh new file mode 100644 index 0000000..1f11e47 --- /dev/null +++ b/app/kernel/poc/gen-poc-config.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# gen-poc-config.sh — 渲染 REALITY 客户端 PoC 配置(tsk_SLCsjNgtmng3) +# +# 用法: ./gen-poc-config.sh [--out ] +# +# 从环境变量读取参数(须全部提供,无 --干跑 模式): +# SERVER_HOST EC2 IP(如 18.136.60.128) +# SERVER_PORT Xray VLESS 监听端口(如 11443) +# REALITY_UUID 客户端 UUID +# REALITY_PUBLIC_KEY REALITY 公钥 +# REALITY_SHORT_ID REALITY shortId +# REALITY_SNI TLS SNI(如 www.apple.com) +# +# 可选: +# CLASH_API_PORT Clash API 端口(默认随机 49152-65535) +# CLASH_API_SECRET Clash API secret(默认随机 32 字节 hex) +# +# 产物默认写到 /tmp/pangolin-poc-config.json(权限 0600)。 +# +# 注意:REALITY 私钥等参数绝不入库,仅在运行时从 EC2 secrets/ 或 Bitwarden 取。 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TMPL="${SCRIPT_DIR}/reality_client.config.json.tmpl" +OUT="${1:---out}" +if [[ "${OUT}" == "--out" ]]; then + OUT="${2:-/tmp/pangolin-poc-config.json}" + shift 2 2>/dev/null || true +fi + +# ── 参数检查 ────────────────────────────────────────────────────── + +_require() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + printf '✗ 环境变量 %s 未设置\n' "$name" >&2 + printf ' 示例: export %s=\n' "$name" >&2 + exit 1 + fi +} + +_require SERVER_HOST +_require SERVER_PORT +_require REALITY_UUID +_require REALITY_PUBLIC_KEY +_require REALITY_SHORT_ID + +REALITY_SNI="${REALITY_SNI:-www.apple.com}" + +# 生成随机 Clash API 端口和 secret(若未指定) +if [[ -z "${CLASH_API_PORT:-}" ]]; then + CLASH_API_PORT="$(python3 -c 'import random; print(random.randint(49152,65535))')" +fi +if [[ -z "${CLASH_API_SECRET:-}" ]]; then + CLASH_API_SECRET="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" +fi + +# ── 渲染模板 ────────────────────────────────────────────────────── + +printf '==> 渲染配置 → %s\n' "$OUT" + +sed \ + -e "s|__SERVER_HOST__|${SERVER_HOST}|g" \ + -e "s|__SERVER_PORT__|${SERVER_PORT}|g" \ + -e "s|__UUID__|${REALITY_UUID}|g" \ + -e "s|__REALITY_PUBLIC_KEY__|${REALITY_PUBLIC_KEY}|g" \ + -e "s|__REALITY_SHORT_ID__|${REALITY_SHORT_ID}|g" \ + -e "s|__REALITY_SNI__|${REALITY_SNI}|g" \ + -e "s|__CLASH_API_PORT__|${CLASH_API_PORT}|g" \ + -e "s|__CLASH_API_SECRET__|${CLASH_API_SECRET}|g" \ + "${TMPL}" > "${OUT}" + +chmod 600 "${OUT}" + +printf '✓ 已写入: %s\n' "$OUT" +printf ' Clash API: http://127.0.0.1:%s (secret len=%d)\n' \ + "$CLASH_API_PORT" "${#CLASH_API_SECRET}" +printf ' Server: %s:%s\n' "$SERVER_HOST" "$SERVER_PORT" +printf '\n提示: 使用此配置测试前,请确认 sing-box 有 TUN 权限:\n' +printf ' sudo sh -c "echo \"%%(whoami)s ALL=(root) NOPASSWD: /path/to/sing-box\" >> /etc/sudoers.d/pangolin"\n' diff --git a/app/kernel/poc/reality_client.config.json.tmpl b/app/kernel/poc/reality_client.config.json.tmpl new file mode 100644 index 0000000..a362814 --- /dev/null +++ b/app/kernel/poc/reality_client.config.json.tmpl @@ -0,0 +1,110 @@ +{ + "_comment": "sing-box 客户端配置模板 — VLESS+REALITY+TUN(PoC tsk_SLCsjNgtmng3)", + "_usage": "渲染脚本: app/kernel/poc/gen-poc-config.sh;占位符一律 __UPPER_SNAKE__", + + "log": { + "level": "info", + "timestamp": true + }, + + "dns": { + "servers": [ + { + "tag": "dns-remote", + "address": "tls://1.1.1.1", + "address_resolver": "dns-local", + "detour": "reality-out" + }, + { + "tag": "dns-local", + "address": "local", + "detour": "direct" + }, + { + "tag": "dns-block", + "address": "rcode://success" + } + ], + "rules": [ + { "outbound": "any", "server": "dns-local" }, + { "geosite": "cn", "server": "dns-local" }, + { "geoip": "private", "server": "dns-local" } + ], + "final": "dns-remote", + "independent_cache": true + }, + + "inbounds": [ + { + "type": "tun", + "tag": "tun-in", + "inet4_address": "172.19.0.1/30", + "inet6_address": "fdfe:dcba:9876::1/126", + "mtu": 1492, + "auto_route": true, + "strict_route": true, + "stack": "system", + "sniff": true, + "sniff_override_destination": false + } + ], + + "outbounds": [ + { + "type": "vless", + "tag": "reality-out", + "server": "__SERVER_HOST__", + "server_port": __SERVER_PORT__, + "uuid": "__UUID__", + "flow": "xtls-rprx-vision", + "tls": { + "enabled": true, + "server_name": "__REALITY_SNI__", + "utls": { + "enabled": true, + "fingerprint": "chrome" + }, + "reality": { + "enabled": true, + "public_key": "__REALITY_PUBLIC_KEY__", + "short_id": "__REALITY_SHORT_ID__" + } + }, + "packet_encoding": "xudp" + }, + { + "type": "direct", + "tag": "direct" + }, + { + "type": "block", + "tag": "block" + }, + { + "type": "dns", + "tag": "dns-out" + } + ], + + "route": { + "rules": [ + { "protocol": "dns", "outbound": "dns-out" }, + { "geosite": "cn", "outbound": "direct" }, + { "geoip": "cn", "outbound": "direct" }, + { "geoip": "private", "outbound": "direct" } + ], + "final": "reality-out", + "auto_detect_interface": true + }, + + "experimental": { + "clash_api": { + "external_controller": "127.0.0.1:__CLASH_API_PORT__", + "secret": "__CLASH_API_SECRET__" + }, + "cache_file": { + "enabled": true, + "path": "/tmp/pangolin-poc.db" + } + } +} diff --git a/client/lib/bridge/desktop_vpn_bridge.dart b/client/lib/bridge/desktop_vpn_bridge.dart new file mode 100644 index 0000000..2a6a785 --- /dev/null +++ b/client/lib/bridge/desktop_vpn_bridge.dart @@ -0,0 +1,239 @@ +// 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} 切换出口 +// +// 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 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 stop() => _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 中自定义组名。 + try { + await _kernel.clashApiClient.selectProxy('proxy', tag); + } catch (e) { + print('[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 ───────────────────────────────── + + @override + Future 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 get statusStream => _kernel.statusStream; + + @override + Stream 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 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); + } + + // ── 内部: 配置文件写入 ──────────────────────────────────────── + + // @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 (_) { + print('[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'); + } +} diff --git a/client/lib/bridge/kernel_process.dart b/client/lib/bridge/kernel_process.dart index dddf11d..d6fc482 100644 --- a/client/lib/bridge/kernel_process.dart +++ b/client/lib/bridge/kernel_process.dart @@ -1,112 +1,597 @@ -// kernel_process.dart — 桌面端内核子进程管理接口 +// kernel_process.dart — 桌面端内核子进程管理(tsk_SLCsjNgtmng3 实现版) // -// 桌面平台(macOS / Windows / Linux)直接将 sing-box 二进制作为 -// 子进程运行,通过其 Clash 兼容 REST API 进行控制。 +// 实现说明: +// · 进程管理: dart:io Process.start +// · macOS PoC 提权: sudo(开发机需有 sudo 权限 / sudoers 白名单) +// 正式版替换为 SMJobBless 特权 Helper + 公证 (BACKLOG-11D-HELPER) +// · Clash API: http 包 HTTP 轮询;readiness 探测 GET /connections; +// 统计通过 /connections 拿累计字节,差分算瞬时速率 +// · 二进制解析优先级(见 _resolveBinaryPath): +// 1. 环境变量 PANGOLIN_SINGBOX_BIN +// 2. /sing-box (或 .exe) +// 3. /../../Resources/sing-box (macOS .app bundle) +// 4. /app/kernel/dist/desktop/-/sing-box (开发目录) +// 5. /usr/local/bin/sing-box +// · 配置 Clash API 端口: 由 spawn 调用方(DesktopVpnBridge)写入配置, +// 或从配置文件读 experimental.clash_api.external_controller // -// 本文件仅定义接口契约;具体实现由任务 11D(桌面 PoC)完成。 +// 生命周期: +// spawn(configPath) → 起子进程 → 等 Clash API 就绪 → emit connecting→on +// kill() → SIGTERM → 等 ≤gracePeriod → SIGKILL → emit off +// 意外退出 → emit error(UI 可一键重连) // -// 进程生命周期: -// spawn(configPath) → 子进程启动,REST API 开始监听 -// kill() → 优雅停止(SIGTERM),超时后 SIGKILL -// -// Clash API 通信由 [ClashApiClient] 封装,基地址固定为 -// http://127.0.0.1:9090(可覆盖)。 +// ignore_for_file: avoid_print import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; -// ── Clash API 客户端 ───────────────────────────────────────────── +import 'package:http/http.dart' as http; + +import 'vpn_bridge.dart'; + +// ══════════════════════════════════════════════════════════════════ +// Clash API 客户端 +// ══════════════════════════════════════════════════════════════════ /// sing-box Clash 兼容 REST API 客户端。 -/// 实现由 11D(桌面 PoC)填充;本骨架仅定义调用约定。 +/// +/// baseUrl: http://127.0.0.1: +/// secret : experimental.clash_api.secret 的值(Bearer Token) class ClashApiClient { - ClashApiClient({this.baseUrl = 'http://127.0.0.1:9090'}); + ClashApiClient({ + required this.baseUrl, + this.secret = '', + http.Client? httpClient, + }) : _http = httpClient ?? http.Client(); final String baseUrl; + final String secret; + final http.Client _http; - /// GET /traffic — 流量统计 - Future> getTraffic() { - throw UnimplementedError('ClashApiClient.getTraffic — 11D 实现'); + Map get _headers => { + if (secret.isNotEmpty) 'Authorization': 'Bearer $secret', + 'Content-Type': 'application/json', + }; + + // ── GET /traffic ────────────────────────────────────────────── + // Clash /traffic 是 SSE 端点,推 {"up": N, "down": N}(bytes/s)。 + // 此实现读首帧后断开,供就绪探测或一次性快照使用。 + // 持续统计用 getConnections()(普通 JSON,含 downloadTotal/uploadTotal)。 + Future> getTraffic() async { + final uri = Uri.parse('$baseUrl/traffic'); + final request = http.Request('GET', uri); + request.headers.addAll(_headers); + request.headers['Accept'] = 'text/event-stream'; + + late http.StreamedResponse streamed; + try { + streamed = + await _http.send(request).timeout(const Duration(seconds: 3)); + } on TimeoutException { + throw const HttpException('timeout connecting to /traffic'); + } + + if (streamed.statusCode != 200) { + // 读掉 body,释放连接 + await streamed.stream.drain(); + throw HttpException('traffic: ${streamed.statusCode}'); + } + + final completer = Completer>(); + StreamSubscription? sub; + + sub = streamed.stream + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen( + (line) { + if (line.startsWith('data:')) { + final payload = line.substring(5).trim(); + if (payload.isNotEmpty && !completer.isCompleted) { + try { + completer.complete(jsonDecode(payload) as Map); + } catch (e) { + if (!completer.isCompleted) completer.completeError(e); + } + sub?.cancel(); + } + } + }, + onError: (Object e) { + if (!completer.isCompleted) completer.completeError(e); + }, + onDone: () { + if (!completer.isCompleted) { + completer.completeError( + const HttpException('SSE stream ended without data'), + ); + } + }, + cancelOnError: true, + ); + + return completer.future.timeout( + const Duration(seconds: 5), + onTimeout: () { + sub?.cancel(); + throw TimeoutException('/traffic SSE timeout'); + }, + ); } - /// GET /proxies — 节点列表 - Future> getProxies() { - throw UnimplementedError('ClashApiClient.getProxies — 11D 实现'); + // ── GET /proxies ────────────────────────────────────────────── + Future> getProxies() async { + final response = await _http + .get(Uri.parse('$baseUrl/proxies'), headers: _headers) + .timeout(const Duration(seconds: 3)); + if (response.statusCode != 200) { + throw HttpException('proxies: ${response.statusCode}'); + } + return jsonDecode(response.body) as Map; } - /// PUT /proxies/{group} — 切换出口 - Future selectProxy(String group, String proxy) { - throw UnimplementedError('ClashApiClient.selectProxy — 11D 实现'); + // ── PUT /proxies/{group} ────────────────────────────────────── + Future selectProxy(String group, String proxy) async { + final body = jsonEncode({'name': proxy}); + final response = await _http + .put(Uri.parse('$baseUrl/proxies/$group'), + headers: _headers, body: body) + .timeout(const Duration(seconds: 3)); + if (response.statusCode != 204 && response.statusCode != 200) { + throw HttpException('selectProxy: ${response.statusCode}'); + } } - /// GET /connections — 实时连接 - Future> getConnections() { - throw UnimplementedError('ClashApiClient.getConnections — 11D 实现'); + // ── GET /connections ────────────────────────────────────────── + // 返回 {"downloadTotal": N, "uploadTotal": N, "connections": [...]} + // 累计字节;用于统计轮询和就绪探测。 + Future> getConnections() async { + final response = await _http + .get(Uri.parse('$baseUrl/connections'), headers: _headers) + .timeout(const Duration(seconds: 3)); + if (response.statusCode != 200) { + throw HttpException('connections: ${response.statusCode}'); + } + return jsonDecode(response.body) as Map; } - /// DELETE /connections — 关闭所有连接 - Future closeAllConnections() { - throw UnimplementedError('ClashApiClient.closeAllConnections — 11D 实现'); + // ── DELETE /connections ─────────────────────────────────────── + Future closeAllConnections() async { + final response = await _http + .delete(Uri.parse('$baseUrl/connections'), headers: _headers) + .timeout(const Duration(seconds: 3)); + if (response.statusCode != 204 && response.statusCode != 200) { + throw HttpException('closeConnections: ${response.statusCode}'); + } } + + void dispose() => _http.close(); } -// ── 进程管理接口 ───────────────────────────────────────────────── +// ══════════════════════════════════════════════════════════════════ +// KernelProcess 接口 +// ══════════════════════════════════════════════════════════════════ /// 桌面端内核子进程管理接口。 -/// -/// 使用方式: -/// ```dart -/// final kernel = DesktopKernelProcess(); -/// await kernel.spawn('/path/to/config.json'); -/// final traffic = await kernel.clashApiClient.getTraffic(); -/// await kernel.kill(); -/// ``` abstract class KernelProcess { - /// 启动内核子进程。 - /// - /// [configPath] 为 sing-box JSON 配置文件的绝对路径。 - /// 返回的 [Future] 在进程就绪(REST API 可用)后 complete。 + /// 启动内核子进程。[configPath] 为 sing-box JSON 配置文件绝对路径。 + /// 返回的 Future 在进程就绪(Clash REST API 可用)后 complete。 Future spawn(String configPath); - /// 停止内核子进程。 - /// - /// 先发 SIGTERM,[gracePeriod] 内未退出则 SIGKILL。 - Future kill({ - Duration gracePeriod = const Duration(seconds: 3), - }); + /// 停止内核子进程。先 SIGTERM,[gracePeriod] 内未退出则 SIGKILL。 + Future kill({Duration gracePeriod = const Duration(seconds: 5)}); /// 进程是否正在运行。 bool get isRunning; - /// Clash 兼容 REST API 客户端(进程运行时可用)。 + /// Clash 兼容 REST API 客户端(仅进程运行时有效)。 ClashApiClient get clashApiClient; - /// 进程标准输出/标准错误日志流(用于调试面板)。 + /// 进程 stdout/stderr 实时日志流(调试面板用)。 Stream get logStream; + + /// VPN 状态事件流:connecting → on(就绪)/ off(停止)/ error(意外退出)。 + Stream get statusStream; + + /// 每秒流量统计帧(字节累计 + 瞬时速率)。 + Stream get statsStream; } -// ── 占位实现(防 analyze 报错)──────────────────────────────────── +// ══════════════════════════════════════════════════════════════════ +// DesktopKernelProcess — 真实子进程实现 +// ══════════════════════════════════════════════════════════════════ -/// 占位实现,抛出 [UnimplementedError]。 -/// 11D 替换为真实子进程实现时删除本类。 +/// 桌面端内核子进程管理(sing-box)。 +/// +/// 除 [KernelProcess] 接口外,还额外暴露: +/// · [statusStream] — VPN 状态事件(connecting / on / off / error) +/// · [statsStream] — 每秒流量统计帧(字节数 + 瞬时速率) +/// +/// 用法(通常由 [DesktopVpnBridge] 驱动,不直接在 UI 使用): +/// ```dart +/// final kernel = DesktopKernelProcess(); +/// await kernel.spawn('/tmp/pangolin/config.json'); +/// // 收流量统计 +/// kernel.statsStream.listen(print); +/// // 停止 +/// await kernel.kill(); +/// ``` class DesktopKernelProcess implements KernelProcess { + DesktopKernelProcess({ + Duration readyTimeout = const Duration(seconds: 20), + Duration statsPollInterval = const Duration(seconds: 1), + this.useSudo = true, + }) : _readyTimeout = readyTimeout, + _statsPollInterval = statsPollInterval; + + /// macOS/Linux PoC 时是否用 sudo 启动(TUN 接口需 root)。 + /// 正式版改用 SMJobBless Helper;Windows 直接以管理员启动。 + final bool useSudo; + + final Duration _readyTimeout; + final Duration _statsPollInterval; + + Process? _process; + ClashApiClient? _clashApi; + Timer? _statsTimer; + bool _running = false; + + // 上一帧累计字节(用于计算瞬时速率) + int _prevDownTotal = 0; + int _prevUpTotal = 0; + DateTime _prevPollTime = DateTime.now(); + + final _statusCtrl = StreamController.broadcast(); + final _logCtrl = StreamController.broadcast(); + final _statsCtrl = StreamController.broadcast(); + + // ── 公开属性 ───────────────────────────────────────────────── + @override - Future spawn(String configPath) { - throw UnimplementedError('DesktopKernelProcess.spawn — 11D 实现'); + bool get isRunning => _running; + + @override + ClashApiClient get clashApiClient { + if (_clashApi == null) throw StateError('kernel not running'); + return _clashApi!; } @override - Future kill({Duration gracePeriod = const Duration(seconds: 3)}) { - throw UnimplementedError('DesktopKernelProcess.kill — 11D 实现'); + Stream get logStream => _logCtrl.stream; + + /// VPN 状态事件流(connecting / on / off / error)。 + @override + Stream get statusStream => _statusCtrl.stream; + + /// 每秒流量统计帧。 + @override + Stream get statsStream => _statsCtrl.stream; + + // ── spawn ───────────────────────────────────────────────────── + + @override + Future spawn(String configPath) async { + if (_running) throw StateError('DesktopKernelProcess already running'); + + // 1. 解析二进制路径 + final binPath = await _resolveBinaryPath(); + _log('binary: $binPath'); + + // 2. 读取配置,提取 Clash API 地址和 secret + final clashEndpoint = await _parseClashEndpoint(configPath); + final clashUrl = 'http://${clashEndpoint.$1}'; + final clashSecret = clashEndpoint.$2; + _log('clash_api: $clashUrl (secret=${clashSecret.isNotEmpty})'); + + // 3. 初始化 ClashApiClient(进程启动前先建好,便于后续就绪检测) + _clashApi = ClashApiClient(baseUrl: clashUrl, secret: clashSecret); + + // 4. 发 connecting 事件 + _emitStatus(VpnStatus.connecting); + + // 5. 构造命令并启动子进程 + final cmd = _buildCommand(binPath, configPath); + _log('spawn: ${cmd.join(' ')}'); + try { + _process = await Process.start( + cmd.first, + cmd.skip(1).toList(), + environment: Platform.environment, + // 不设 workingDirectory,让 sing-box 自行处理 + ); + } catch (e) { + _clashApi!.dispose(); + _clashApi = null; + _emitStatus(VpnStatus.error); + throw ProcessException(cmd.first, cmd.skip(1).toList(), + 'failed to start: $e'); + } + + _running = true; + + // 6. 挂接 stdout/stderr 日志 + _process!.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((l) => _log('[stdout] $l')); + _process!.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((l) => _log('[stderr] $l')); + + // 7. 监测意外退出 + _process!.exitCode.then((code) { + if (_running) { + _running = false; + _statsTimer?.cancel(); + _statsTimer = null; + _log('[kernel] unexpected exit: code=$code'); + _emitStatus(VpnStatus.error); + } + }); + + // 8. 等待 Clash API 就绪 + try { + await _waitForClashApi(); + } catch (e) { + // 超时或 API 不可用 + await kill(gracePeriod: const Duration(seconds: 2)); + rethrow; + } + + // 9. 启动统计轮询 + _startStatsPoll(); + + // 10. 发 on 事件 + _emitStatus(VpnStatus.on); + _log('[kernel] ready'); } - @override - bool get isRunning => false; + // ── kill ────────────────────────────────────────────────────── @override - ClashApiClient get clashApiClient => - throw UnimplementedError('DesktopKernelProcess.clashApiClient — 11D 实现'); + Future kill({Duration gracePeriod = const Duration(seconds: 5)}) async { + if (!_running && _process == null) return; - @override - Stream get logStream => - throw UnimplementedError('DesktopKernelProcess.logStream — 11D 实现'); + _running = false; + _statsTimer?.cancel(); + _statsTimer = null; + + final proc = _process; + _process = null; + + if (proc != null) { + _log('[kernel] sending SIGTERM …'); + proc.kill(ProcessSignal.sigterm); + try { + await proc.exitCode.timeout(gracePeriod); + _log('[kernel] exited after SIGTERM'); + } on TimeoutException { + _log('[kernel] SIGTERM timeout, sending SIGKILL …'); + proc.kill(ProcessSignal.sigkill); + await proc.exitCode.timeout(const Duration(seconds: 3)).catchError((_) => -1); + } + } + + _clashApi?.dispose(); + _clashApi = null; + _resetStats(); + _emitStatus(VpnStatus.off); + _log('[kernel] stopped'); + } + + // ── 释放资源 ───────────────────────────────────────────────── + + Future dispose() async { + await kill(gracePeriod: const Duration(seconds: 3)); + await _statusCtrl.close(); + await _logCtrl.close(); + await _statsCtrl.close(); + } + + // ── 内部方法 ───────────────────────────────────────────────── + + void _emitStatus(VpnStatus s) { + if (!_statusCtrl.isClosed) _statusCtrl.add(s); + } + + void _log(String msg) { + print('[DesktopKernel] $msg'); + if (!_logCtrl.isClosed) _logCtrl.add(msg); + } + + void _resetStats() { + _prevDownTotal = 0; + _prevUpTotal = 0; + _prevPollTime = DateTime.now(); + } + + // ── 就绪等待 ───────────────────────────────────────────────── + + Future _waitForClashApi() async { + final deadline = DateTime.now().add(_readyTimeout); + while (DateTime.now().isBefore(deadline)) { + if (!_running) throw StateError('process exited during startup'); + try { + await _clashApi!.getConnections(); + return; // API 已就绪 + } catch (_) { + await Future.delayed(const Duration(milliseconds: 250)); + } + } + throw TimeoutException( + 'Clash API not ready after ${_readyTimeout.inSeconds}s'); + } + + // ── 统计轮询 ───────────────────────────────────────────────── + + void _startStatsPoll() { + _resetStats(); + _prevPollTime = DateTime.now(); + + _statsTimer = Timer.periodic(_statsPollInterval, (_) async { + if (!_running || _statsCtrl.isClosed) return; + try { + final now = DateTime.now(); + final data = await _clashApi!.getConnections(); + + final downTotal = + (data['downloadTotal'] as num?)?.toInt() ?? _prevDownTotal; + final upTotal = + (data['uploadTotal'] as num?)?.toInt() ?? _prevUpTotal; + + final deltaSec = + now.difference(_prevPollTime).inMilliseconds / 1000.0; + + final downSpeed = deltaSec > 0 + ? (downTotal - _prevDownTotal) / deltaSec + : 0.0; + final upSpeed = deltaSec > 0 + ? (upTotal - _prevUpTotal) / deltaSec + : 0.0; + + _prevDownTotal = downTotal; + _prevUpTotal = upTotal; + _prevPollTime = now; + + if (!_statsCtrl.isClosed) { + _statsCtrl.add(VpnStatsEvent( + uploadBytes: upTotal, + downloadBytes: downTotal, + uploadSpeed: upSpeed.clamp(0, double.infinity), + downloadSpeed: downSpeed.clamp(0, double.infinity), + urltestResults: const [], // URLTest 节点选优归 11G + )); + } + } catch (_) { + // 统计读取失败不影响连接状态;下次重试 + } + }); + } + + // ── 二进制路径解析 ──────────────────────────────────────────── + + static Future _resolveBinaryPath() async { + final envBin = Platform.environment['PANGOLIN_SINGBOX_BIN']; + if (envBin != null && envBin.isNotEmpty) { + final f = File(envBin); + if (await f.exists()) return envBin; + } + + final binName = Platform.isWindows ? 'sing-box.exe' : 'sing-box'; + + // 可执行文件同目录 + final exeDir = File(Platform.resolvedExecutable).parent; + for (final candidate in [ + File('${exeDir.path}/$binName'), + // macOS .app bundle: Contents/MacOS/../Resources/sing-box + File('${exeDir.parent.path}/Resources/$binName'), + ]) { + if (await candidate.exists()) return candidate.path; + } + + // 开发目录: /app/kernel/dist/desktop/-/sing-box + final os = _platformOsName(); + final arch = _platformArchName(); + if (os != null && arch != null) { + // 往上找项目根(含 app/kernel 的目录) + Directory dir = exeDir; + for (var i = 0; i < 8; i++) { + final candidate = + File('${dir.path}/app/kernel/dist/desktop/$os-$arch/$binName'); + if (await candidate.exists()) return candidate.path; + final parent = dir.parent; + if (parent.path == dir.path) break; + dir = parent; + } + } + + // 系统 PATH 兜底 + const fallback = '/usr/local/bin/sing-box'; + if (await File(fallback).exists()) return fallback; + + throw FileSystemException( + 'sing-box binary not found. ' + 'Set PANGOLIN_SINGBOX_BIN or run: ' + 'app/kernel/fetch-desktop-bin.sh ${os ?? "darwin"} ${arch ?? "arm64"}', + ); + } + + static String? _platformOsName() { + if (Platform.isMacOS) return 'darwin'; + if (Platform.isLinux) return 'linux'; + if (Platform.isWindows) return 'windows'; + return null; + } + + static String? _platformArchName() { + // Dart 目前没有公开 CPU arch 检测;用环境变量或约定 + final envArch = Platform.environment['PROCESSOR_ARCHITECTURE'] ?? + Platform.environment['HOSTTYPE'] ?? + ''; + if (envArch.contains('arm') || envArch.contains('aarch')) return 'arm64'; + // Apple Silicon Mac 的 HOSTTYPE 是 arm64,Intel 是 x86_64 + // 若 uname 不可用则按照 macOS 默认 arm64(M 系芯片为主) + if (Platform.isMacOS) return 'arm64'; + return 'amd64'; + } + + // ── 命令构造 ───────────────────────────────────────────────── + + List _buildCommand(String binPath, String configPath) { + if (useSudo && (Platform.isMacOS || Platform.isLinux)) { + // PoC: sudo 提权,开发机需配 sudoers 白名单(无密码)或近期有 sudo 缓存。 + // 正式版: SMJobBless (macOS) / polkit (Linux) + // BACKLOG-11D-HELPER: 实装特权 Helper + 公证后删除此分支 + return ['sudo', binPath, 'run', '-c', configPath]; + } + // Windows: 以管理员权限启动 Flutter 应用时 sing-box 自动获得管理员权 + return [binPath, 'run', '-c', configPath]; + } + + // ── 配置文件解析 ────────────────────────────────────────────── + + /// 读取 configPath,提取 experimental.clash_api.external_controller 和 secret。 + /// 返回 (controller_address, secret),如 ('127.0.0.1:51234', 'abc123')。 + static Future<(String, String)> _parseClashEndpoint(String configPath) async { + final raw = await File(configPath).readAsString(); + final Map json; + try { + json = jsonDecode(raw) as Map; + } catch (e) { + throw FormatException('invalid sing-box config: $e'); + } + + final exp = json['experimental']; + if (exp is! Map) { + return ('127.0.0.1:9090', ''); + } + final api = exp['clash_api']; + if (api is! Map) { + return ('127.0.0.1:9090', ''); + } + + final controller = + (api['external_controller'] as String?) ?? '127.0.0.1:9090'; + final secret = (api['secret'] as String?) ?? ''; + return (controller, secret); + } +} + +// ══════════════════════════════════════════════════════════════════ +// 辅助:随机 Clash API 端口 / Secret 生成器 +// ══════════════════════════════════════════════════════════════════ + +/// 生成用于 Clash API 的随机高位端口(49152–65535)。 +int generateClashApiPort() { + return 49152 + math.Random().nextInt(65535 - 49152 + 1); +} + +/// 生成 32 字节(64 hex 字符)随机 Secret。 +String generateClashApiSecret() { + final rng = math.Random.secure(); + final bytes = List.generate(32, (_) => rng.nextInt(256)); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); } diff --git a/client/test/bridge/kernel_process_test.dart b/client/test/bridge/kernel_process_test.dart new file mode 100644 index 0000000..6fd292e --- /dev/null +++ b/client/test/bridge/kernel_process_test.dart @@ -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.broadcast(); + final _logCtrl = StreamController.broadcast(); + final _statsCtrl = StreamController.broadcast(); + + @override + bool get isRunning => _running; + + @override + ClashApiClient get clashApiClient => + throw UnimplementedError('FakeKernelProcess.clashApiClient not needed in tests'); + + @override + Stream get logStream => _logCtrl.stream; + + @override + Stream get statusStream => _statusCtrl.stream; + + @override + Stream get statsStream => _statsCtrl.stream; + + @override + Future spawn(String configPath) async { + lastConfigPath = configPath; + if (shouldFailSpawn) { + _emitStatus(VpnStatus.error); + throw Exception('fake spawn failure'); + } + + _running = true; + _emitStatus(VpnStatus.connecting); + await Future.delayed(const Duration(milliseconds: 10)); + _emitStatus(VpnStatus.on); + + if (simulateUnexpectedExit) { + Future.delayed(const Duration(milliseconds: 60)).then((_) { + if (_running) { + _running = false; + _emitStatus(VpnStatus.error); + } + }); + } + } + + @override + Future 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 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 = []; + 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())); + 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? body; + final mock = MockClient((req) async { + expect(req.method, 'PUT'); + expect(req.url.path, '/proxies/my-group'); + body = jsonDecode(req.body) as Map; + 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 49152–65535', () { + 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; + final exp = decoded['experimental'] as Map; + final api = exp['clash_api'] as Map; + + 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()); + 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; + + expect((decoded['log'] as Map)['level'], 'info'); + expect(decoded['dns'], isA()); + }); + + test('throws FormatException on invalid JSON', () { + expect( + () => DesktopVpnBridge.injectClashApi('not json'), + throwsA(isA()), + ); + }); + + 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; + final exp = decoded['experimental'] as Map; + + // 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 = []; + final sub = bridge.statusStream.listen(events.add); + + await bridge.start('{"log":{"level":"info"}}'); + await Future.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 = []; + final sub = bridge.statusStream.listen(events.add); + + await bridge.start('{"log":{}}'); + await Future.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.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 = []; + final sub = bridge.statsStream.listen(stats.add); + + await bridge.start('{"log":{}}'); + await Future.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.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 = []; + final sub = bridge.statusStream.listen(events.add); + + await bridge.start('{"log":{}}'); + await Future.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 = []; + 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; + final exp = written['experimental'] as Map?; + 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>()); + expect(kp.statusStream, isA>()); + expect(kp.statsStream, isA>()); + }); + }); +}