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.
@@ -1,8 +1,9 @@
1
1
  /**
2
- * ClashDaemon — 代理节点健康守护
2
+ * ClashDaemon — 代理节点健康守护(多控制端)
3
3
  *
4
- * 职责: 定时巡检 → 异常切换 → delay 初测生成候选集 → IPC 上报
5
- * 看门狗通过 /tmp/clash-cmd.json 下发命令,每 10s 轮询
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; // 等待 Clash 就绪的重试定时器
42
+ this._waitTimer = null; // 等待控制端就绪的重试定时器
33
43
  this._startedOnce = false; // 首次启动标记(banner/信号只打印注册一次)
34
- this._benchmarkRunning = false;
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
- // 等待 Clash 就绪(容错启动顺序:先启 watchdog 后开 Clash 控制端口也可正常工作)
71
- // 以 API 控制端口为准:进程在但控制端口未开启时同样等待,避免误判后所有 API 操作失败
72
- const alive = checker.checkClashApiAlive();
73
- const reason = alive
74
- ? `API 端口 ${checker.CONFIG.apiPort} 可达`
75
- : `Clash 控制端口 ${checker.CONFIG.apiPort} 未开启`;
76
- if (!alive) {
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
- ` 等待 API 端口 ${checker.CONFIG.apiPort} 可用,10s 后重试(Clash 就绪后自动接入)...`,
177
+ `⚠ 未发现可用控制端(已等待 ${waitedSec}s),10s 后重试(客户端就绪后自动接入)...`,
85
178
  );
86
179
  }
87
180
  ipc.writeHealth({
88
181
  node: "?",
89
182
  healthy: false,
90
- error: `等待 Clash 就绪: ${reason}`,
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
- const list = checker.loadHealthyList();
108
- const ageMin = list.updated
109
- ? Math.round((Date.now() - new Date(list.updated).getTime()) / 60000)
110
- : Infinity;
111
- const isStale = !list.nodes.length || ageMin >= this.benchmarkMs / 60000;
112
- this._log(
113
- `${list.nodes.length ? `已加载 ${list.nodes.length} 个候选节点${isStale ? ` (已过期 ${ageMin}min,立即刷新)` : ""}` : "候选集为空,将立即执行 delay 初测"}`,
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._healthCheck();
117
- this._timers.push(setInterval(() => this._healthCheck(), this.intervalMs));
213
+ await this._healthCheckAll();
214
+ this._timers.push(
215
+ setInterval(() => this._healthCheckAll(), this.intervalMs),
216
+ );
118
217
 
119
- const initial = isStale ? 5000 : this.benchmarkMs;
218
+ const initial = primaryStale ? 5000 : this.benchmarkMs;
120
219
  setTimeout(() => {
121
- this._runDelayProbe();
220
+ this._runDelayProbeAll();
122
221
  this._timers.push(
123
- setInterval(() => this._runDelayProbe(), this.benchmarkMs),
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
- async _healthCheck() {
146
- if (!this._running || this._benchmarkRunning) return;
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 ipc.writeHealth({
258
+ return this._writeHealth(c, {
150
259
  node: "?",
151
260
  healthy: false,
152
- error: "Clash 未运行",
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
- ipc.writeHealth({
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
- this._log(`✗ 巡检异常 [${reason}] — 候选集为空,触发 delay 初测`);
183
- await this._runDelayProbe();
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(`✗ 巡检异常 [${reason}],开始从候选集切换 (当前 ${node})`);
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(`✗ 恢复原节点 ${node}(原因: ${reason} — 候选全部不可用)`);
217
- ipc.writeHealth({
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
- ipc.writeHealth({
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(`巡检异常: ${e.message}`);
245
- ipc.writeHealth({
246
- node: "?",
247
- healthy: false,
248
- error: e.message,
249
- meta: this._buildMeta(),
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
- async _runDelayProbe() {
257
- if (this._benchmarkRunning) return;
258
- this._benchmarkRunning = true;
259
- this._log("=== 节点 delay 初测开始(零切换) ===");
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
- this._meta.lastBenchmark = new Date().toISOString();
268
- this._meta.healthyNodesTotal = r.total;
269
- this._meta.healthyNodesCount = r.nodes.length;
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(`=== delay 初测完成 (${elapsed}s): 0 个可达节点 ===`);
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
- this._benchmarkRunning = false;
439
+ c._probing = false;
281
440
  }
282
441
  }
283
442
 
284
- // ── 命令轮询 ──
443
+ // ── 命令轮询(多通道:默认通道 + 每端口通道) ──
285
444
 
286
445
  _pollCommands() {
287
446
  if (!this._running) return;
288
- try {
289
- const cmds = ipc.readCommand();
290
- if (!cmds) return;
291
- for (const cmd of cmds) {
292
- const handler = this._cmdHandlers[cmd.action];
293
- if (handler) {
294
- handler(cmd);
295
- } else {
296
- this._log(`📋 未知命令: ${JSON.stringify(cmd)}`);
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
- ipc.clearCommand();
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(`🔄 看门狗指令: 切换节点 → ${cmd.node} (当前: ${before})`);
474
+ this._log(
475
+ `🔄 看门狗指令: [${c.proxyPort}] 切换节点 → ${cmd.node} (当前: ${before})`,
476
+ );
309
477
  if (!(await checker.switchToNode(cmd.node))) {
310
478
  this._log("🔄 看门狗指令: 切换失败");
311
- return ipc.writeHealth({
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
- ipc.writeHealth({
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(`🔄 看门狗指令: 触发故障切换 [${reason}] (当前: ${node})`);
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._runDelayProbe();
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 ipc.writeHealth({
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
- ipc.writeHealth({
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(this._meta, {
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 this._meta;
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._runDelayProbe().catch(() => {});
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._runDelayProbe();
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
  }