feat(M4/M5): URLTest 自动选线 + Kill-switch + 弹性重连 [tsk_xAQhC1xuCd8x]
M4 URLTest 自动选线:
- ClashApiClient.getGroupDelay() — GET /group/<name>/delay 触发按需测速
- ClashApiClient.extractUrltestResults() — 从 /proxies 响应解析 URLTest
组成员最新延迟,填充 stats 帧的 urltestResults 字段
- DesktopKernelProcess._startStatsPoll() — 每秒并行拉取连接统计 + URLTest
延迟(_clashApi.getProxies()),URLTest 失败不影响主统计
- DesktopVpnBridge.selectOutbound(tag) — Clash API PUT /proxies/proxy,
tag 可传节点 tag 或 urltest 组 tag(如 "auto-select")恢复自动
- DesktopVpnBridge.getActiveOutbound() — GET /proxies 读 proxy.now
- app/kernel/poc/reality_client.config.json.tmpl — 增加 urltest("auto-select")
+ selector("proxy") 出口组,route.final 改为 "proxy"
M5 Kill-switch + 弹性重连:
- DesktopVpnBridge.setKillSwitch({required bool on}) — 将 _killSwitchEnabled
写入 TUN inbound strict_route 字段(applyKillSwitchToConfig);内核运行中
触发静默重载(kill → start(lastConfig))
- DesktopVpnBridge.applyKillSwitchToConfig() — static,killSwitch=true ↔
TUN strict_route=true,killSwitch=false ↔ strict_route=false
- 自动退避重连:kernel 崩溃推 error → _scheduleReconnect() 退避序列
1s/2s/4s/8s/16s/30s(上限),stop() 取消,重连成功后重置计数器
- DesktopKernelProcess 意外退出清理 _clashApi(避免下次 spawn 残留)
- app/pangolin/test/killswitch_checklist.md — 三端故障注入验收清单
(Desktop ✅,iOS/Android 欠账说明)
新增测试(client/test/bridge/desktop_vpn_bridge_m4m5_test.dart):
- M4: extractUrltestResults 解析/空/无 history 边界
- M4: getGroupDelay HTTP 请求格式验证
- M4: selectOutbound / getActiveOutbound Clash API 调用断言
- M5: applyKillSwitchToConfig 字段覆盖 + 无 TUN 边界
- M5: setKillSwitch 状态注入 + 同值幂等
- M5: 崩溃→error 不崩 / 首次重连在退避延迟内触发 / stop 后不再重连
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,9 +7,11 @@
|
||||
// - 注入 experimental.clash_api(随机高位端口 + 随机 secret)
|
||||
// - 写 config 到 <AppSupport>/pangolin/kernel/config_<ts>.json(权限 0600)
|
||||
// - 调 kernel.spawn(configPath)
|
||||
// 2. stop(): kernel.kill()
|
||||
// 2. stop(): kernel.kill() + 取消自动重连
|
||||
// 3. statusStream / statsStream: 代理 KernelProcess 事件流
|
||||
// 4. selectOutbound: 通过 Clash API PUT /proxies/{group} 切换出口
|
||||
// 4. selectOutbound: 通过 Clash API PUT /proxies/{group} 切换出口(M4)
|
||||
// 5. setKillSwitch: 修改 TUN strict_route 并重载内核(M5)
|
||||
// 6. 自动退避重连: 内核崩溃后 1s/2s/4s/8s/16s/30s 上限自动重试(M5)
|
||||
//
|
||||
// macOS PoC 依赖:
|
||||
// · sing-box 需能建立 TUN 接口(sudo 或 sudoers 免密白名单)
|
||||
@@ -18,6 +20,7 @@
|
||||
//
|
||||
// ignore_for_file: avoid_print
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -56,26 +59,68 @@ class DesktopVpnBridge implements VpnBridge {
|
||||
final KernelProcess _kernel;
|
||||
final String? _configDirOverride;
|
||||
|
||||
// ── M5: Kill-switch 状态 ──────────────────────────────────────
|
||||
bool _killSwitchEnabled = false;
|
||||
|
||||
// ── M5: 自动退避重连状态 ─────────────────────────────────────
|
||||
// 记录用户最初传入的 config(未注入 Clash API 之前的原始字符串),
|
||||
// 重连时重用,保持每次重连都有新 port+secret。
|
||||
String? _lastUserConfigJson;
|
||||
bool _shouldAutoReconnect = false;
|
||||
int _reconnectAttempt = 0;
|
||||
Timer? _reconnectTimer;
|
||||
StreamSubscription<VpnStatus>? _kernelStatusSub;
|
||||
|
||||
// ── 退避延迟序列(秒):1/2/4/8/16/30/30/…
|
||||
static const _kRetryDelaysSec = [1, 2, 4, 8, 16, 30];
|
||||
|
||||
// ── VpnBridge: start ─────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Future<void> start(String configJson) async {
|
||||
// 1. 注入 Clash API 配置(随机端口 + secret)
|
||||
final (enrichedJson, port, secret) = injectClashApi(configJson);
|
||||
// 保存原始 config(未注入 Clash API),用于重连
|
||||
_lastUserConfigJson = configJson;
|
||||
_shouldAutoReconnect = true;
|
||||
// 注意:不在此重置 _reconnectAttempt,由 stop() 和重连成功回调负责重置,
|
||||
// 确保连续 start 失败时退避延迟单调递增。
|
||||
|
||||
// 2. 写 config 到应用支持目录(0600 权限)
|
||||
// 订阅内核状态流:内核崩溃时自动触发退避重连
|
||||
_kernelStatusSub?.cancel();
|
||||
_kernelStatusSub = _kernel.statusStream.listen((status) {
|
||||
if (status == VpnStatus.error && _shouldAutoReconnect) {
|
||||
_scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// 1. 将 kill-switch 偏好写入 config(strict_route 字段)
|
||||
final configWithKs = applyKillSwitchToConfig(configJson, _killSwitchEnabled);
|
||||
|
||||
// 2. 注入 Clash API 配置(随机端口 + secret)
|
||||
final (enrichedJson, port, secret) = injectClashApi(configWithKs);
|
||||
|
||||
// 3. 写 config 到应用支持目录(0600 权限)
|
||||
final configPath = await writeConfig(enrichedJson);
|
||||
print('[DesktopVpnBridge] config written: $configPath');
|
||||
print('[DesktopVpnBridge] clash_api port=$port secret_len=${secret.length}');
|
||||
print('[DesktopVpnBridge] killSwitch=$_killSwitchEnabled');
|
||||
|
||||
// 3. 启动内核子进程(blocking until Clash API ready or error)
|
||||
// 4. 启动内核子进程(blocking until Clash API ready or error)
|
||||
await _kernel.spawn(configPath);
|
||||
}
|
||||
|
||||
// ── VpnBridge: stop ──────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Future<void> stop() => _kernel.kill(gracePeriod: const Duration(seconds: 5));
|
||||
Future<void> stop() async {
|
||||
// 取消自动重连,防止 kill 后再次被重连定时器触发
|
||||
_shouldAutoReconnect = false;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
_kernelStatusSub?.cancel();
|
||||
_kernelStatusSub = null;
|
||||
_reconnectAttempt = 0;
|
||||
await _kernel.kill(gracePeriod: const Duration(seconds: 5));
|
||||
}
|
||||
|
||||
// ── VpnBridge: getStatus ─────────────────────────────────────
|
||||
|
||||
@@ -91,7 +136,8 @@ class DesktopVpnBridge implements VpnBridge {
|
||||
if (!_kernel.isRunning) {
|
||||
throw StateError('kernel not running; cannot selectOutbound');
|
||||
}
|
||||
// sing-box Selector 出口组名默认为 "proxy";调用方可在 config 中自定义组名。
|
||||
// sing-box Selector 出口组名固定为 "proxy"(见 config 模板 outbounds[].tag)。
|
||||
// tag 可以是单节点 tag(如 "reality-out")或 urltest 组 tag(如 "auto-select")。
|
||||
try {
|
||||
await _kernel.clashApiClient.selectProxy('proxy', tag);
|
||||
} catch (e) {
|
||||
@@ -120,11 +166,33 @@ class DesktopVpnBridge implements VpnBridge {
|
||||
|
||||
// ── VpnBridge: setKillSwitch ─────────────────────────────────
|
||||
|
||||
/// 设置 Kill-switch 开关。
|
||||
///
|
||||
/// Kill-switch 通过修改 TUN inbound 的 `strict_route` 字段实现:
|
||||
/// - `on=true`: `strict_route: true` → 内核停止时流量被系统丢弃
|
||||
/// - `on=false`: `strict_route: false` → 内核停止时流量走正常路由
|
||||
///
|
||||
/// 若内核当前正在运行,会触发一次静默重载(先 kill,再以新 config 重启)。
|
||||
@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)');
|
||||
if (_killSwitchEnabled == on) return;
|
||||
_killSwitchEnabled = on;
|
||||
print('[DesktopVpnBridge] setKillSwitch=$on');
|
||||
|
||||
// 若内核正在运行,用新设置重载(会有约 1~2s 重连)
|
||||
if (_kernel.isRunning && _lastUserConfigJson != null) {
|
||||
print('[DesktopVpnBridge] reloading kernel for killSwitch change');
|
||||
// 暂停自动重连,避免 kill 触发重连定时器
|
||||
final wasAutoReconnect = _shouldAutoReconnect;
|
||||
_shouldAutoReconnect = false;
|
||||
_reconnectTimer?.cancel();
|
||||
_kernelStatusSub?.cancel();
|
||||
_kernelStatusSub = null;
|
||||
await _kernel.kill(gracePeriod: const Duration(seconds: 3));
|
||||
_shouldAutoReconnect = wasAutoReconnect;
|
||||
// start() 会重新订阅 _kernelStatusSub
|
||||
await start(_lastUserConfigJson!);
|
||||
}
|
||||
}
|
||||
|
||||
// ── VpnBridge: 事件流 ────────────────────────────────────────
|
||||
@@ -139,11 +207,46 @@ class DesktopVpnBridge implements VpnBridge {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shouldAutoReconnect = false;
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
_kernelStatusSub?.cancel();
|
||||
_kernelStatusSub = null;
|
||||
if (_kernel is DesktopKernelProcess) {
|
||||
(_kernel as DesktopKernelProcess).dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── M5: 退避重连调度 ─────────────────────────────────────────
|
||||
|
||||
/// 安排下一次退避重连。
|
||||
/// 延迟序列:1s → 2s → 4s → 8s → 16s → 30s → 30s → …
|
||||
void _scheduleReconnect() {
|
||||
if (!_shouldAutoReconnect || _lastUserConfigJson == null) return;
|
||||
_reconnectTimer?.cancel();
|
||||
|
||||
final delaySec = _kRetryDelaysSec[
|
||||
_reconnectAttempt.clamp(0, _kRetryDelaysSec.length - 1)];
|
||||
_reconnectAttempt++;
|
||||
|
||||
print(
|
||||
'[DesktopVpnBridge] auto-reconnect in ${delaySec}s (attempt $_reconnectAttempt)');
|
||||
|
||||
_reconnectTimer = Timer(Duration(seconds: delaySec), () async {
|
||||
if (!_shouldAutoReconnect || _lastUserConfigJson == null) return;
|
||||
try {
|
||||
print('[DesktopVpnBridge] auto-reconnect: attempting start...');
|
||||
await start(_lastUserConfigJson!);
|
||||
_reconnectAttempt = 0; // 成功后重置退避计数
|
||||
print('[DesktopVpnBridge] auto-reconnect: success');
|
||||
} catch (e) {
|
||||
print('[DesktopVpnBridge] auto-reconnect: start failed: $e');
|
||||
// start() 失败(内核未能启动)→ 手动调度下一次重试
|
||||
if (_shouldAutoReconnect) _scheduleReconnect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── 内部: Clash API 注入(@visibleForTesting)─────────────────
|
||||
|
||||
/// 检查 configJson 中是否有 experimental.clash_api;若无则注入随机端口+secret。
|
||||
@@ -189,6 +292,39 @@ class DesktopVpnBridge implements VpnBridge {
|
||||
return (jsonEncode(updated), port, secret);
|
||||
}
|
||||
|
||||
// ── 内部: Kill-switch 注入(@visibleForTesting)──────────────
|
||||
|
||||
/// 将 kill-switch 偏好写入 configJson 的 TUN inbound `strict_route` 字段。
|
||||
///
|
||||
/// - `killSwitch=true`: `strict_route: true`(所有流量绑定 TUN,内核停止即断流)
|
||||
/// - `killSwitch=false`: `strict_route: false`(内核停止后流量走默认路由)
|
||||
///
|
||||
/// 若配置中没有 TUN inbound,原样返回(不修改)。
|
||||
// @visibleForTesting
|
||||
static String applyKillSwitchToConfig(String configJson, bool killSwitch) {
|
||||
late Map<String, dynamic> cfg;
|
||||
try {
|
||||
cfg = jsonDecode(configJson) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return configJson; // 解析失败,原样返回
|
||||
}
|
||||
|
||||
final inbounds = cfg['inbounds'];
|
||||
if (inbounds is! List) return configJson;
|
||||
|
||||
bool changed = false;
|
||||
final updatedInbounds = inbounds.map((ib) {
|
||||
if (ib is Map && ib['type'] == 'tun') {
|
||||
changed = true;
|
||||
return <String, dynamic>{...Map<String, dynamic>.from(ib), 'strict_route': killSwitch};
|
||||
}
|
||||
return ib;
|
||||
}).toList();
|
||||
|
||||
if (!changed) return configJson;
|
||||
return jsonEncode(<String, dynamic>{...cfg, 'inbounds': updatedInbounds});
|
||||
}
|
||||
|
||||
// ── 内部: 配置文件写入 ────────────────────────────────────────
|
||||
|
||||
// @visibleForTesting
|
||||
|
||||
@@ -167,6 +167,70 @@ class ClashApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /group/<name>/delay ───────────────────────────────────
|
||||
// 触发一次 URLTest 延迟测试并返回结果 {tag: delayMs}。
|
||||
// 注意:此接口会发起真实网络探测,耗时约 timeoutMs。
|
||||
// 若只需读缓存延迟,使用 getProxies() + extractUrltestResults()。
|
||||
Future<Map<String, int>> getGroupDelay(
|
||||
String groupName, {
|
||||
String testUrl = 'http://www.gstatic.com/generate_204',
|
||||
int timeoutMs = 3000,
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/group/$groupName/delay').replace(
|
||||
queryParameters: {
|
||||
'url': testUrl,
|
||||
'timeout': timeoutMs.toString(),
|
||||
},
|
||||
);
|
||||
final response = await _http
|
||||
.get(uri, headers: _headers)
|
||||
.timeout(Duration(milliseconds: timeoutMs + 2000));
|
||||
if (response.statusCode != 200) {
|
||||
throw HttpException('getGroupDelay($groupName): ${response.statusCode}');
|
||||
}
|
||||
final data = jsonDecode(response.body);
|
||||
if (data is! Map) return {};
|
||||
return data.cast<String, int>();
|
||||
}
|
||||
|
||||
// ── 工具:从 /proxies 响应提取 URLTest 延迟列表 ──────────────
|
||||
// 扫描所有类型为 URLTest 的出口组,提取成员节点的最新 history delay。
|
||||
static List<UrltestResult> extractUrltestResults(
|
||||
Map<String, dynamic> proxiesResponse) {
|
||||
final proxies = proxiesResponse['proxies'];
|
||||
if (proxies is! Map) return const [];
|
||||
|
||||
final results = <UrltestResult>[];
|
||||
|
||||
for (final entry in proxies.entries) {
|
||||
final p = entry.value;
|
||||
if (p is! Map) continue;
|
||||
final type = (p['type'] as String?) ?? '';
|
||||
// URLTest / Fallback 组都有 all 成员列表
|
||||
if (type != 'URLTest' && type != 'Fallback') continue;
|
||||
|
||||
final all = p['all'];
|
||||
if (all is! List) continue;
|
||||
|
||||
for (final memberTag in all) {
|
||||
if (memberTag is! String) continue;
|
||||
final member = proxies[memberTag];
|
||||
if (member is! Map) continue;
|
||||
|
||||
final history = member['history'];
|
||||
if (history is List && history.isNotEmpty) {
|
||||
final last = history.last;
|
||||
if (last is Map) {
|
||||
final delay = (last['delay'] as num?)?.toInt() ?? -1;
|
||||
results.add(UrltestResult(tag: memberTag, delayMs: delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
void dispose() => _http.close();
|
||||
}
|
||||
|
||||
@@ -243,6 +307,9 @@ class DesktopKernelProcess implements KernelProcess {
|
||||
int _prevUpTotal = 0;
|
||||
DateTime _prevPollTime = DateTime.now();
|
||||
|
||||
// 最新 URLTest 延迟列表(每次 stats 轮询时更新)
|
||||
List<UrltestResult> _lastUrltestResults = const [];
|
||||
|
||||
final _statusCtrl = StreamController<VpnStatus>.broadcast();
|
||||
final _logCtrl = StreamController<String>.broadcast();
|
||||
final _statsCtrl = StreamController<VpnStatsEvent>.broadcast();
|
||||
@@ -327,6 +394,9 @@ class DesktopKernelProcess implements KernelProcess {
|
||||
_running = false;
|
||||
_statsTimer?.cancel();
|
||||
_statsTimer = null;
|
||||
// 清理 Clash API 客户端,确保下次 spawn() 重建干净的客户端
|
||||
_clashApi?.dispose();
|
||||
_clashApi = null;
|
||||
_log('[kernel] unexpected exit: code=$code');
|
||||
_emitStatus(VpnStatus.error);
|
||||
}
|
||||
@@ -406,6 +476,7 @@ class DesktopKernelProcess implements KernelProcess {
|
||||
_prevDownTotal = 0;
|
||||
_prevUpTotal = 0;
|
||||
_prevPollTime = DateTime.now();
|
||||
_lastUrltestResults = const [];
|
||||
}
|
||||
|
||||
// ── 就绪等待 ─────────────────────────────────────────────────
|
||||
@@ -435,7 +506,17 @@ class DesktopKernelProcess implements KernelProcess {
|
||||
if (!_running || _statsCtrl.isClosed) return;
|
||||
try {
|
||||
final now = DateTime.now();
|
||||
final data = await _clashApi!.getConnections();
|
||||
|
||||
// 并行拉取连接统计与 URLTest 延迟(Best-effort:URLTest 失败不影响主统计)
|
||||
final connFuture = _clashApi!.getConnections();
|
||||
final proxiesFuture = _clashApi!.getProxies().then((p) {
|
||||
_lastUrltestResults = ClashApiClient.extractUrltestResults(p);
|
||||
}).catchError((_) {
|
||||
// 读取失败静默跳过,保留上次缓存值
|
||||
});
|
||||
|
||||
final data = await connFuture;
|
||||
await proxiesFuture; // 等 URLTest 解析完再组帧
|
||||
|
||||
final downTotal =
|
||||
(data['downloadTotal'] as num?)?.toInt() ?? _prevDownTotal;
|
||||
@@ -462,7 +543,7 @@ class DesktopKernelProcess implements KernelProcess {
|
||||
downloadBytes: downTotal,
|
||||
uploadSpeed: upSpeed.clamp(0, double.infinity),
|
||||
downloadSpeed: downSpeed.clamp(0, double.infinity),
|
||||
urltestResults: const [], // URLTest 节点选优归 11G
|
||||
urltestResults: _lastUrltestResults,
|
||||
));
|
||||
}
|
||||
} catch (_) {
|
||||
|
||||
Reference in New Issue
Block a user