tt-help-cli-ycl 1.4.115 → 1.4.117
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/test-clash-discover.mjs +126 -0
- package/scripts/test-clash-multichannel.mjs +120 -0
- package/src/clash/api.js +29 -3
- package/src/clash/benchmark/lib/clash-api.js +3 -232
- package/src/clash/checker.js +63 -30
- package/src/clash/daemon.js +285 -108
- package/src/clash/discover.js +451 -0
- package/src/clash/ipc.js +65 -20
- package/src/cli/clash-daemon.js +10 -1
- package/src/cli/refresh-mini.js +36 -24
- package/src/lib/args.js +17 -0
- package/src/lib/constants.js +6 -1
- package/src/watch/public/app.js +58 -17
- package/src/watch/watchdog-server.js +14 -4
- package/src/watchdog/agent.js +16 -6
- package/src/watchdog/reporter.js +32 -1
package/src/clash/daemon.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ClashDaemon —
|
|
2
|
+
* ClashDaemon — 代理节点健康守护(多控制端)
|
|
3
3
|
*
|
|
4
|
-
* 职责:
|
|
5
|
-
*
|
|
4
|
+
* 职责: 定时巡检全部 mihomo 客户端 → 异常切换 → delay 初测生成候选集 → IPC 上报
|
|
5
|
+
* 多通道: 命令/健康文件按代理端口分通道(默认通道兼容 watchdog/旧调用方),
|
|
6
|
+
* 所有控制器操作经 _withLock 串行化(api/checker 为单活动上下文)
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { fileURLToPath } from "url";
|
|
@@ -10,6 +11,7 @@ import { normalizeCountries } from "./checker.js";
|
|
|
10
11
|
import * as api from "./api.js";
|
|
11
12
|
import * as checker from "./checker.js";
|
|
12
13
|
import * as ipc from "./ipc.js";
|
|
14
|
+
import { discoverAll, invalidateDiscoverCache } from "./discover.js";
|
|
13
15
|
|
|
14
16
|
// ── 国家过滤辅助(normalizeCountries 由 checker.js 提供单一来源) ──
|
|
15
17
|
/** 国家配置显示(数组 join) */
|
|
@@ -27,11 +29,23 @@ export class ClashDaemon {
|
|
|
27
29
|
this.intervalMs = (opts.intervalSec || 120) * 1000;
|
|
28
30
|
this.benchmarkMs = (opts.benchmarkMin || 10) * 60 * 1000; // 候选集刷新默认 10 分钟
|
|
29
31
|
this.dryRun = !!opts.dryRun;
|
|
32
|
+
// 控制端发现参数(CLI 显式值优先,内部再合并 ~/.tt-help.json)
|
|
33
|
+
this._discoverOpts = {
|
|
34
|
+
apiPort: opts.apiPort,
|
|
35
|
+
secret: opts.secret,
|
|
36
|
+
selection: opts.selection,
|
|
37
|
+
configPaths: opts.configPaths,
|
|
38
|
+
};
|
|
39
|
+
this._apiFailCount = 0;
|
|
30
40
|
this._running = false;
|
|
31
41
|
this._timers = [];
|
|
32
|
-
this._waitTimer = null; //
|
|
42
|
+
this._waitTimer = null; // 等待控制端就绪的重试定时器
|
|
33
43
|
this._startedOnce = false; // 首次启动标记(banner/信号只打印注册一次)
|
|
34
|
-
|
|
44
|
+
// 多控制端:每个控制器独立上下文;所有控制器操作经 _withLock 串行化
|
|
45
|
+
this._controllers = [];
|
|
46
|
+
this._primary = null;
|
|
47
|
+
this._lock = Promise.resolve();
|
|
48
|
+
this._lastDiscoverKey = "";
|
|
35
49
|
// 支持逗号分隔字符串(如 "US,JP"),便于 watchdog 以 --country 透传上次生效的多国配置
|
|
36
50
|
let initCountriesRaw = opts.country;
|
|
37
51
|
if (
|
|
@@ -54,6 +68,47 @@ export class ClashDaemon {
|
|
|
54
68
|
this._cmdHandlers = _buildCmdHandlers(this);
|
|
55
69
|
}
|
|
56
70
|
|
|
71
|
+
// ── 多控制端基础设施 ──
|
|
72
|
+
|
|
73
|
+
/** 控制器操作串行锁(api/checker 为全局单活动上下文,串行切换避免互相污染) */
|
|
74
|
+
_withLock(fn) {
|
|
75
|
+
const run = this._lock.then(fn, fn);
|
|
76
|
+
this._lock = run.catch(() => {});
|
|
77
|
+
return run;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 将全局 api/checker 上下文指向指定控制器 */
|
|
81
|
+
_useController(c) {
|
|
82
|
+
api.configure({ port: c.port, secret: c.secret });
|
|
83
|
+
checker.applyDiscovered({
|
|
84
|
+
port: c.port,
|
|
85
|
+
proxyUrl: c.proxyUrl,
|
|
86
|
+
proxyPort: c.proxyPort,
|
|
87
|
+
});
|
|
88
|
+
c._meta = c._meta || {};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 命令路由:按 proxyPort 找对应控制器,找不到回退主控制端 */
|
|
92
|
+
_routeController(cmd) {
|
|
93
|
+
if (cmd && cmd.proxyPort) {
|
|
94
|
+
const t = this._controllers.find(
|
|
95
|
+
(c) => c.proxyPort === Number(cmd.proxyPort),
|
|
96
|
+
);
|
|
97
|
+
if (t) return t;
|
|
98
|
+
this._log(
|
|
99
|
+
`📋 命令目标代理端口 ${cmd.proxyPort} 无对应控制端,回退主控制端`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return this._primary;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 写通道健康状态(每端口一份;主控制端额外写默认文件兼容 watchdog) */
|
|
106
|
+
_writeHealth(c, state) {
|
|
107
|
+
const payload = { ...state, meta: this._buildMeta(c) };
|
|
108
|
+
ipc.writeHealth(payload, c.proxyPort || 0);
|
|
109
|
+
if (c === this._primary && c.proxyPort) ipc.writeHealth(payload, 0);
|
|
110
|
+
}
|
|
111
|
+
|
|
57
112
|
// ── 生命周期 ──
|
|
58
113
|
|
|
59
114
|
async start() {
|
|
@@ -67,64 +122,108 @@ export class ClashDaemon {
|
|
|
67
122
|
this._setupSignals();
|
|
68
123
|
}
|
|
69
124
|
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
125
|
+
// ── 控制端自动发现(全部客户端:端口 + secret + 代理端口) ──
|
|
126
|
+
let discs;
|
|
127
|
+
try {
|
|
128
|
+
discs = await discoverAll(this._discoverOpts);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
// 显式指定端口验证失败:报错并等待重试,不回退默认(逃生舱语义)
|
|
131
|
+
this._log(`⚠ 控制端发现失败: ${e.message}(10s 后重试)`);
|
|
132
|
+
ipc.writeHealth({
|
|
133
|
+
node: "?",
|
|
134
|
+
healthy: false,
|
|
135
|
+
error: `控制端发现失败: ${e.message}`,
|
|
136
|
+
meta: this._buildMeta(null),
|
|
137
|
+
});
|
|
138
|
+
this._running = false;
|
|
139
|
+
this._waitTimer = setTimeout(() => this.start(), 10_000);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
this._controllers = discs.filter((d) => {
|
|
144
|
+
if (seen.has(d.port)) return false;
|
|
145
|
+
seen.add(d.port);
|
|
146
|
+
return true;
|
|
147
|
+
});
|
|
148
|
+
this._primary = this._controllers[0] || null;
|
|
149
|
+
|
|
150
|
+
// 控制端列表只在变化时打印(等待重试期间不刷屏)
|
|
151
|
+
const key =
|
|
152
|
+
this._controllers.map((c) => `${c.clientName}@${c.port}`).join(" | ") ||
|
|
153
|
+
"无";
|
|
154
|
+
if (this._lastDiscoverKey !== key) {
|
|
155
|
+
this._lastDiscoverKey = key;
|
|
156
|
+
for (const c of this._controllers) {
|
|
157
|
+
this._log(
|
|
158
|
+
`✓ 控制端[${c.proxyPort}]: ${c.clientName} @127.0.0.1:${c.port}(来源: ${c.source}${c.fallback ? ",回退默认值" : ""})`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 就绪检查:至少一个控制端 API 可达(以 API 控制端口为准)
|
|
164
|
+
const aliveAny = await this._withLock(async () => {
|
|
165
|
+
for (const c of this._controllers) {
|
|
166
|
+
this._useController(c);
|
|
167
|
+
if (checker.checkClashApiAlive()) return true;
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
});
|
|
171
|
+
if (!aliveAny) {
|
|
77
172
|
this._waitRetry = (this._waitRetry || 0) + 1;
|
|
78
|
-
// 前 3 次(约 30s)每次打印,之后每 6 次(约 1 分钟)打印一次,避免刷屏
|
|
79
173
|
const shouldLog = this._waitRetry <= 3 || this._waitRetry % 6 === 0;
|
|
80
174
|
if (shouldLog) {
|
|
81
175
|
const waitedSec = this._waitRetry * 10;
|
|
82
|
-
this._log(`⚠ Clash 未就绪:${reason}(已等待 ${waitedSec}s)`);
|
|
83
176
|
this._log(
|
|
84
|
-
|
|
177
|
+
`⚠ 未发现可用控制端(已等待 ${waitedSec}s),10s 后重试(客户端就绪后自动接入)...`,
|
|
85
178
|
);
|
|
86
179
|
}
|
|
87
180
|
ipc.writeHealth({
|
|
88
181
|
node: "?",
|
|
89
182
|
healthy: false,
|
|
90
|
-
error:
|
|
91
|
-
meta: this._buildMeta(),
|
|
183
|
+
error: "等待控制端就绪",
|
|
184
|
+
meta: this._buildMeta(null),
|
|
92
185
|
});
|
|
93
186
|
this._running = false;
|
|
94
187
|
this._waitTimer = setTimeout(() => this.start(), 10_000);
|
|
95
188
|
return;
|
|
96
189
|
}
|
|
97
|
-
this._waitRetry = 0;
|
|
98
|
-
// 等待期间的重试定时器已触发,清理引用
|
|
190
|
+
this._waitRetry = 0;
|
|
99
191
|
if (this._waitTimer) {
|
|
100
192
|
this._waitTimer = null;
|
|
101
193
|
}
|
|
102
|
-
const proxyUrl = await checker.resolveAndSetProxyUrl();
|
|
103
|
-
this._log(
|
|
104
|
-
`✓ Clash 已连接 (${reason}), 代理端口: ${proxyUrl.replace("http://", "")}`,
|
|
105
|
-
);
|
|
106
194
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
195
|
+
// 主控制端上下文初始化 + 候选集状态
|
|
196
|
+
let primaryStale = true;
|
|
197
|
+
await this._withLock(async () => {
|
|
198
|
+
this._useController(this._primary);
|
|
199
|
+
const proxyUrl = await checker.resolveAndSetProxyUrl();
|
|
200
|
+
this._log(
|
|
201
|
+
`✓ 主控制端: ${this._primary.clientName} (API ${this._primary.port}), 代理端口: ${proxyUrl.replace("http://", "")}`,
|
|
202
|
+
);
|
|
203
|
+
const list = checker.loadHealthyList();
|
|
204
|
+
const ageMin = list.updated
|
|
205
|
+
? Math.round((Date.now() - new Date(list.updated).getTime()) / 60000)
|
|
206
|
+
: Infinity;
|
|
207
|
+
primaryStale = !list.nodes.length || ageMin >= this.benchmarkMs / 60000;
|
|
208
|
+
this._log(
|
|
209
|
+
`${list.nodes.length ? `已加载 ${list.nodes.length} 个候选节点${primaryStale ? ` (已过期 ${ageMin}min,立即刷新)` : ""}` : "候选集为空,将立即执行 delay 初测"}`,
|
|
210
|
+
);
|
|
211
|
+
});
|
|
115
212
|
|
|
116
|
-
await this.
|
|
117
|
-
this._timers.push(
|
|
213
|
+
await this._healthCheckAll();
|
|
214
|
+
this._timers.push(
|
|
215
|
+
setInterval(() => this._healthCheckAll(), this.intervalMs),
|
|
216
|
+
);
|
|
118
217
|
|
|
119
|
-
const initial =
|
|
218
|
+
const initial = primaryStale ? 5000 : this.benchmarkMs;
|
|
120
219
|
setTimeout(() => {
|
|
121
|
-
this.
|
|
220
|
+
this._runDelayProbeAll();
|
|
122
221
|
this._timers.push(
|
|
123
|
-
setInterval(() => this.
|
|
222
|
+
setInterval(() => this._runDelayProbeAll(), this.benchmarkMs),
|
|
124
223
|
);
|
|
125
224
|
}, initial);
|
|
126
225
|
|
|
127
|
-
this._log(
|
|
226
|
+
this._log(`守护进程已启动(${this._controllers.length} 个控制端)`);
|
|
128
227
|
this._pollCommands();
|
|
129
228
|
this._timers.push(setInterval(() => this._pollCommands(), 10000));
|
|
130
229
|
}
|
|
@@ -140,37 +239,46 @@ export class ClashDaemon {
|
|
|
140
239
|
this._log("守护进程已停止");
|
|
141
240
|
}
|
|
142
241
|
|
|
143
|
-
// ──
|
|
242
|
+
// ── 巡检(每个控制端独立巡检,串行执行) ──
|
|
144
243
|
|
|
145
|
-
|
|
146
|
-
if (!this._running
|
|
244
|
+
_healthCheckAll() {
|
|
245
|
+
if (!this._running) return;
|
|
246
|
+
this._withLock(async () => {
|
|
247
|
+
for (const c of this._controllers) {
|
|
248
|
+
await this._healthCheckController(c);
|
|
249
|
+
}
|
|
250
|
+
}).catch((e) => this._log(`巡检异常: ${e.message}`));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async _healthCheckController(c) {
|
|
254
|
+
if (c._probing) return; // delay 初测中跳过本轮巡检
|
|
255
|
+
this._useController(c);
|
|
147
256
|
try {
|
|
148
257
|
if (!checker.checkClashAlive()) {
|
|
149
|
-
return
|
|
258
|
+
return this._writeHealth(c, {
|
|
150
259
|
node: "?",
|
|
151
260
|
healthy: false,
|
|
152
|
-
error:
|
|
153
|
-
meta: this._buildMeta(),
|
|
261
|
+
error: `${c.clientName} 未运行`,
|
|
154
262
|
});
|
|
155
263
|
}
|
|
156
264
|
const node = await api
|
|
157
265
|
.getCurrentNode(checker.CONFIG.groupName)
|
|
158
266
|
.catch(() => "?");
|
|
159
267
|
const h = checker.checkNodeHealth();
|
|
160
|
-
|
|
268
|
+
this._writeHealth(c, {
|
|
161
269
|
node,
|
|
162
270
|
healthy: h.healthy,
|
|
163
271
|
httpCode: h.httpCode,
|
|
164
272
|
timeTotal: h.timeTotal,
|
|
165
273
|
sizeDownload: h.sizeDownload,
|
|
166
274
|
title: h.title,
|
|
167
|
-
meta: this._buildMeta(),
|
|
168
275
|
});
|
|
169
276
|
|
|
170
277
|
if (h.healthy) {
|
|
278
|
+
c._apiFailCount = 0;
|
|
171
279
|
if (Math.random() < 0.17)
|
|
172
280
|
this._log(
|
|
173
|
-
`✓ ${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB`,
|
|
281
|
+
`✓ [${c.proxyPort}] ${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB`,
|
|
174
282
|
);
|
|
175
283
|
return;
|
|
176
284
|
}
|
|
@@ -179,24 +287,37 @@ export class ClashDaemon {
|
|
|
179
287
|
h.error || `HTTP ${h.httpCode} ${(h.sizeDownload / 1024).toFixed(0)}KB`;
|
|
180
288
|
const { nodes } = checker.loadHealthyList();
|
|
181
289
|
if (!nodes.length) {
|
|
182
|
-
|
|
183
|
-
|
|
290
|
+
// 候选集为空时初测有冷却:避免坏控制器(全部节点不可达)
|
|
291
|
+
// 每轮巡检都触发初测,霸占串行锁饿死其它控制端与命令处理
|
|
292
|
+
const gap = Date.now() - (c._lastProbeAt || 0);
|
|
293
|
+
if (gap >= this.benchmarkMs) {
|
|
294
|
+
this._log(
|
|
295
|
+
`✗ [${c.proxyPort}] 巡检异常 [${reason}] — 候选集为空,触发 delay 初测`,
|
|
296
|
+
);
|
|
297
|
+
await this._runDelayProbeController(c);
|
|
298
|
+
} else {
|
|
299
|
+
this._log(
|
|
300
|
+
`✗ [${c.proxyPort}] 巡检异常 [${reason}] — 候选集为空(初测冷却中 ${Math.round((this.benchmarkMs - gap) / 1000)}s)`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
184
303
|
return;
|
|
185
304
|
}
|
|
186
305
|
if (this.dryRun) {
|
|
187
306
|
this._log(
|
|
188
|
-
`[DRY-RUN] 当前节点 ${node} 异常 [${reason}],候选: ${nodes.length} 个`,
|
|
307
|
+
`[DRY-RUN] [${c.proxyPort}] 当前节点 ${node} 异常 [${reason}],候选: ${nodes.length} 个`,
|
|
189
308
|
);
|
|
190
309
|
return;
|
|
191
310
|
}
|
|
192
311
|
|
|
193
|
-
this._log(
|
|
312
|
+
this._log(
|
|
313
|
+
`✗ [${c.proxyPort}] 巡检异常 [${reason}],开始从候选集切换 (当前 ${node})`,
|
|
314
|
+
);
|
|
194
315
|
// 逐个切换候选 + 内容验证,第一个满足即采用
|
|
195
316
|
let target = null;
|
|
196
317
|
let h2 = null;
|
|
197
318
|
for (const cand of nodes) {
|
|
198
319
|
if (cand === node) continue;
|
|
199
|
-
this._log(`↺ 尝试候选: ${cand} [原因: ${reason}]`);
|
|
320
|
+
this._log(`↺ [${c.proxyPort}] 尝试候选: ${cand} [原因: ${reason}]`);
|
|
200
321
|
if (!(await checker.switchToNode(cand))) {
|
|
201
322
|
this._log(` ↺ ${cand} 切换失败,下一个`);
|
|
202
323
|
continue;
|
|
@@ -213,24 +334,25 @@ export class ClashDaemon {
|
|
|
213
334
|
// 全部候选不可用,恢复原节点
|
|
214
335
|
await checker.switchToNode(node).catch(() => {});
|
|
215
336
|
await api.closeAllConnections().catch(() => {});
|
|
216
|
-
this._log(
|
|
217
|
-
|
|
337
|
+
this._log(
|
|
338
|
+
`✗ [${c.proxyPort}] 恢复原节点 ${node}(原因: ${reason} — 候选全部不可用)`,
|
|
339
|
+
);
|
|
340
|
+
this._writeHealth(c, {
|
|
218
341
|
node,
|
|
219
342
|
healthy: false,
|
|
220
343
|
error: `${reason} — 候选全部不可用`,
|
|
221
344
|
lastSwitch: `巡检恢复(${reason}): → ${node}`,
|
|
222
|
-
meta: this._buildMeta(),
|
|
223
345
|
});
|
|
224
346
|
return;
|
|
225
347
|
}
|
|
226
348
|
|
|
227
349
|
this._log(
|
|
228
|
-
`⚠ 节点切换 [巡检异常: ${reason}]: ${node} → ${target} (${h2.timeTotal.toFixed(1)}s ${(h2.sizeDownload / 1024).toFixed(0)}KB)`,
|
|
350
|
+
`⚠ [${c.proxyPort}] 节点切换 [巡检异常: ${reason}]: ${node} → ${target} (${h2.timeTotal.toFixed(1)}s ${(h2.sizeDownload / 1024).toFixed(0)}KB)`,
|
|
229
351
|
);
|
|
230
352
|
// 切换后断开所有旧连接:mihomo 不主动掐旧连接,浏览器 keep-alive 会继续走旧节点
|
|
231
353
|
// 导致"时好时坏"(实测旧连接可存活 8+ 分钟),必须强制重建才即时生效
|
|
232
354
|
await api.closeAllConnections().catch(() => {});
|
|
233
|
-
|
|
355
|
+
this._writeHealth(c, {
|
|
234
356
|
node: target,
|
|
235
357
|
healthy: h2.healthy,
|
|
236
358
|
httpCode: h2.httpCode,
|
|
@@ -238,25 +360,60 @@ export class ClashDaemon {
|
|
|
238
360
|
sizeDownload: h2.sizeDownload,
|
|
239
361
|
title: h2.title,
|
|
240
362
|
lastSwitch: `巡检异常(${reason}): ${node} → ${target}`,
|
|
241
|
-
meta: this._buildMeta(),
|
|
242
363
|
});
|
|
243
364
|
} catch (e) {
|
|
244
|
-
this._log(
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
365
|
+
this._log(`[${c.proxyPort}] 巡检异常: ${e.message}`);
|
|
366
|
+
this._writeHealth(c, { node: "?", healthy: false, error: e.message });
|
|
367
|
+
// API 连续失败时重新发现控制端(FR-7 失效重探)
|
|
368
|
+
this._maybeRediscover(e).catch(() => {});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* API 连续失败时的控制端重发现
|
|
374
|
+
* 连续 2 次巡检异常触发一次完整重发现,防止端口漂移后失联
|
|
375
|
+
*/
|
|
376
|
+
async _maybeRediscover(e) {
|
|
377
|
+
this._apiFailCount = (this._apiFailCount || 0) + 1;
|
|
378
|
+
if (this._apiFailCount < 2) return;
|
|
379
|
+
this._apiFailCount = 0;
|
|
380
|
+
invalidateDiscoverCache();
|
|
381
|
+
this._log(`API 连续失败,重新发现控制端... (${e?.message || e})`);
|
|
382
|
+
try {
|
|
383
|
+
const discs = await discoverAll(this._discoverOpts);
|
|
384
|
+
const seen = new Set();
|
|
385
|
+
this._controllers = discs.filter((d) => {
|
|
386
|
+
if (seen.has(d.port)) return false;
|
|
387
|
+
seen.add(d.port);
|
|
388
|
+
return true;
|
|
250
389
|
});
|
|
390
|
+
this._primary = this._controllers[0] || null;
|
|
391
|
+
this._lastDiscoverKey = "";
|
|
392
|
+
this._log(
|
|
393
|
+
`✓ 重新选定 ${this._controllers.length} 个控制端: ${this._controllers.map((c) => `${c.clientName}@${c.port}`).join(", ") || "无"}`,
|
|
394
|
+
);
|
|
395
|
+
} catch (err) {
|
|
396
|
+
this._log(`重新发现控制端失败: ${err.message}`);
|
|
251
397
|
}
|
|
252
398
|
}
|
|
253
399
|
|
|
254
|
-
// ── 候选集 delay
|
|
400
|
+
// ── 候选集 delay 初测(每个控制端独立,零切换) ──
|
|
255
401
|
|
|
256
|
-
|
|
257
|
-
if (this.
|
|
258
|
-
this.
|
|
259
|
-
|
|
402
|
+
_runDelayProbeAll() {
|
|
403
|
+
if (!this._running) return;
|
|
404
|
+
this._withLock(async () => {
|
|
405
|
+
for (const c of this._controllers) {
|
|
406
|
+
await this._runDelayProbeController(c);
|
|
407
|
+
}
|
|
408
|
+
}).catch((e) => this._log(`delay 初测异常: ${e.message}`));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async _runDelayProbeController(c) {
|
|
412
|
+
if (c._probing) return;
|
|
413
|
+
c._probing = true;
|
|
414
|
+
c._lastProbeAt = Date.now();
|
|
415
|
+
this._useController(c);
|
|
416
|
+
this._log(`=== [${c.proxyPort}] 节点 delay 初测开始(零切换) ===`);
|
|
260
417
|
const start = Date.now();
|
|
261
418
|
try {
|
|
262
419
|
const r = await checker.runDelayProbe({
|
|
@@ -264,64 +421,74 @@ export class ClashDaemon {
|
|
|
264
421
|
});
|
|
265
422
|
const elapsed = ((Date.now() - start) / 1000).toFixed(0);
|
|
266
423
|
checker.saveHealthyList(r.nodes);
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
424
|
+
c._meta.lastBenchmark = new Date().toISOString();
|
|
425
|
+
c._meta.healthyNodesTotal = r.total;
|
|
426
|
+
c._meta.healthyNodesCount = r.nodes.length;
|
|
270
427
|
if (r.nodes.length) {
|
|
271
428
|
this._log(
|
|
272
|
-
`=== delay 初测完成 (${elapsed}s): ${r.nodes.length} 个可达节点 ===`,
|
|
429
|
+
`=== [${c.proxyPort}] delay 初测完成 (${elapsed}s): ${r.nodes.length} 个可达节点 ===`,
|
|
273
430
|
);
|
|
274
431
|
} else {
|
|
275
|
-
this._log(
|
|
432
|
+
this._log(
|
|
433
|
+
`=== [${c.proxyPort}] delay 初测完成 (${elapsed}s): 0 个可达节点 ===`,
|
|
434
|
+
);
|
|
276
435
|
}
|
|
277
436
|
} catch (e) {
|
|
278
|
-
this._log(`delay 初测异常: ${e.message}`);
|
|
437
|
+
this._log(`[${c.proxyPort}] delay 初测异常: ${e.message}`);
|
|
279
438
|
} finally {
|
|
280
|
-
|
|
439
|
+
c._probing = false;
|
|
281
440
|
}
|
|
282
441
|
}
|
|
283
442
|
|
|
284
|
-
// ──
|
|
443
|
+
// ── 命令轮询(多通道:默认通道 + 每端口通道) ──
|
|
285
444
|
|
|
286
445
|
_pollCommands() {
|
|
287
446
|
if (!this._running) return;
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
447
|
+
this._withLock(async () => {
|
|
448
|
+
for (const ch of ipc.listCommandChannels()) {
|
|
449
|
+
let cmds = null;
|
|
450
|
+
try {
|
|
451
|
+
cmds = ipc.readCommand(ch);
|
|
452
|
+
} catch {}
|
|
453
|
+
if (!cmds) continue;
|
|
454
|
+
for (const cmd of cmds) {
|
|
455
|
+
const target = this._routeController(cmd);
|
|
456
|
+
this._useController(target);
|
|
457
|
+
const handler = this._cmdHandlers[cmd.action];
|
|
458
|
+
if (handler) {
|
|
459
|
+
await handler(cmd, target);
|
|
460
|
+
} else {
|
|
461
|
+
this._log(`📋 未知命令: ${JSON.stringify(cmd)}`);
|
|
462
|
+
}
|
|
297
463
|
}
|
|
464
|
+
ipc.clearCommand(ch);
|
|
298
465
|
}
|
|
299
|
-
|
|
300
|
-
} catch {}
|
|
466
|
+
}).catch(() => {});
|
|
301
467
|
}
|
|
302
468
|
|
|
303
|
-
async _handleSwitchNode(cmd) {
|
|
469
|
+
async _handleSwitchNode(cmd, c) {
|
|
304
470
|
try {
|
|
305
471
|
const before = await api
|
|
306
472
|
.getCurrentNode(checker.CONFIG.groupName)
|
|
307
473
|
.catch(() => "?");
|
|
308
|
-
this._log(
|
|
474
|
+
this._log(
|
|
475
|
+
`🔄 看门狗指令: [${c.proxyPort}] 切换节点 → ${cmd.node} (当前: ${before})`,
|
|
476
|
+
);
|
|
309
477
|
if (!(await checker.switchToNode(cmd.node))) {
|
|
310
478
|
this._log("🔄 看门狗指令: 切换失败");
|
|
311
|
-
return
|
|
479
|
+
return this._writeHealth(c, {
|
|
312
480
|
node: before,
|
|
313
481
|
healthy: null,
|
|
314
482
|
lastSwitch: `manual → ${cmd.node} (失败)`,
|
|
315
|
-
meta: this._buildMeta(),
|
|
316
483
|
});
|
|
317
484
|
}
|
|
318
485
|
const h = checker.checkNodeHealth();
|
|
319
486
|
// 切换后断开所有旧连接,浏览器立即重建到新节点(同巡检切换)
|
|
320
487
|
await api.closeAllConnections().catch(() => {});
|
|
321
488
|
this._log(
|
|
322
|
-
`🔄 看门狗指令: 切换完成 (${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB, ${h.healthy ? "健康" : "异常"})`,
|
|
489
|
+
`🔄 看门狗指令: [${c.proxyPort}] 切换完成 (${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB, ${h.healthy ? "健康" : "异常"})`,
|
|
323
490
|
);
|
|
324
|
-
|
|
491
|
+
this._writeHealth(c, {
|
|
325
492
|
node: cmd.node,
|
|
326
493
|
healthy: h.healthy,
|
|
327
494
|
httpCode: h.httpCode,
|
|
@@ -329,7 +496,6 @@ export class ClashDaemon {
|
|
|
329
496
|
sizeDownload: h.sizeDownload,
|
|
330
497
|
title: h.title,
|
|
331
498
|
lastSwitch: `manual: ${before} → ${cmd.node}`,
|
|
332
|
-
meta: this._buildMeta(),
|
|
333
499
|
});
|
|
334
500
|
} catch (e) {
|
|
335
501
|
this._log(`🔄 看门狗指令异常: ${e.message}`);
|
|
@@ -340,17 +506,19 @@ export class ClashDaemon {
|
|
|
340
506
|
* 看门狗指令: 主动故障切换(不依赖巡检发现,强制从候选集切下一个可用节点)
|
|
341
507
|
* 用于业务进程连续报错(CDN限流/网络异常/封 IP 等)时换出口节点
|
|
342
508
|
*/
|
|
343
|
-
async _handleFailover(cmd) {
|
|
509
|
+
async _handleFailover(cmd, c) {
|
|
344
510
|
try {
|
|
345
511
|
const reason = (cmd && cmd.reason) || "看门狗主动故障切换";
|
|
346
512
|
const node = await api
|
|
347
513
|
.getCurrentNode(checker.CONFIG.groupName)
|
|
348
514
|
.catch(() => "?");
|
|
349
|
-
this._log(
|
|
515
|
+
this._log(
|
|
516
|
+
`🔄 看门狗指令: [${c.proxyPort}] 触发故障切换 [${reason}] (当前: ${node})`,
|
|
517
|
+
);
|
|
350
518
|
const { nodes } = checker.loadHealthyList();
|
|
351
519
|
if (!nodes.length) {
|
|
352
520
|
this._log("🔄 候选集为空,触发 delay 初测");
|
|
353
|
-
await this.
|
|
521
|
+
await this._runDelayProbeController(c);
|
|
354
522
|
return;
|
|
355
523
|
}
|
|
356
524
|
// 逐个切换候选 + 内容验证,第一个满足即采用(跳过当前节点)
|
|
@@ -376,18 +544,17 @@ export class ClashDaemon {
|
|
|
376
544
|
this._log(
|
|
377
545
|
`✗ failover [${reason}] — 候选全部不可用,恢复原节点 ${node}`,
|
|
378
546
|
);
|
|
379
|
-
return
|
|
547
|
+
return this._writeHealth(c, {
|
|
380
548
|
node,
|
|
381
549
|
healthy: false,
|
|
382
550
|
error: `failover(${reason}): 候选全部不可用`,
|
|
383
551
|
lastSwitch: `failover恢复(${reason}): → ${node}`,
|
|
384
|
-
meta: this._buildMeta(),
|
|
385
552
|
});
|
|
386
553
|
}
|
|
387
554
|
this._log(`⚠ 节点切换 [${reason}]: ${node} → ${target}`);
|
|
388
555
|
// 切换后断开所有旧连接,浏览器立即重建到新节点(同巡检切换)
|
|
389
556
|
await api.closeAllConnections().catch(() => {});
|
|
390
|
-
|
|
557
|
+
this._writeHealth(c, {
|
|
391
558
|
node: target,
|
|
392
559
|
healthy: h2.healthy,
|
|
393
560
|
httpCode: h2.httpCode,
|
|
@@ -395,7 +562,6 @@ export class ClashDaemon {
|
|
|
395
562
|
sizeDownload: h2.sizeDownload,
|
|
396
563
|
title: h2.title,
|
|
397
564
|
lastSwitch: `${reason}: ${node} → ${target}`,
|
|
398
|
-
meta: this._buildMeta(),
|
|
399
565
|
});
|
|
400
566
|
} catch (e) {
|
|
401
567
|
this._log(`🔄 故障切换异常: ${e.message}`);
|
|
@@ -404,17 +570,20 @@ export class ClashDaemon {
|
|
|
404
570
|
|
|
405
571
|
// ── helpers ──
|
|
406
572
|
|
|
407
|
-
_buildMeta() {
|
|
573
|
+
_buildMeta(c) {
|
|
574
|
+
const base = c && c._meta ? c._meta : this._meta;
|
|
408
575
|
const list = checker.loadHealthyList();
|
|
409
|
-
Object.assign(
|
|
576
|
+
Object.assign(base, {
|
|
410
577
|
proxyUrl: checker.CONFIG.proxyUrl,
|
|
578
|
+
apiPort: checker.CONFIG.apiPort,
|
|
579
|
+
clientName: c ? c.clientName || null : null,
|
|
411
580
|
testUrl: checker.CONFIG.testUrl,
|
|
412
581
|
countryFilter: checker.CONFIG.nodeCountryFilter,
|
|
413
582
|
healthyNodesCount: list.nodes.length,
|
|
414
583
|
healthyNodesTotal: list.totalNodes,
|
|
415
584
|
healthyNodes: list.nodes.slice(0, 50),
|
|
416
585
|
});
|
|
417
|
-
return
|
|
586
|
+
return base;
|
|
418
587
|
}
|
|
419
588
|
|
|
420
589
|
_log(msg) {
|
|
@@ -466,7 +635,7 @@ function _buildCmdHandlers(daemon) {
|
|
|
466
635
|
// 幂等:国家配置变化时才立即刷新候选集,避免每次心跳重复初测
|
|
467
636
|
if (JSON.stringify(prev || []) !== JSON.stringify(next)) {
|
|
468
637
|
daemon._log(`📋 国家变化,立即刷新候选集...`);
|
|
469
|
-
daemon.
|
|
638
|
+
daemon._runDelayProbeAll().catch(() => {});
|
|
470
639
|
}
|
|
471
640
|
},
|
|
472
641
|
setTestUrl(cmd) {
|
|
@@ -478,7 +647,7 @@ function _buildCmdHandlers(daemon) {
|
|
|
478
647
|
},
|
|
479
648
|
runBenchmark() {
|
|
480
649
|
daemon._log("📋 收到手动刷新候选集命令");
|
|
481
|
-
daemon.
|
|
650
|
+
daemon._runDelayProbeAll();
|
|
482
651
|
},
|
|
483
652
|
setInterval(cmd) {
|
|
484
653
|
if (cmd.seconds > 0) {
|
|
@@ -486,8 +655,8 @@ function _buildCmdHandlers(daemon) {
|
|
|
486
655
|
daemon._log(`📋 巡检间隔: → ${cmd.seconds}s`);
|
|
487
656
|
}
|
|
488
657
|
},
|
|
489
|
-
switchNode: (cmd) => daemon._handleSwitchNode(cmd),
|
|
490
|
-
failover: (cmd) => daemon._handleFailover(cmd),
|
|
658
|
+
switchNode: (cmd, c) => daemon._handleSwitchNode(cmd, c),
|
|
659
|
+
failover: (cmd, c) => daemon._handleFailover(cmd, c),
|
|
491
660
|
};
|
|
492
661
|
}
|
|
493
662
|
|
|
@@ -496,10 +665,18 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
|
496
665
|
const args = process.argv.slice(2);
|
|
497
666
|
const getArg = (flag) =>
|
|
498
667
|
args.includes(flag) ? args[args.indexOf(flag) + 1] : undefined;
|
|
668
|
+
const configPaths = [];
|
|
669
|
+
for (let i = 0; i < args.length; i++) {
|
|
670
|
+
if (args[i] === "--config-path" && args[i + 1]) configPaths.push(args[++i]);
|
|
671
|
+
}
|
|
499
672
|
new ClashDaemon({
|
|
500
673
|
intervalSec: parseInt(getArg("--interval")) || undefined,
|
|
501
674
|
benchmarkMin: parseInt(getArg("--benchmark")) || undefined,
|
|
502
675
|
country: getArg("--country") || undefined,
|
|
503
676
|
dryRun: args.includes("--dry-run"),
|
|
677
|
+
apiPort: parseInt(getArg("--api-port")) || undefined,
|
|
678
|
+
secret: getArg("--secret") || undefined,
|
|
679
|
+
selection: getArg("--selection") || undefined,
|
|
680
|
+
configPaths: configPaths.length ? configPaths : undefined,
|
|
504
681
|
}).start();
|
|
505
682
|
}
|