merge: 桌面端 PoC M1 — sing-box 子进程 TUN 接线 [tsk_SLCsjNgtmng3]

This commit is contained in:
wangjia
2026-06-15 20:56:37 +08:00
6 changed files with 1576 additions and 63 deletions
+133
View File
@@ -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 # 应显示节点 IP18.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 |
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# gen-poc-config.sh — 渲染 REALITY 客户端 PoC 配置(tsk_SLCsjNgtmng3
#
# 用法: ./gen-poc-config.sh [--out <output.json>]
#
# 从环境变量读取参数(须全部提供,无 --干跑 模式):
# 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=<value>\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'
@@ -0,0 +1,110 @@
{
"_comment": "sing-box 客户端配置模板 — VLESS+REALITY+TUNPoC 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"
}
}
}
+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');
}
}
+548 -63
View File
@@ -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. <executable dir>/sing-box (或 .exe)
// 3. <executable dir>/../../Resources/sing-box (macOS .app bundle)
// 4. <project>/app/kernel/dist/desktop/<os>-<arch>/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 errorUI 可一键重连)
//
// 进程生命周期:
// 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:<port>
/// 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<Map<String, dynamic>> getTraffic() {
throw UnimplementedError('ClashApiClient.getTraffic — 11D 实现');
Map<String, String> 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<Map<String, dynamic>> 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<void>();
throw HttpException('traffic: ${streamed.statusCode}');
}
final completer = Completer<Map<String, dynamic>>();
StreamSubscription<String>? 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<String, dynamic>);
} 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<Map<String, dynamic>> getProxies() {
throw UnimplementedError('ClashApiClient.getProxies — 11D 实现');
// ── GET /proxies ──────────────────────────────────────────────
Future<Map<String, dynamic>> 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<String, dynamic>;
}
/// PUT /proxies/{group} — 切换出口
Future<void> selectProxy(String group, String proxy) {
throw UnimplementedError('ClashApiClient.selectProxy — 11D 实现');
// ── PUT /proxies/{group} ──────────────────────────────────────
Future<void> 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<Map<String, dynamic>> getConnections() {
throw UnimplementedError('ClashApiClient.getConnections — 11D 实现');
// ── GET /connections ──────────────────────────────────────────
// 返回 {"downloadTotal": N, "uploadTotal": N, "connections": [...]}
// 累计字节;用于统计轮询和就绪探测。
Future<Map<String, dynamic>> 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<String, dynamic>;
}
/// DELETE /connections — 关闭所有连接
Future<void> closeAllConnections() {
throw UnimplementedError('ClashApiClient.closeAllConnections — 11D 实现');
// ── DELETE /connections ───────────────────────────────────────
Future<void> 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<void> spawn(String configPath);
/// 停止内核子进程。
///
/// 先发 SIGTERM[gracePeriod] 内未退出则 SIGKILL。
Future<void> kill({
Duration gracePeriod = const Duration(seconds: 3),
});
/// 停止内核子进程。先 SIGTERM[gracePeriod] 内未退出则 SIGKILL。
Future<void> kill({Duration gracePeriod = const Duration(seconds: 5)});
/// 进程是否正在运行。
bool get isRunning;
/// Clash 兼容 REST API 客户端(进程运行时可用)。
/// Clash 兼容 REST API 客户端(进程运行时有效)。
ClashApiClient get clashApiClient;
/// 进程标准输出/标准错误日志流(用于调试面板)。
/// 进程 stdout/stderr 实时日志流(调试面板)。
Stream<String> get logStream;
/// VPN 状态事件流:connecting → on(就绪)/ off(停止)/ error(意外退出)。
Stream<VpnStatus> get statusStream;
/// 每秒流量统计帧(字节累计 + 瞬时速率)。
Stream<VpnStatsEvent> 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 HelperWindows 直接以管理员启动。
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<VpnStatus>.broadcast();
final _logCtrl = StreamController<String>.broadcast();
final _statsCtrl = StreamController<VpnStatsEvent>.broadcast();
// ── 公开属性 ─────────────────────────────────────────────────
@override
Future<void> 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<void> kill({Duration gracePeriod = const Duration(seconds: 3)}) {
throw UnimplementedError('DesktopKernelProcess.kill — 11D 实现');
Stream<String> get logStream => _logCtrl.stream;
/// VPN 状态事件流(connecting / on / off / error)。
@override
Stream<VpnStatus> get statusStream => _statusCtrl.stream;
/// 每秒流量统计帧。
@override
Stream<VpnStatsEvent> get statsStream => _statsCtrl.stream;
// ── spawn ─────────────────────────────────────────────────────
@override
Future<void> 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<void> kill({Duration gracePeriod = const Duration(seconds: 5)}) async {
if (!_running && _process == null) return;
@override
Stream<String> 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<void> 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<void> _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<void>.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<String> _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;
}
// 开发目录: <project_root>/app/kernel/dist/desktop/<os>-<arch>/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 是 arm64Intel 是 x86_64
// 若 uname 不可用则按照 macOS 默认 arm64(M 系芯片为主)
if (Platform.isMacOS) return 'arm64';
return 'amd64';
}
// ── 命令构造 ─────────────────────────────────────────────────
List<String> _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<String, dynamic> json;
try {
json = jsonDecode(raw) as Map<String, dynamic>;
} 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 的随机高位端口(4915265535)。
int generateClashApiPort() {
return 49152 + math.Random().nextInt(65535 - 49152 + 1);
}
/// 生成 32 字节(64 hex 字符)随机 Secret。
String generateClashApiSecret() {
final rng = math.Random.secure();
final bytes = List<int>.generate(32, (_) => rng.nextInt(256));
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
+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>>());
});
});
}