tt-help-cli-ycl 1.4.161 → 1.4.163
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/src/clash/daemon.js +190 -21
- package/src/clash/runtime.js +18 -8
- package/src/watch/public/app.js +328 -28
- package/src/watch/public/index.html +1 -1
- package/src/watch/watchdog-server.js +13 -5
- package/src/watchdog/agent.js +103 -33
- package/src/watchdog/env-init.js +13 -0
- package/src/watchdog/process-manager.js +14 -3
package/package.json
CHANGED
package/src/clash/daemon.js
CHANGED
|
@@ -175,20 +175,31 @@ export class ClashDaemon {
|
|
|
175
175
|
|
|
176
176
|
/**
|
|
177
177
|
* 逐个尝试候选节点并内容验证(巡检/failover/白名单守卫共用切换循环)
|
|
178
|
+
* @param {object} [opts]
|
|
179
|
+
* @param {Set<string>} [opts.occupied] 同订阅其它实例已占用的节点(互斥避让);
|
|
180
|
+
* 有空闲候选时跳过被占用节点;候选全被占/仅一个候选时放行(“只允许一个节点”例外)
|
|
178
181
|
* @returns {{target: string|null, h2: object|null}} 第一个满足(健康或壳页)的候选;全部失败返回 {target:null}
|
|
179
182
|
*/
|
|
180
|
-
async _tryRotateCandidates(c, ordered, currentNode, reason = "") {
|
|
183
|
+
async _tryRotateCandidates(c, ordered, currentNode, reason = "", opts = {}) {
|
|
181
184
|
const tag = reason ? ` [原因: ${reason}]` : "";
|
|
185
|
+
// 同订阅互斥: 计算空闲候选(排除被占用与当前节点);全被占时不排除(放行)
|
|
186
|
+
const occ = opts.occupied || null;
|
|
187
|
+
const free = occ ? ordered.filter((x) => !occ.has(x) && x !== currentNode) : null;
|
|
182
188
|
let target = null;
|
|
183
189
|
let h2 = null;
|
|
184
190
|
for (const cand of ordered) {
|
|
185
191
|
if (cand === currentNode) continue;
|
|
192
|
+
if (free && free.length > 0 && !free.includes(cand)) {
|
|
193
|
+
this._log(` ⏭ [${c.proxyPort}] ${cand} 被同订阅其它实例占用,跳过`);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
186
196
|
this._log(`↺ [${c.proxyPort}] 尝试候选: ${cand}${tag}`);
|
|
187
197
|
if (!(await checker.switchToNode(cand))) {
|
|
188
198
|
this._log(` ↺ ${cand} 切换失败,下一个`);
|
|
189
199
|
continue;
|
|
190
200
|
}
|
|
191
201
|
this._markSwitch(c, cand);
|
|
202
|
+
c._currentNode = cand; // 同订阅互斥: 维护当前节点内存快照
|
|
192
203
|
h2 = checker.checkNodeHealth();
|
|
193
204
|
if (h2.healthy || this._isShellPage(h2)) {
|
|
194
205
|
target = cand;
|
|
@@ -199,6 +210,120 @@ export class ClashDaemon {
|
|
|
199
210
|
return { target, h2 };
|
|
200
211
|
}
|
|
201
212
|
|
|
213
|
+
// ── 同订阅节点互斥(同 URL 多实例不允许同时使用同一节点,除非无空闲可用节点) ──
|
|
214
|
+
|
|
215
|
+
/** 内置实例序号(proxyPort 推导;0 = 非内置段如外部 Verge,不参与互斥) */
|
|
216
|
+
_instanceIndexOf(c) {
|
|
217
|
+
const idx = (c?.proxyPort || 0) - DISCOVER_DEFAULTS.proxyPort + 1;
|
|
218
|
+
return idx >= 1 && idx <= RUNTIME_MAX_INSTANCES ? idx : 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 同 URL 订阅的同组其它实例控制器(before=true 仅序号比自己小的) */
|
|
222
|
+
_mutexPeers(c, beforeOnly = false) {
|
|
223
|
+
const idx = this._instanceIndexOf(c);
|
|
224
|
+
if (!idx) return [];
|
|
225
|
+
let stored;
|
|
226
|
+
try {
|
|
227
|
+
stored = runtime.readStoredSubscriptions();
|
|
228
|
+
} catch {
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
const myUrl = stored[idx - 1]?.url;
|
|
232
|
+
if (!myUrl) return [];
|
|
233
|
+
const out = [];
|
|
234
|
+
for (let i = 1; i <= stored.length; i++) {
|
|
235
|
+
if (i === idx || stored[i - 1]?.url !== myUrl) continue;
|
|
236
|
+
if (beforeOnly && i > idx) continue;
|
|
237
|
+
const peer = this._controllers.find((x) => this._instanceIndexOf(x) === i);
|
|
238
|
+
if (peer && peer !== c) out.push(peer);
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 当前被同组其它实例占用的节点集合(无同组返回 null = 不启用避让) */
|
|
244
|
+
_occupiedNodes(c) {
|
|
245
|
+
const peers = this._mutexPeers(c);
|
|
246
|
+
if (!peers.length) return null;
|
|
247
|
+
const occ = new Set();
|
|
248
|
+
for (const p of peers) if (p._currentNode) occ.add(p._currentNode);
|
|
249
|
+
return occ.size ? occ : null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 巡检常驻互斥对齐(与白名单守卫同口径,不受 failover 冷却约束):
|
|
254
|
+
* 与序号更小的同组实例撞节点 → 自己让位(序号小的保留);10 分钟防抖。
|
|
255
|
+
* 让位无空闲可用节点(候选全被占/全不可用)→ 保留现状,下轮再试。
|
|
256
|
+
* @returns {Promise<boolean>} true=已让位切换(调用方跳过自身 health 写入)
|
|
257
|
+
*/
|
|
258
|
+
async _enforceMutex(c, node) {
|
|
259
|
+
if (!node || node === "?") return false;
|
|
260
|
+
const conflict = this._mutexPeers(c, true).find((p) => p._currentNode === node);
|
|
261
|
+
if (!conflict) return false;
|
|
262
|
+
if (Date.now() - (c._lastMutexAlignAt || 0) < 600_000) return false;
|
|
263
|
+
c._lastMutexAlignAt = Date.now();
|
|
264
|
+
this._log(
|
|
265
|
+
`🔒 [${c.proxyPort}] 同订阅互斥: 节点 ${node} 已被实例@${conflict.proxyPort} 占用,让位切换`,
|
|
266
|
+
);
|
|
267
|
+
const { nodes } = checker.loadHealthyList();
|
|
268
|
+
if (!nodes.length) return false;
|
|
269
|
+
const ordered = this._sortCandidates(nodes, c);
|
|
270
|
+
const { target, h2 } = await this._tryRotateCandidates(
|
|
271
|
+
c, ordered, node, "同订阅节点互斥", { occupied: this._occupiedNodes(c) },
|
|
272
|
+
);
|
|
273
|
+
if (!target) {
|
|
274
|
+
await checker.switchToNode(node).catch(() => {});
|
|
275
|
+
this._log(`🔒 [${c.proxyPort}] 互斥让位失败(无空闲可用节点),暂保留 ${node}`);
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
await this._disconnect(c);
|
|
279
|
+
this._markSwitchTime(c);
|
|
280
|
+
this._writeHealth(c, {
|
|
281
|
+
node: target,
|
|
282
|
+
healthy: h2.healthy,
|
|
283
|
+
httpCode: h2.httpCode,
|
|
284
|
+
timeTotal: h2.timeTotal,
|
|
285
|
+
sizeDownload: h2.sizeDownload,
|
|
286
|
+
title: h2.title,
|
|
287
|
+
lastSwitch: `同订阅互斥: ${node} → ${target}`,
|
|
288
|
+
});
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 手动切换后让位: 同组其它实例若占用了新节点 → 挪它(用户显式意图优先,不挪切换者)
|
|
294
|
+
*/
|
|
295
|
+
async _mutexYieldTo(c, node) {
|
|
296
|
+
for (const p of this._mutexPeers(c)) {
|
|
297
|
+
if (p._currentNode !== node || p._probing) continue;
|
|
298
|
+
this._useController(p);
|
|
299
|
+
this._log(
|
|
300
|
+
`🔒 [${p.proxyPort}] 同订阅互斥: 节点 ${node} 被手动切换给 [${c.proxyPort}],让位`,
|
|
301
|
+
);
|
|
302
|
+
const { nodes } = checker.loadHealthyList();
|
|
303
|
+
if (!nodes.length) continue;
|
|
304
|
+
const ordered = this._sortCandidates(nodes, p);
|
|
305
|
+
const { target, h2 } = await this._tryRotateCandidates(
|
|
306
|
+
p, ordered, node, "同订阅互斥(手动让位)", { occupied: this._occupiedNodes(p) },
|
|
307
|
+
);
|
|
308
|
+
if (!target) {
|
|
309
|
+
await checker.switchToNode(node).catch(() => {});
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
await this._disconnect(p);
|
|
313
|
+
this._markSwitchTime(p);
|
|
314
|
+
this._writeHealth(p, {
|
|
315
|
+
node: target,
|
|
316
|
+
healthy: h2.healthy,
|
|
317
|
+
httpCode: h2.httpCode,
|
|
318
|
+
timeTotal: h2.timeTotal,
|
|
319
|
+
sizeDownload: h2.sizeDownload,
|
|
320
|
+
title: h2.title,
|
|
321
|
+
lastSwitch: `同订阅互斥(手动): ${node} → ${target}`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
this._useController(c); // 恢复切换者上下文
|
|
325
|
+
}
|
|
326
|
+
|
|
202
327
|
/** 将全局 api/checker 上下文指向指定控制器 */
|
|
203
328
|
_useController(c) {
|
|
204
329
|
api.configure({ port: c.port, secret: c.secret });
|
|
@@ -443,6 +568,7 @@ export class ClashDaemon {
|
|
|
443
568
|
if (r.status === "started") {
|
|
444
569
|
started++;
|
|
445
570
|
this._log(`✓ 实例${idx} (${spec.apiPort}/${spec.mixedPort}) 自愈拉起成功,下轮发现将接管`);
|
|
571
|
+
await this._rediscover("实例自愈拉起");
|
|
446
572
|
} else if (r.status === "failed" || r.status === "no-vendor") {
|
|
447
573
|
failed++;
|
|
448
574
|
this._log(`⚠ 实例${idx} 自愈拉起失败: ${r.error || r.status}`);
|
|
@@ -485,8 +611,7 @@ export class ClashDaemon {
|
|
|
485
611
|
const node = await api
|
|
486
612
|
.getCurrentNode(checker.CONFIG.groupName)
|
|
487
613
|
.catch(() => "?");
|
|
488
|
-
|
|
489
|
-
// 订阅信息 + 流量采样(随巡检周期,差分算速率)
|
|
614
|
+
if (node && node !== "?") c._currentNode = node; // 同订阅互斥: 维护当前节点内存快照
|
|
490
615
|
c._subsSnapshot = await api.getSubscriptionSummaries().catch(() => null);
|
|
491
616
|
const conn = await api.getConnections().catch(() => null);
|
|
492
617
|
if (conn) {
|
|
@@ -554,25 +679,26 @@ export class ClashDaemon {
|
|
|
554
679
|
}
|
|
555
680
|
|
|
556
681
|
if (h.healthy) {
|
|
557
|
-
c._apiFailCount = 0;
|
|
558
682
|
if (Math.random() < 0.17)
|
|
559
683
|
this._log(
|
|
560
684
|
`✓ [${c.proxyPort}] ${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB`,
|
|
561
685
|
);
|
|
562
|
-
//
|
|
563
|
-
|
|
686
|
+
// 白名单守卫 + 同订阅互斥:节点健康但不在咱们节点列表内 → 强制切回;
|
|
687
|
+
// 与序号更小的同组实例撞节点 → 让位
|
|
688
|
+
if (await this._enforceWhitelist(c, node)) return;
|
|
689
|
+
if (await this._enforceMutex(c, node)) return;
|
|
564
690
|
return;
|
|
565
691
|
}
|
|
566
692
|
|
|
567
693
|
// 空壳页(TikTok SSR 降级响应):链路可用、浏览器会自行渲染,
|
|
568
694
|
// 切换无收益且会打断业务连接,不触发切换
|
|
569
695
|
if (this._isShellPage(h)) {
|
|
570
|
-
c._apiFailCount = 0;
|
|
571
696
|
this._log(
|
|
572
697
|
`~ [${c.proxyPort}] 空壳页(降级响应,链路正常),不切换 (${(h.sizeDownload / 1024).toFixed(0)}KB ${h.timeTotal.toFixed(1)}s)`,
|
|
573
698
|
);
|
|
574
|
-
//
|
|
699
|
+
// 白名单守卫与互斥:壳页=链路正常,但列表外节点仍不允许;撞节点让位同口径
|
|
575
700
|
if (await this._enforceWhitelist(c, node)) return;
|
|
701
|
+
if (await this._enforceMutex(c, node)) return;
|
|
576
702
|
return this._writeHealth(c, {
|
|
577
703
|
node,
|
|
578
704
|
healthy: true,
|
|
@@ -593,8 +719,9 @@ export class ClashDaemon {
|
|
|
593
719
|
this._log(
|
|
594
720
|
`~ [${c.proxyPort}] 复测恢复(首次异常: ${firstReason}),不切换`,
|
|
595
721
|
);
|
|
596
|
-
//
|
|
722
|
+
// 白名单守卫与互斥:复测恢复同样守口径(列表外不允许;撞节点让位)
|
|
597
723
|
if (await this._enforceWhitelist(c, node)) return;
|
|
724
|
+
if (await this._enforceMutex(c, node)) return;
|
|
598
725
|
return this._writeHealth(c, {
|
|
599
726
|
node,
|
|
600
727
|
healthy: true,
|
|
@@ -637,13 +764,15 @@ export class ClashDaemon {
|
|
|
637
764
|
`✗ [${c.proxyPort}] 巡检异常 [${reason}],开始从候选集切换 (当前 ${node})`,
|
|
638
765
|
);
|
|
639
766
|
// 逐个切换候选 + 内容验证,第一个满足即采用
|
|
640
|
-
// 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"
|
|
767
|
+
// 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"的节点;
|
|
768
|
+
// 同订阅互斥: 避开同组其它实例占用的节点
|
|
641
769
|
const ordered = this._sortCandidates(nodes, c);
|
|
642
770
|
const { target, h2 } = await this._tryRotateCandidates(
|
|
643
771
|
c,
|
|
644
772
|
ordered,
|
|
645
773
|
node,
|
|
646
774
|
reason,
|
|
775
|
+
{ occupied: this._occupiedNodes(c) },
|
|
647
776
|
);
|
|
648
777
|
|
|
649
778
|
if (!target) {
|
|
@@ -689,33 +818,62 @@ export class ClashDaemon {
|
|
|
689
818
|
}
|
|
690
819
|
|
|
691
820
|
/**
|
|
692
|
-
* API
|
|
693
|
-
*
|
|
821
|
+
* 重新发现控制端(订阅应用/自愈拉起新实例后调用,或 API 连续失败时)
|
|
822
|
+
* 背景: controllers 是 start() 时的快照,正常运行期新拉起的内置实例(9098+)
|
|
823
|
+
* 不会自动进入巡检列表 —— 必须主动重发现才能接管(否则新实例永远不被巡检/上报)
|
|
824
|
+
* 防抖: 30s 内多次触发只执行一次(多实例同时拉起时避免重复探测)
|
|
694
825
|
*/
|
|
695
|
-
async
|
|
696
|
-
|
|
697
|
-
if (this.
|
|
698
|
-
this.
|
|
826
|
+
async _rediscover(reason) {
|
|
827
|
+
const now = Date.now();
|
|
828
|
+
if (now - (this._lastRediscoverAt || 0) < 30_000) return;
|
|
829
|
+
this._lastRediscoverAt = now;
|
|
699
830
|
invalidateDiscoverCache();
|
|
700
|
-
this._log(`API 连续失败,重新发现控制端... (${e?.message || e})`);
|
|
701
831
|
try {
|
|
702
832
|
const discs = await discoverAll(this._discoverOpts);
|
|
833
|
+
// 按端口迁移旧控制器运行状态(切换历史/冷却锚点/订阅快照等),
|
|
834
|
+
// 避免重发现后 failover 冷却锚点丢失导致反复切换
|
|
835
|
+
const prev = new Map(this._controllers.map((c) => [c.port, c]));
|
|
703
836
|
const seen = new Set();
|
|
704
837
|
this._controllers = discs.filter((d) => {
|
|
705
838
|
if (seen.has(d.port)) return false;
|
|
706
839
|
seen.add(d.port);
|
|
840
|
+
const old = prev.get(d.port);
|
|
841
|
+
if (old) {
|
|
842
|
+
d._meta = old._meta;
|
|
843
|
+
d._switchHistory = old._switchHistory;
|
|
844
|
+
d._lastSwitchAt = old._lastSwitchAt;
|
|
845
|
+
d._restartSeq = old._restartSeq;
|
|
846
|
+
d._trafficLast = old._trafficLast;
|
|
847
|
+
d._traffic = old._traffic;
|
|
848
|
+
d._subsSnapshot = old._subsSnapshot;
|
|
849
|
+
d._applyResult = old._applyResult;
|
|
850
|
+
d._lastProbeAt = old._lastProbeAt;
|
|
851
|
+
d._currentNode = old._currentNode;
|
|
852
|
+
d._lastMutexAlignAt = old._lastMutexAlignAt;
|
|
853
|
+
}
|
|
707
854
|
return true;
|
|
708
855
|
});
|
|
709
856
|
this._primary = this._controllers[0] || null;
|
|
710
|
-
this._lastDiscoverKey = "";
|
|
857
|
+
this._lastDiscoverKey = ""; // 强制下轮日志重新打印控制端列表
|
|
711
858
|
this._log(
|
|
712
|
-
`✓
|
|
859
|
+
`✓ ${reason}: 重新发现 ${this._controllers.length} 个控制端: ${this._controllers.map((c) => `${c.clientName}@${c.port}`).join(", ") || "无"}`,
|
|
713
860
|
);
|
|
714
861
|
} catch (err) {
|
|
715
|
-
this._log(
|
|
862
|
+
this._log(`${reason}: 重新发现控制端失败: ${err.message}`);
|
|
716
863
|
}
|
|
717
864
|
}
|
|
718
865
|
|
|
866
|
+
/**
|
|
867
|
+
* API 连续失败时的控制端重发现
|
|
868
|
+
* 连续 2 次巡检异常触发一次完整重发现,防止端口漂移后失联
|
|
869
|
+
*/
|
|
870
|
+
async _maybeRediscover(e) {
|
|
871
|
+
this._apiFailCount = (this._apiFailCount || 0) + 1;
|
|
872
|
+
if (this._apiFailCount < 2) return;
|
|
873
|
+
this._apiFailCount = 0;
|
|
874
|
+
await this._rediscover(`API 连续失败 (${e?.message || e})`);
|
|
875
|
+
}
|
|
876
|
+
|
|
719
877
|
/**
|
|
720
878
|
* 白名单守卫:当前 Clash Verge 正在使用的节点必须 ∈ 候选集(咱们节点列表)
|
|
721
879
|
*
|
|
@@ -748,6 +906,7 @@ export class ClashDaemon {
|
|
|
748
906
|
ordered,
|
|
749
907
|
node,
|
|
750
908
|
"白名单守卫",
|
|
909
|
+
{ occupied: this._occupiedNodes(c) },
|
|
751
910
|
);
|
|
752
911
|
if (!target) {
|
|
753
912
|
// 与 failover/巡检切换口径一致:候选全部不可用时恢复原节点
|
|
@@ -898,9 +1057,12 @@ export class ClashDaemon {
|
|
|
898
1057
|
}
|
|
899
1058
|
this._markSwitch(c, cmd.node);
|
|
900
1059
|
this._markSwitchTime(c);
|
|
1060
|
+
c._currentNode = cmd.node; // 同订阅互斥: 维护当前节点内存快照
|
|
901
1061
|
const h = checker.checkNodeHealth();
|
|
902
1062
|
// 切换后优雅断连:回收空闲连接,活跃放行(同巡检切换)
|
|
903
1063
|
await this._disconnect(c);
|
|
1064
|
+
// 同订阅互斥: 让同组占用新节点的其它实例让位(用户显式意图优先,不挪切换者)
|
|
1065
|
+
await this._mutexYieldTo(c, cmd.node);
|
|
904
1066
|
this._log(
|
|
905
1067
|
`🔄 看门狗指令: [${c.proxyPort}] 切换完成 (${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB, ${h.healthy ? "健康" : "异常"})`,
|
|
906
1068
|
);
|
|
@@ -986,13 +1148,15 @@ export class ClashDaemon {
|
|
|
986
1148
|
return;
|
|
987
1149
|
}
|
|
988
1150
|
// 逐个切换候选 + 内容验证,第一个满足即采用(跳过当前节点)
|
|
989
|
-
// 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"
|
|
1151
|
+
// 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"的节点;
|
|
1152
|
+
// 同订阅互斥: 避开同组其它实例占用的节点
|
|
990
1153
|
const ordered = this._sortCandidates(nodes, c);
|
|
991
1154
|
const { target, h2 } = await this._tryRotateCandidates(
|
|
992
1155
|
c,
|
|
993
1156
|
ordered,
|
|
994
1157
|
node,
|
|
995
1158
|
reason,
|
|
1159
|
+
{ occupied: this._occupiedNodes(c) },
|
|
996
1160
|
);
|
|
997
1161
|
// 本次 failover 已尽力处置(成功切换或恢复原节点):更新锚点,给新节点干净观察期
|
|
998
1162
|
this._markSwitchTime(c);
|
|
@@ -1048,6 +1212,8 @@ export class ClashDaemon {
|
|
|
1048
1212
|
c._applyResult = { ok: true, changed: true, cleared: true, at: Date.now() };
|
|
1049
1213
|
this._log(`📋 订阅已清空: 回收内置 mihomo ${killed} 个进程,不再接管端口`);
|
|
1050
1214
|
this._writeHealth(c, { node: "?", healthy: null });
|
|
1215
|
+
// 实例已回收 → 重发现让死实例退出巡检列表(否则每轮巡检持续报"未运行")
|
|
1216
|
+
await this._rediscover("订阅清空");
|
|
1051
1217
|
return;
|
|
1052
1218
|
}
|
|
1053
1219
|
this._log(`📋 收到订阅配置: ${subs.length} 条,按条启动独立实例(9097+每条递增)...`);
|
|
@@ -1077,6 +1243,9 @@ export class ClashDaemon {
|
|
|
1077
1243
|
} else {
|
|
1078
1244
|
this._log(`⚠ ${failed.length}/${subs.length} 个实例应用失败(未持久化,待下轮心跳重试): ${failed.map((f) => `实例${f.index}`).join(",")}`);
|
|
1079
1245
|
}
|
|
1246
|
+
// 实例集可能已变化(成功拉起新实例/回收多余实例,含部分失败场景)→ 重发现同步巡检列表
|
|
1247
|
+
// (controllers 是启动快照;不重发现则新实例永远不被巡检/上报,214 实采踩坑)
|
|
1248
|
+
await this._rediscover("订阅应用");
|
|
1080
1249
|
c._subsSnapshot = await api.getSubscriptionSummaries().catch(() => null);
|
|
1081
1250
|
c._applyResult = {
|
|
1082
1251
|
ok: allOk,
|
package/src/clash/runtime.js
CHANGED
|
@@ -78,12 +78,13 @@ const UI_MANIFEST = { file: "metacubexd-dist.tgz", sha256: "93eb334af7e37d213317
|
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* 内核/面板下载源(顺序 failover):
|
|
81
|
-
* 1. ~/.tt-help.json → clash.vendorBase
|
|
82
|
-
* 2.
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* 4.
|
|
86
|
-
* 5.
|
|
81
|
+
* 1. ~/.tt-help.json → clash.vendorBase / 环境变量 TT_VENDOR_BASE(显式覆盖,逃生舱)
|
|
82
|
+
* 2. HTTPS 域名镜像(最优默认源:域名+TLS+CDN,比公网 IP 直连更稳)
|
|
83
|
+
* 3. 配置的服务端(~/.tt-help.json → server)→ {server}/scripts/vendor
|
|
84
|
+
* (内网机器的 server 为内网地址,作为 CDN 不可达时的就近回退)
|
|
85
|
+
* 4. 自建服务器公网(201 watch 静态路由 /scripts/vendor,免鉴权)
|
|
86
|
+
* 5. 自建服务器内网(农场机器直连)
|
|
87
|
+
* 6. GitHub Releases(仅海外/兜底;农场不可达属预期)
|
|
87
88
|
*/
|
|
88
89
|
export function vendorBases() {
|
|
89
90
|
const urls = [];
|
|
@@ -95,6 +96,14 @@ export function vendorBases() {
|
|
|
95
96
|
if (existsSync(cfgPath)) {
|
|
96
97
|
const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
|
|
97
98
|
push(cfg?.clash?.vendorBase);
|
|
99
|
+
}
|
|
100
|
+
} catch {}
|
|
101
|
+
push(process.env.TT_VENDOR_BASE);
|
|
102
|
+
push("https://call.huochedejia.com/vendor");
|
|
103
|
+
try {
|
|
104
|
+
const cfgPath = join(homedir(), ".tt-help.json");
|
|
105
|
+
if (existsSync(cfgPath)) {
|
|
106
|
+
const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
|
|
98
107
|
const server = String(cfg?.server || "").replace(/\/+$/, "");
|
|
99
108
|
// 显式配置的服务端地址 → 推导 vendor 下载源(未配置/非 http 则跳过,避免污染 127.0.0.1 默认值)
|
|
100
109
|
if (/^https?:\/\//.test(server) && !/^https?:\/\/127\.0\.0\.1/.test(server)) {
|
|
@@ -102,7 +111,6 @@ export function vendorBases() {
|
|
|
102
111
|
}
|
|
103
112
|
}
|
|
104
113
|
} catch {}
|
|
105
|
-
push(process.env.TT_VENDOR_BASE);
|
|
106
114
|
push("http://117.71.53.99:17301/scripts/vendor");
|
|
107
115
|
push("http://172.18.154.201:3001/scripts/vendor");
|
|
108
116
|
push("https://github.com/MetaCubeX/mihomo/releases/download");
|
|
@@ -446,7 +454,9 @@ export function buildConfig({ subscriptions, apiPort, secret, mixedPort }) {
|
|
|
446
454
|
` ${s.name}:`,
|
|
447
455
|
" type: http",
|
|
448
456
|
` url: ${s.url}`,
|
|
449
|
-
|
|
457
|
+
// 订阅文件+userinfo(subscription-userinfo 响应头)刷新周期: 1 小时。
|
|
458
|
+
// 同一订阅可配多实例,各自快照时刻不同;24h 周期会导致页面剩余流量显示差异巨大(214 实测一天差 447GB)
|
|
459
|
+
" interval: 3600",
|
|
450
460
|
` path: ./providers/${s.name}.yaml`,
|
|
451
461
|
" proxy: DIRECT",
|
|
452
462
|
" health-check:",
|
package/src/watch/public/app.js
CHANGED
|
@@ -4779,6 +4779,10 @@ async function wdSaveProxySettings() {
|
|
|
4779
4779
|
return s.trim();
|
|
4780
4780
|
})
|
|
4781
4781
|
.filter(Boolean);
|
|
4782
|
+
if (lines.length > 8) {
|
|
4783
|
+
alert("最多 8 条订阅(每条一个独立 mihomo 实例,超出部分无法巡检);当前 " + lines.length + " 行");
|
|
4784
|
+
return;
|
|
4785
|
+
}
|
|
4782
4786
|
try {
|
|
4783
4787
|
var res = await fetch("/api/watchdog/set-proxy-settings", {
|
|
4784
4788
|
method: "POST",
|
|
@@ -5278,16 +5282,53 @@ async function wdSetErrorTrigger() {
|
|
|
5278
5282
|
|
|
5279
5283
|
// ── 代理节点区域(看门狗面板中每个客户端卡片,支持多控制端通道) ──
|
|
5280
5284
|
|
|
5285
|
+
/** 内置实例端口段(与 discover.js RUNTIME_MAX_INSTANCES=8 对齐):代理 7897+n / API 9097+n */
|
|
5286
|
+
var _SUBS_BASE_PROXY_PORT = 7897;
|
|
5287
|
+
var _SUBS_BASE_API_PORT = 9097;
|
|
5288
|
+
var _SUBS_MAX_INSTANCES = 8;
|
|
5289
|
+
|
|
5290
|
+
/** 服务端配置的订阅列表(proxySettings[userId].subscriptions) */
|
|
5291
|
+
function wdSubsConfigured(userId) {
|
|
5292
|
+
if (_wdData && _wdData.proxySettings && _wdData.proxySettings[userId]) {
|
|
5293
|
+
return _wdData.proxySettings[userId].subscriptions || [];
|
|
5294
|
+
}
|
|
5295
|
+
return [];
|
|
5296
|
+
}
|
|
5297
|
+
|
|
5298
|
+
/**
|
|
5299
|
+
* 代理区域入口:
|
|
5300
|
+
* 内置实例通道(7897+n)→ 订阅总览表(一行 = 一条订阅 = 一个实例)
|
|
5301
|
+
* 外部通道(Verge 等) → 独立通道行(节点 + 自身订阅摘要)
|
|
5302
|
+
* 无通道 → 配置过订阅显示总览(实例离线态);否则单控制端旧视图
|
|
5303
|
+
*/
|
|
5281
5304
|
function renderProxySection(c, userId) {
|
|
5282
|
-
var channels = c.clashHealthChannels || [];
|
|
5305
|
+
var channels = (c.clashHealthChannels || []).slice();
|
|
5306
|
+
channels.sort(function (a, b) {
|
|
5307
|
+
return (a.port || 0) - (b.port || 0);
|
|
5308
|
+
});
|
|
5283
5309
|
if (channels.length > 0) {
|
|
5284
|
-
var
|
|
5310
|
+
var builtin = [];
|
|
5311
|
+
var external = [];
|
|
5285
5312
|
for (var i = 0; i < channels.length; i++) {
|
|
5286
|
-
|
|
5313
|
+
var idx = (channels[i].port || 0) - _SUBS_BASE_PROXY_PORT;
|
|
5314
|
+
if (idx >= 0 && idx < _SUBS_MAX_INSTANCES) builtin.push(channels[i]);
|
|
5315
|
+
else external.push(channels[i]);
|
|
5316
|
+
}
|
|
5317
|
+
var html = renderSubsOverview(c, userId, builtin, channels);
|
|
5318
|
+
for (var j = 0; j < external.length; j++) {
|
|
5319
|
+
// 外部通道的订阅配置入口:仅当没有总览区时显示(配置入口全局收敛为一个)
|
|
5320
|
+
html += renderExternalChannel(external[j], userId, !html);
|
|
5287
5321
|
}
|
|
5288
5322
|
return html;
|
|
5289
5323
|
}
|
|
5290
|
-
|
|
5324
|
+
if (wdSubsConfigured(userId).length > 0) {
|
|
5325
|
+
return renderSubsOverview(c, userId, [], []);
|
|
5326
|
+
}
|
|
5327
|
+
return renderProxyLegacy(c, userId);
|
|
5328
|
+
}
|
|
5329
|
+
|
|
5330
|
+
/** 兼容旧版:仅主控制端(legacy clashHealth) */
|
|
5331
|
+
function renderProxyLegacy(c, userId) {
|
|
5291
5332
|
var h = c.clashHealth;
|
|
5292
5333
|
if (!h || !h.node) return "";
|
|
5293
5334
|
var nodes = (h.meta && h.meta.healthyNodes) || [];
|
|
@@ -5300,8 +5341,8 @@ function renderProxySection(c, userId) {
|
|
|
5300
5341
|
);
|
|
5301
5342
|
}
|
|
5302
5343
|
|
|
5303
|
-
/**
|
|
5304
|
-
function
|
|
5344
|
+
/** 外部代理通道(Verge 等非内置实例):节点行 + 节点列表 + 自身订阅摘要 */
|
|
5345
|
+
function renderExternalChannel(ch, userId, withConfig) {
|
|
5305
5346
|
if (!ch || !ch.node) return "";
|
|
5306
5347
|
var nodes = (ch.meta && ch.meta.healthyNodes) || [];
|
|
5307
5348
|
var delays = (ch.meta && ch.meta.healthyNodeDelays) || {};
|
|
@@ -5309,10 +5350,284 @@ function renderProxyChannel(ch, userId) {
|
|
|
5309
5350
|
return (
|
|
5310
5351
|
renderProxyLine(ch, nodes, userId, ch.port || 0, hasNodes) +
|
|
5311
5352
|
renderNodeList(nodes, ch.node, userId, ch.port || 0, delays) +
|
|
5312
|
-
renderSubscriptions(ch.meta && ch.meta.proxy, userId)
|
|
5353
|
+
renderSubscriptions(ch.meta && ch.meta.proxy, userId, withConfig !== false)
|
|
5354
|
+
);
|
|
5355
|
+
}
|
|
5356
|
+
|
|
5357
|
+
// ── 订阅总览区(每客户端一个) ──
|
|
5358
|
+
|
|
5359
|
+
/** 订阅应用结果提示(daemon 最近一次 setSubscription 执行反馈) */
|
|
5360
|
+
function renderApplyResult(ar) {
|
|
5361
|
+
if (!ar || !ar.at) return "";
|
|
5362
|
+
if (ar.ok === false) {
|
|
5363
|
+
return styled(
|
|
5364
|
+
"div",
|
|
5365
|
+
"font-size:11px;margin:2px 0 4px;padding:4px 6px;background:#3b1d1d;border:1px solid #7f1d1d;border-radius:4px;color:#f87171;line-height:1.4;width:100%;box-sizing:border-box",
|
|
5366
|
+
"❌ 订阅应用失败: " + (ar.error || "未知错误") +
|
|
5367
|
+
styled("span", "color:#94a3b8;margin-left:6px;font-size:10px", new Date(ar.at).toLocaleTimeString()),
|
|
5368
|
+
);
|
|
5369
|
+
}
|
|
5370
|
+
if (ar.changed) {
|
|
5371
|
+
return styled(
|
|
5372
|
+
"div",
|
|
5373
|
+
"font-size:11px;color:#4ade80;width:100%",
|
|
5374
|
+
"✓ 订阅应用成功 (" + (ar.count || "?") + " 条," + new Date(ar.at).toLocaleTimeString() + ")",
|
|
5375
|
+
);
|
|
5376
|
+
}
|
|
5377
|
+
return "";
|
|
5378
|
+
}
|
|
5379
|
+
|
|
5380
|
+
|
|
5381
|
+
/**
|
|
5382
|
+
* 订阅总览表:
|
|
5383
|
+
* 订阅名 | 实例 | 代理端口(业务 --proxy 用) | 当前节点(可展开切换) | 用量/到期/速率
|
|
5384
|
+
* 一行 = 一条订阅 = 一个内置 mihomo 实例;通道离线时显示占位行(配置可见,实例自愈中)
|
|
5385
|
+
*/
|
|
5386
|
+
function renderSubsOverview(c, userId, builtinChannels, allChannels) {
|
|
5387
|
+
var configured = wdSubsConfigured(userId);
|
|
5388
|
+
var byPort = {};
|
|
5389
|
+
for (var i = 0; i < builtinChannels.length; i++) {
|
|
5390
|
+
byPort[builtinChannels[i].port] = builtinChannels[i];
|
|
5391
|
+
}
|
|
5392
|
+
var count = configured.length;
|
|
5393
|
+
for (var p in byPort) {
|
|
5394
|
+
var n = parseInt(p, 10) - _SUBS_BASE_PROXY_PORT + 1;
|
|
5395
|
+
if (n > count) count = n;
|
|
5396
|
+
}
|
|
5397
|
+
if (!count) return "";
|
|
5398
|
+
|
|
5399
|
+
// 订阅应用结果(主控制端默认健康文件优先,回退任一通道快照)
|
|
5400
|
+
var ar = null;
|
|
5401
|
+
if (c.clashHealth && c.clashHealth.meta && c.clashHealth.meta.proxy) {
|
|
5402
|
+
ar = c.clashHealth.meta.proxy.applyResult;
|
|
5403
|
+
}
|
|
5404
|
+
if (!ar || !ar.at) {
|
|
5405
|
+
for (var k = 0; k < (allChannels || []).length; k++) {
|
|
5406
|
+
var pp = allChannels[k].meta && allChannels[k].meta.proxy;
|
|
5407
|
+
if (pp && pp.applyResult && pp.applyResult.at) {
|
|
5408
|
+
ar = pp.applyResult;
|
|
5409
|
+
break;
|
|
5410
|
+
}
|
|
5411
|
+
}
|
|
5412
|
+
}
|
|
5413
|
+
|
|
5414
|
+
// 标题行:入口收敛为全局唯一一个配置按钮
|
|
5415
|
+
var head = styled(
|
|
5416
|
+
"div",
|
|
5417
|
+
"padding:6px 8px;background:#111;display:flex;align-items:center;gap:8px;font-size:12px;flex-wrap:wrap",
|
|
5418
|
+
styled("span", "font-size:14px", "🛜") +
|
|
5419
|
+
styled("span", "color:#e0e0e0;font-weight:bold", "代理订阅") +
|
|
5420
|
+
styled("span", "color:#94a3b8", count + " 条") +
|
|
5421
|
+
tag(
|
|
5422
|
+
"button",
|
|
5423
|
+
{
|
|
5424
|
+
onclick: "wdShowProxyModal('" + esc(userId) + "')",
|
|
5425
|
+
style:
|
|
5426
|
+
"padding:2px 8px;font-size:11px;background:#333;color:#fbbf24;" +
|
|
5427
|
+
"border:1px solid #444;border-radius:3px;cursor:pointer",
|
|
5428
|
+
title: "配置该客户端的代理订阅(每行一个地址,保存后自动热应用,无需重启)",
|
|
5429
|
+
},
|
|
5430
|
+
"配置 ⚙️",
|
|
5431
|
+
) +
|
|
5432
|
+
styled(
|
|
5433
|
+
"span",
|
|
5434
|
+
"color:#555;font-size:10px;margin-left:auto",
|
|
5435
|
+
"一条订阅 = 一个独立 mihomo 实例",
|
|
5436
|
+
) +
|
|
5437
|
+
renderApplyResult(ar),
|
|
5438
|
+
);
|
|
5439
|
+
|
|
5440
|
+
// tab 栏(即概览: 状态点+订阅名+端口;临期/超量红标、离线灰)+ 详情区(仅激活显示)
|
|
5441
|
+
// 选中态跨刷新保留(_wdSubsActiveTab),点击纯 DOM 切换不重渲染
|
|
5442
|
+
var activePort = _wdSubsActiveTab[wdEscId(userId)] || _SUBS_BASE_PROXY_PORT;
|
|
5443
|
+
var tabsBar = styled(
|
|
5444
|
+
"div",
|
|
5445
|
+
"display:flex;gap:2px;padding:0 8px;background:#16161e;border-bottom:1px solid #2a2a3a;overflow-x:auto;flex-wrap:wrap",
|
|
5446
|
+
(function () {
|
|
5447
|
+
var out = "";
|
|
5448
|
+
for (var m = 1; m <= count; m++) {
|
|
5449
|
+
out += renderSubsTab(m, configured[m - 1], byPort[_SUBS_BASE_PROXY_PORT + m - 1], userId,
|
|
5450
|
+
_SUBS_BASE_PROXY_PORT + m - 1 === activePort);
|
|
5451
|
+
}
|
|
5452
|
+
return out;
|
|
5453
|
+
})(),
|
|
5454
|
+
);
|
|
5455
|
+
var bodies = "";
|
|
5456
|
+
for (var m2 = 1; m2 <= count; m2++) {
|
|
5457
|
+
bodies += renderSubsBody(m2, configured[m2 - 1], byPort[_SUBS_BASE_PROXY_PORT + m2 - 1], userId,
|
|
5458
|
+
_SUBS_BASE_PROXY_PORT + m2 - 1 === activePort);
|
|
5459
|
+
}
|
|
5460
|
+
return styled(
|
|
5461
|
+
"div",
|
|
5462
|
+
"margin-top:6px;border:1px solid #2a2a3a;border-radius:8px;overflow:hidden",
|
|
5463
|
+
head + tabsBar + bodies,
|
|
5464
|
+
);
|
|
5465
|
+
}
|
|
5466
|
+
|
|
5467
|
+
/** 订阅选中 tab(userId → 代理端口),跨刷新保留 */
|
|
5468
|
+
var _wdSubsActiveTab = {};
|
|
5469
|
+
|
|
5470
|
+
/** tab 点击切换(纯 DOM 操作,不重渲染;与节点展开/切换命令同用代理端口路由) */
|
|
5471
|
+
function wdSubsTab(userId, port) {
|
|
5472
|
+
_wdSubsActiveTab[wdEscId(userId)] = port;
|
|
5473
|
+
for (var i = 0; i < _SUBS_MAX_INSTANCES; i++) {
|
|
5474
|
+
var p = _SUBS_BASE_PROXY_PORT + i;
|
|
5475
|
+
var tab = document.getElementById("wdSubsTab-" + wdEscId(userId) + "-" + p);
|
|
5476
|
+
var body = document.getElementById("wdSubsBody-" + wdEscId(userId) + "-" + p);
|
|
5477
|
+
if (!tab || !body) continue;
|
|
5478
|
+
var active = p === port;
|
|
5479
|
+
tab.style.background = active ? "#2a2a3a" : "transparent";
|
|
5480
|
+
tab.style.color = active ? "#fff" : "#94a3b8";
|
|
5481
|
+
tab.style.borderBottom = active ? "2px solid #60a5fa" : "2px solid transparent";
|
|
5482
|
+
body.style.display = active ? "block" : "none";
|
|
5483
|
+
}
|
|
5484
|
+
}
|
|
5485
|
+
|
|
5486
|
+
/** tab 按钮(即概览条目): 状态点 + 订阅名 + 端口;临期/超量红标、离线灰 */
|
|
5487
|
+
function renderSubsTab(n, conf, ch, userId, active) {
|
|
5488
|
+
var port = _SUBS_BASE_PROXY_PORT + n - 1;
|
|
5489
|
+
var meta = (ch && ch.meta) || {};
|
|
5490
|
+
var sub = ((meta.proxy || {}).subscriptions || [])[0] || null;
|
|
5491
|
+
var online = !!(ch && ch.node);
|
|
5492
|
+
var name = (sub && sub.name) || (conf && conf.name) || "实例" + n;
|
|
5493
|
+
// 临期/超量判定(与详情区同口径)
|
|
5494
|
+
var danger = false;
|
|
5495
|
+
if (sub) {
|
|
5496
|
+
var used = (sub.upload || 0) + (sub.download || 0);
|
|
5497
|
+
var total = sub.total || 0;
|
|
5498
|
+
var pct = total > 0 ? Math.round(((total - used) / total) * 100) : null;
|
|
5499
|
+
var daysLeft = sub.expire ? Math.round((sub.expire * 1000 - Date.now()) / 86400000) : null;
|
|
5500
|
+
danger = (pct !== null && pct < 10) || (daysLeft !== null && daysLeft < 7);
|
|
5501
|
+
}
|
|
5502
|
+
var dot = !online ? "○" : ch.healthy ? "●" : "●";
|
|
5503
|
+
var dotColor = !online ? "#555" : ch.healthy ? "#4ade80" : "#f87171";
|
|
5504
|
+
return tag(
|
|
5505
|
+
"button",
|
|
5506
|
+
{
|
|
5507
|
+
id: "wdSubsTab-" + wdEscId(userId) + "-" + port,
|
|
5508
|
+
onclick: "wdSubsTab('" + esc(userId) + "'," + port + ")",
|
|
5509
|
+
style:
|
|
5510
|
+
"padding:5px 10px;font-size:11px;cursor:pointer;white-space:nowrap;border:none;flex-shrink:0;" +
|
|
5511
|
+
"background:" + (active ? "#2a2a3a" : "transparent") + ";" +
|
|
5512
|
+
"color:" + (active ? "#fff" : "#94a3b8") + ";" +
|
|
5513
|
+
"border-bottom:2px solid " + (active ? "#60a5fa" : "transparent") + ";" +
|
|
5514
|
+
(online ? "" : "opacity:0.6;"),
|
|
5515
|
+
title: "实例" + n + " · 代理 " + port + " · API " + (_SUBS_BASE_API_PORT + n - 1) + (online ? (ch.healthy ? " · 健康" : " · 异常") : " · 未上线"),
|
|
5516
|
+
},
|
|
5517
|
+
styled("span", "color:" + dotColor, dot) +
|
|
5518
|
+
" " + name + styled("span", "color:#60a5fa;font-size:10px", " :" + port) +
|
|
5519
|
+
(danger ? styled("span", "color:#f87171;margin-left:2px", "⚠️") : ""),
|
|
5520
|
+
);
|
|
5521
|
+
}
|
|
5522
|
+
|
|
5523
|
+
/** tab 详情区: 当前节点 + 端口/实例 + 用量/到期 + 速率 + 节点列表(可展开) */
|
|
5524
|
+
function renderSubsBody(n, conf, ch, userId, active) {
|
|
5525
|
+
var port = _SUBS_BASE_PROXY_PORT + n - 1;
|
|
5526
|
+
var apiPort = _SUBS_BASE_API_PORT + n - 1;
|
|
5527
|
+
var meta = (ch && ch.meta) || {};
|
|
5528
|
+
var proxy = meta.proxy || {};
|
|
5529
|
+
var sub = (proxy.subscriptions || [])[0] || null;
|
|
5530
|
+
var online = !!(ch && ch.node);
|
|
5531
|
+
var nodes = meta.healthyNodes || [];
|
|
5532
|
+
var delays = meta.healthyNodeDelays || {};
|
|
5533
|
+
|
|
5534
|
+
// 行1: 当前节点 + 健康状态 + 节点列表展开
|
|
5535
|
+
var line1;
|
|
5536
|
+
if (online) {
|
|
5537
|
+
var dotColor = ch.healthy ? "#4ade80" : "#f87171";
|
|
5538
|
+
var status = ch.healthy
|
|
5539
|
+
? (ch.timeTotal || 0).toFixed(1) + "s " + ((ch.sizeDownload || 0) / 1024).toFixed(0) + "KB"
|
|
5540
|
+
: ch.error || "异常";
|
|
5541
|
+
line1 = styled(
|
|
5542
|
+
"div",
|
|
5543
|
+
"display:flex;align-items:center;gap:8px;padding:8px 10px 4px;font-size:13px;flex-wrap:wrap",
|
|
5544
|
+
styled("span", "color:" + dotColor, "●") +
|
|
5545
|
+
styled("span", "color:#e0e0e0;font-weight:bold;word-break:break-all", ch.node) +
|
|
5546
|
+
styled("span", "color:#666;font-size:11px", "| " + status) +
|
|
5547
|
+
(ch.stale ? styled("span", "color:#fbbf24;font-size:10px", "数据过期" + ch.ageSeconds + "s") : "") +
|
|
5548
|
+
(nodes.length
|
|
5549
|
+
? tag(
|
|
5550
|
+
"button",
|
|
5551
|
+
{
|
|
5552
|
+
onclick: "wdToggleNodes('" + esc(userId) + "'," + port + ")",
|
|
5553
|
+
style:
|
|
5554
|
+
"margin-left:auto;padding:2px 8px;font-size:11px;background:#333;color:#60a5fa;" +
|
|
5555
|
+
"border:1px solid #444;border-radius:3px;cursor:pointer;flex-shrink:0",
|
|
5556
|
+
title: "查看/切换该实例的可用节点",
|
|
5557
|
+
},
|
|
5558
|
+
nodes.length + " 个节点 ▸",
|
|
5559
|
+
)
|
|
5560
|
+
: ""),
|
|
5561
|
+
);
|
|
5562
|
+
} else {
|
|
5563
|
+
line1 = styled(
|
|
5564
|
+
"div",
|
|
5565
|
+
"display:flex;align-items:center;gap:8px;padding:8px 10px 4px;font-size:12px",
|
|
5566
|
+
styled("span", "color:#555", "○") +
|
|
5567
|
+
styled("span", "color:#888", "实例未运行(等待自愈拉起,配置保留)"),
|
|
5568
|
+
);
|
|
5569
|
+
}
|
|
5570
|
+
|
|
5571
|
+
// 行2: 实例/端口(业务 --proxy 用代理端口)
|
|
5572
|
+
var line2 = styled(
|
|
5573
|
+
"div",
|
|
5574
|
+
"padding:2px 10px;font-size:11px;color:#888;display:flex;gap:10px;flex-wrap:wrap",
|
|
5575
|
+
styled("span", null, "实例 #" + n) +
|
|
5576
|
+
tag(
|
|
5577
|
+
"span",
|
|
5578
|
+
{
|
|
5579
|
+
style: "color:#60a5fa;font-family:monospace",
|
|
5580
|
+
title: "业务命令 --proxy 用此端口",
|
|
5581
|
+
},
|
|
5582
|
+
"代理 127.0.0.1:" + port,
|
|
5583
|
+
) +
|
|
5584
|
+
styled("span", "color:#555;font-family:monospace", "API " + apiPort) +
|
|
5585
|
+
(proxy.runtime && proxy.runtime.status ? styled("span", "color:#555", "内核" + (proxy.runtime.status || "")) : ""),
|
|
5586
|
+
);
|
|
5587
|
+
|
|
5588
|
+
// 行3: 用量/到期 + 行4: 速率
|
|
5589
|
+
var line34 = "";
|
|
5590
|
+
if (sub) {
|
|
5591
|
+
var used = (sub.upload || 0) + (sub.download || 0);
|
|
5592
|
+
var total = sub.total || 0;
|
|
5593
|
+
var remain = total > 0 ? total - used : 0;
|
|
5594
|
+
var pct = total > 0 ? Math.round((remain / total) * 100) : null;
|
|
5595
|
+
var daysLeft = sub.expire ? Math.round((sub.expire * 1000 - Date.now()) / 86400000) : null;
|
|
5596
|
+
var danger = (pct !== null && pct < 10) || (daysLeft !== null && daysLeft < 7);
|
|
5597
|
+
var parts = [];
|
|
5598
|
+
if (sub.proxies) parts.push(sub.proxies + " 节点");
|
|
5599
|
+
parts.push(total > 0 ? "剩 " + fmtGB(remain) + (pct !== null ? " (" + pct + "%)" : "") : "用量未知");
|
|
5600
|
+
if (sub.expire) {
|
|
5601
|
+
parts.push("到 " + new Date(sub.expire * 1000).toLocaleDateString() + (daysLeft !== null ? "(剩" + daysLeft + "天)" : ""));
|
|
5602
|
+
}
|
|
5603
|
+
line34 = styled(
|
|
5604
|
+
"div",
|
|
5605
|
+
"padding:2px 10px 4px;font-size:11px" + (danger ? ";background:#3b1d1d" : ""),
|
|
5606
|
+
styled("span", "color:" + (danger ? "#f87171" : "#94a3b8"), "📡 " + parts.join(" · ") + (danger ? " ⚠️" : "")),
|
|
5607
|
+
);
|
|
5608
|
+
}
|
|
5609
|
+
var t = proxy.traffic;
|
|
5610
|
+
if (t && (t.downRate > 0 || t.upRate > 0)) {
|
|
5611
|
+
line34 += styled(
|
|
5612
|
+
"div",
|
|
5613
|
+
"padding:2px 10px 6px;font-size:11px;color:#666",
|
|
5614
|
+
"⚡ ↓" + fmtSpeed(t.downRate) + " / ↑" + fmtSpeed(t.upRate),
|
|
5615
|
+
);
|
|
5616
|
+
}
|
|
5617
|
+
|
|
5618
|
+
var bodyInner = line1 + line2 + line34 +
|
|
5619
|
+
(online && nodes.length ? renderNodeList(nodes, ch.node, userId, port, delays) : "");
|
|
5620
|
+
return tag(
|
|
5621
|
+
"div",
|
|
5622
|
+
{
|
|
5623
|
+
id: "wdSubsBody-" + wdEscId(userId) + "-" + port,
|
|
5624
|
+
style: "display:" + (active ? "block" : "none") + ";border-top:1px solid #22222f",
|
|
5625
|
+
},
|
|
5626
|
+
bodyInner,
|
|
5313
5627
|
);
|
|
5314
5628
|
}
|
|
5315
5629
|
|
|
5630
|
+
|
|
5316
5631
|
function fmtGB(bytes) {
|
|
5317
5632
|
if (!bytes || bytes <= 0) return "0";
|
|
5318
5633
|
var gb = bytes / 1073741824;
|
|
@@ -5328,31 +5643,14 @@ function fmtSpeed(bytesPerSec) {
|
|
|
5328
5643
|
/**
|
|
5329
5644
|
* 订阅用量/到期 + 实时速率 + 订阅配置入口(数据: clashHealth.meta.proxy)
|
|
5330
5645
|
* 临期(<7天)或超量(剩余<10%)标红
|
|
5646
|
+
* @param {boolean} [withConfig] 是否渲染配置入口按钮(默认 true;多实例总览区已收敛唯一入口时传 false)
|
|
5331
5647
|
*/
|
|
5332
|
-
function renderSubscriptions(proxy, userId) {
|
|
5648
|
+
function renderSubscriptions(proxy, userId, withConfig) {
|
|
5333
5649
|
proxy = proxy || {};
|
|
5334
5650
|
var subs = proxy.subscriptions || [];
|
|
5335
5651
|
var rows = "";
|
|
5336
5652
|
// 订阅应用结果(daemon 最近一次 setSubscription 执行反馈;随下次应用/daemon 重启刷新)
|
|
5337
|
-
|
|
5338
|
-
if (ar && ar.at) {
|
|
5339
|
-
if (ar.ok === false) {
|
|
5340
|
-
rows +=
|
|
5341
|
-
styled(
|
|
5342
|
-
"div",
|
|
5343
|
-
"font-size:11px;margin:2px 0 4px;padding:4px 6px;background:#3b1d1d;border:1px solid #7f1d1d;border-radius:4px;color:#f87171;line-height:1.4",
|
|
5344
|
-
"❌ 订阅应用失败: " + esc(ar.error || "未知错误") +
|
|
5345
|
-
styled("span", "color:#94a3b8;margin-left:6px;font-size:10px", new Date(ar.at).toLocaleTimeString()),
|
|
5346
|
-
);
|
|
5347
|
-
} else if (ar.changed) {
|
|
5348
|
-
rows +=
|
|
5349
|
-
styled(
|
|
5350
|
-
"div",
|
|
5351
|
-
"font-size:11px;margin:2px 0 4px;color:#4ade80",
|
|
5352
|
-
"✓ 订阅应用成功 (" + (ar.count || "?") + " 条," + new Date(ar.at).toLocaleTimeString() + ")",
|
|
5353
|
-
);
|
|
5354
|
-
}
|
|
5355
|
-
}
|
|
5653
|
+
rows += renderApplyResult(proxy.applyResult);
|
|
5356
5654
|
// 实时速率
|
|
5357
5655
|
var t = proxy.traffic;
|
|
5358
5656
|
if (t && (t.downRate > 0 || t.upRate > 0)) {
|
|
@@ -5389,7 +5687,8 @@ function renderSubscriptions(proxy, userId) {
|
|
|
5389
5687
|
);
|
|
5390
5688
|
}
|
|
5391
5689
|
// 配置入口(摘要双来源: 后台配置优先,回退 daemon 实际生效快照;显示条数+订阅名)
|
|
5392
|
-
|
|
5690
|
+
if (withConfig !== false) {
|
|
5691
|
+
var configured = (window._wdData && _wdData.proxySettings && _wdData.proxySettings[userId] && _wdData.proxySettings[userId].subscriptions) || proxy.subscriptions || [];
|
|
5393
5692
|
var cfgNames = configured
|
|
5394
5693
|
.map(function (s) {
|
|
5395
5694
|
return s.name || String(s.url || "").replace(/^https?:\/\//, "").split("/")[0];
|
|
@@ -5416,6 +5715,7 @@ function renderSubscriptions(proxy, userId) {
|
|
|
5416
5715
|
"配置 ⚙️",
|
|
5417
5716
|
),
|
|
5418
5717
|
);
|
|
5718
|
+
}
|
|
5419
5719
|
return rows;
|
|
5420
5720
|
}
|
|
5421
5721
|
|
|
@@ -786,7 +786,7 @@
|
|
|
786
786
|
style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:9999;align-items:center;justify-content:center">
|
|
787
787
|
<div style="background:#1e1e2e;border:1px solid #333;border-radius:12px;padding:24px;width:90%;max-width:560px">
|
|
788
788
|
<div style="font-size:16px;color:#fbbf24;margin-bottom:4px">🛜 代理订阅配置 — <span id="wdProxyUserId"></span></div>
|
|
789
|
-
<div style="color:#888;font-size:12px;margin-bottom:12px">每行一个订阅地址(http/https),<b style="color:#fbbf24"
|
|
789
|
+
<div style="color:#888;font-size:12px;margin-bottom:12px">每行一个订阅地址(http/https),<b style="color:#fbbf24">一行 = 一个独立 mihomo 实例,最多 8 行</b>;<b style="color:#fbbf24">同一地址可配置多行</b>(各自独立使用不同节点):按行序分配实例 1/2/3…,代理端口依次 127.0.0.1:7897、7898、7899…(API 管理端口 9097 起,同步 +1),业务命令用 --proxy 指定使用哪个端口(客户端卡片“代理订阅”tabs 可查映射)。<br>保存后客户端下次心跳时自动热应用(约 15~30 秒);订阅名按行序自动编号 sub-1、sub-2…,<b style="color:#f87171">在中间插入/删除行会使后续实例重新编号并重载配置</b>,建议只在末尾追加。临期(<7天)或超量(剩余<10%)在 tab 上标红。</div>
|
|
790
790
|
<textarea id="wdProxySubsText" rows="6" placeholder="https://example.com/api/v1/client/subscribe?token=xxxx\nhttps://another-airport.com/sub?token=yyyy"
|
|
791
791
|
style="width:100%;box-sizing:border-box;padding:10px;background:#16161e;border:1px solid #333;border-radius:8px;color:#e0e0e0;font-size:12px;font-family:monospace;resize:vertical"></textarea>
|
|
792
792
|
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
|
|
@@ -831,7 +831,12 @@ async function handleSetErrorTrigger(params, body, res) {
|
|
|
831
831
|
sendJSON(res, 200, { ok: true, config: cleaned });
|
|
832
832
|
}
|
|
833
833
|
|
|
834
|
-
/**
|
|
834
|
+
/**
|
|
835
|
+
* 配置某 agent 的代理订阅(心跳下发 → daemon 热应用;空列表表示清空)
|
|
836
|
+
* 允许同一订阅地址配置多行(每行一个独立实例,可各自使用不同节点)
|
|
837
|
+
* 上限 8 条: 与 discover.js RUNTIME_MAX_INSTANCES=8 对齐(第 9 条实例端口会超出
|
|
838
|
+
* 巡检枚举段 9097-9104,永远进不了 daemon 巡检列表)
|
|
839
|
+
*/
|
|
835
840
|
async function handleSetProxySettings(params, body, res) {
|
|
836
841
|
const { userId, subscriptions } = body || {};
|
|
837
842
|
if (!userId) {
|
|
@@ -842,16 +847,19 @@ async function handleSetProxySettings(params, body, res) {
|
|
|
842
847
|
sendJSON(res, 400, { error: "subscriptions 须为数组" });
|
|
843
848
|
return;
|
|
844
849
|
}
|
|
845
|
-
// 归一化: 字符串或 {name,url}
|
|
846
|
-
const
|
|
850
|
+
// 归一化: 字符串或 {name,url},去空去非法;不去重(同 URL 多行 = 多实例)
|
|
851
|
+
const MAX_SUBS = 8;
|
|
847
852
|
const cleaned = [];
|
|
848
853
|
for (const it of subscriptions) {
|
|
849
854
|
const url = typeof it === "string" ? it.trim() : it && String(it.url || "").trim();
|
|
850
|
-
if (!url || !/^https?:\/\//.test(url)
|
|
851
|
-
seen.add(url);
|
|
855
|
+
if (!url || !/^https?:\/\//.test(url)) continue;
|
|
852
856
|
const name = typeof it === "object" && it.name ? String(it.name).trim() : "";
|
|
853
857
|
cleaned.push({ name, url });
|
|
854
858
|
}
|
|
859
|
+
if (cleaned.length > MAX_SUBS) {
|
|
860
|
+
sendJSON(res, 400, { error: `最多 ${MAX_SUBS} 条(每条一个独立 mihomo 实例,超出部分无法巡检)` });
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
855
863
|
setProxySettingsStored(userId, { subscriptions: cleaned });
|
|
856
864
|
const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
|
857
865
|
console.error(`[watchdog ${ts}] ${userId} 代理订阅配置已更新: ${cleaned.length} 条`);
|
package/src/watchdog/agent.js
CHANGED
|
@@ -113,7 +113,7 @@ const NETWORK_ERROR_PATTERNS = [
|
|
|
113
113
|
];
|
|
114
114
|
const NETWORK_ERROR_RE = new RegExp(NETWORK_ERROR_PATTERNS.join("|"), "i");
|
|
115
115
|
const ERROR_SCAN_INTERVAL_MS = 30_000; // 日志扫描间隔
|
|
116
|
-
const SWITCH_SCAN_INTERVAL_MS = 15_000; // 代理切换感知检查间隔(daemon restartSeq 变化 →
|
|
116
|
+
const SWITCH_SCAN_INTERVAL_MS = 15_000; // 代理切换感知检查间隔(daemon restartSeq 变化 → 重启对应通道命令)
|
|
117
117
|
const ERROR_TRIGGER_COUNT = 10; // 连续错误阈值
|
|
118
118
|
const ERROR_MIN_MS = 5 * 60 * 1000; // 最小持续时间(时间只做最小值判断)
|
|
119
119
|
const ERROR_BREAK_MS = 120_000; // 两次错误间隔超过则视为连续中断,重置计数
|
|
@@ -145,6 +145,27 @@ function isWebserverMode(cmdDef) {
|
|
|
145
145
|
return /--webserver(?=\s|=|$)/.test(cmdDef?.command || "");
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* 解析命令使用的代理端口(多实例通道路由):
|
|
150
|
+
* --proxy http://127.0.0.1:7898 / --proxy 127.0.0.1:7898 / --proxy=:7898 → 7898
|
|
151
|
+
* 无 --proxy(走系统代理 = 主控制端)→ null
|
|
152
|
+
* 用于: 错误触发 failover / 代理切换重启 时路由到出错命令实际使用的实例,
|
|
153
|
+
* 避免多实例下切错对象(用 7898 的命令报错却切了 7897 的节点)。
|
|
154
|
+
*/
|
|
155
|
+
function parseCmdProxyPort(cmdDef) {
|
|
156
|
+
const s = cmdDef?.command || "";
|
|
157
|
+
const m = s.match(/--proxy(?:=|\s+)(\S+)/);
|
|
158
|
+
if (!m) return null;
|
|
159
|
+
const pm = m[1].match(/:(\d{2,5})(?:\/|$)/);
|
|
160
|
+
return pm ? parseInt(pm[1], 10) : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 从 daemon 健康数据提取主控制端代理端口(meta.proxyUrl = http://127.0.0.1:7897) */
|
|
164
|
+
function mainProxyPortOf(health) {
|
|
165
|
+
const m = String(health?.meta?.proxyUrl || "").match(/:(\d{2,5})\/?$/);
|
|
166
|
+
return m ? parseInt(m[1], 10) : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
148
169
|
/** 自动发现 daemon.js 路径 */
|
|
149
170
|
function findDaemonPath() {
|
|
150
171
|
// 1. 内部打包路径优先(npm publish 后 src/clash/daemon.js,升级即最新)
|
|
@@ -459,35 +480,54 @@ export class WatchdogAgent {
|
|
|
459
480
|
try {
|
|
460
481
|
const health = getClashHealth();
|
|
461
482
|
if (!health) return; // daemon 未就绪,暂不初始化基线
|
|
483
|
+
// 通道 → seq 映射: 主控制端(默认文件)与其代理端口通道是同一控制器双写,
|
|
484
|
+
// 用代理端口作 key 自动合并,避免同一次切换被当成两个通道
|
|
485
|
+
const mainPort = mainProxyPortOf(health);
|
|
462
486
|
const seqMap = new Map();
|
|
463
|
-
seqMap.set("main", Number(health.restartSeq) || 0);
|
|
487
|
+
seqMap.set(mainPort || "main", Number(health.restartSeq) || 0);
|
|
464
488
|
for (const ch of getClashHealthChannels()) {
|
|
465
|
-
|
|
489
|
+
if (mainPort && ch.port === mainPort) continue; // 已并入主控制端 key
|
|
490
|
+
seqMap.set(ch.port, Number(ch.restartSeq) || 0);
|
|
466
491
|
}
|
|
467
492
|
if (this._restartSeqBaseline === null) {
|
|
468
493
|
this._restartSeqBaseline = seqMap; // 首次读取建基线(watchdog 重启不误触发)
|
|
469
494
|
return;
|
|
470
495
|
}
|
|
471
|
-
|
|
496
|
+
// 收集发生切换的通道(多实例各自独立切换,互不影响)
|
|
497
|
+
const switchedPorts = []; // 代理端口(数字);主控制端无端口信息时为 null
|
|
498
|
+
let mainSwitched = false;
|
|
472
499
|
for (const [key, seq] of seqMap) {
|
|
473
500
|
if (seq > (this._restartSeqBaseline.get(key) || 0)) {
|
|
474
|
-
|
|
475
|
-
|
|
501
|
+
if (key === "main") mainSwitched = true;
|
|
502
|
+
else {
|
|
503
|
+
switchedPorts.push(key);
|
|
504
|
+
if (key === mainPort) mainSwitched = true; // 主控制端端口通道切换
|
|
505
|
+
}
|
|
476
506
|
}
|
|
477
507
|
}
|
|
478
508
|
this._restartSeqBaseline = seqMap;
|
|
479
|
-
if (!
|
|
509
|
+
if (!switchedPorts.length && !mainSwitched) return;
|
|
510
|
+
const switchedDesc = switchedPorts.length
|
|
511
|
+
? switchedPorts.join(",") + (mainSwitched ? ",主控制端" : "")
|
|
512
|
+
: "主控制端";
|
|
480
513
|
if (!this._switchRestartEnabled) {
|
|
481
514
|
console.error(
|
|
482
|
-
`[watchdog]
|
|
515
|
+
`[watchdog] 检测到代理切换(通道 ${switchedDesc})但重启功能未启用(后台可开启),跳过重启`,
|
|
483
516
|
);
|
|
484
517
|
return;
|
|
485
518
|
}
|
|
486
519
|
const reason = health.lastSwitch
|
|
487
520
|
? `代理切换(${health.lastSwitch})`
|
|
488
521
|
: "代理切换";
|
|
489
|
-
|
|
490
|
-
|
|
522
|
+
// 只重启使用切换通道的命令(按 --proxy 匹配;无 --proxy 的命令走系统代理=主控制端)
|
|
523
|
+
// 避免多实例下误伤其它通道的命令
|
|
524
|
+
await this.processManager.restartMatching(
|
|
525
|
+
`代理切换(通道 ${switchedDesc}, ${reason})`,
|
|
526
|
+
(cmdDef) => {
|
|
527
|
+
const p = parseCmdProxyPort(cmdDef);
|
|
528
|
+
return p === null ? mainSwitched : switchedPorts.includes(p);
|
|
529
|
+
},
|
|
530
|
+
);
|
|
491
531
|
} catch (e) {
|
|
492
532
|
console.error(`[watchdog] 代理切换感知检查失败: ${e.message}`);
|
|
493
533
|
}
|
|
@@ -548,18 +588,21 @@ export class WatchdogAgent {
|
|
|
548
588
|
if (!this.running || !this._errorTriggerEnabled) return;
|
|
549
589
|
const now = Date.now();
|
|
550
590
|
const scanned = this.processManager.scanErrorLogs(NETWORK_ERROR_RE);
|
|
551
|
-
|
|
591
|
+
// 本轮已触发切换的通道(同通道只触发一次;不同通道互不阻塞——多实例各自出口独立)
|
|
592
|
+
const triggeredChannels = new Set();
|
|
552
593
|
for (const { cmdId, cmdDef, newErrors } of scanned) {
|
|
553
594
|
if (!isCollectorCommand(cmdDef)) continue;
|
|
554
595
|
// webserver 模式:错误发生在远端出口,本地切节点无法解决,不计入错误触发统计
|
|
555
596
|
if (isWebserverMode(cmdDef)) continue;
|
|
597
|
+
// 通道键: 命令实际使用的代理端口(多实例各切各的);无 --proxy = 主控制端
|
|
598
|
+
const channelKey = parseCmdProxyPort(cmdDef) || "main";
|
|
556
599
|
let st = this._errorStates.get(cmdId) || {
|
|
557
600
|
consecutive: 0,
|
|
558
601
|
firstErrorAt: 0,
|
|
559
602
|
lastErrorAt: 0,
|
|
560
603
|
};
|
|
561
|
-
if (
|
|
562
|
-
//
|
|
604
|
+
if (triggeredChannels.has(channelKey)) {
|
|
605
|
+
// 本轮该通道已触发切换 → 该进程也从新计数开始(防同轮接力触发)
|
|
563
606
|
st = { consecutive: 0, firstErrorAt: 0, lastErrorAt: 0 };
|
|
564
607
|
}
|
|
565
608
|
const hadNew = newErrors.length > 0;
|
|
@@ -581,14 +624,13 @@ export class WatchdogAgent {
|
|
|
581
624
|
now - st.firstErrorAt >= this._errorTriggerMinMs
|
|
582
625
|
) {
|
|
583
626
|
console.error(
|
|
584
|
-
`[watchdog] ${cmdId} 连续 ${st.consecutive} 次错误(≥${this._errorTriggerCount}次,持续≥${this._errorTriggerMinMs / 60000}min
|
|
627
|
+
`[watchdog] ${cmdId} 连续 ${st.consecutive} 次错误(≥${this._errorTriggerCount}次,持续≥${this._errorTriggerMinMs / 60000}min),触发节点切换 (通道 ${channelKey})`,
|
|
585
628
|
);
|
|
586
|
-
this._triggerFailover(cmdId);
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
// 触发者自身也重新计数(含本轮后续错误行,从 0 累计)。
|
|
629
|
+
this._triggerFailover(cmdId, parseCmdProxyPort(cmdDef));
|
|
630
|
+
// 切换作用于该命令实际使用的代理通道;同通道命令共用出口,
|
|
631
|
+
// 全部重新计数给新节点干净观察窗口(含触发者自身,从本轮后续错误重新累计)。
|
|
590
632
|
this._errorStates.clear();
|
|
591
|
-
|
|
633
|
+
triggeredChannels.add(channelKey);
|
|
592
634
|
st = { consecutive: 0, firstErrorAt: 0, lastErrorAt: 0 };
|
|
593
635
|
}
|
|
594
636
|
}
|
|
@@ -616,31 +658,59 @@ export class WatchdogAgent {
|
|
|
616
658
|
}
|
|
617
659
|
}
|
|
618
660
|
|
|
619
|
-
/**
|
|
620
|
-
|
|
661
|
+
/**
|
|
662
|
+
* 下发 failover 命令给 daemon(候选集切下一个可用节点)
|
|
663
|
+
* @param {string} cmdId 触发源命令 id(日志用)
|
|
664
|
+
* @param {number|null} proxyPort 命令实际使用的代理端口(多实例路由;null=主控制端)
|
|
665
|
+
*/
|
|
666
|
+
async _triggerFailover(cmdId, proxyPort = null) {
|
|
621
667
|
try {
|
|
622
668
|
const ipc = await import("../clash/ipc.js");
|
|
623
|
-
ipc.writeCommand(
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
669
|
+
ipc.writeCommand(
|
|
670
|
+
{
|
|
671
|
+
action: "failover",
|
|
672
|
+
reason: `业务进程${cmdId ? ` ${cmdId}` : ""} 连续错误触发`,
|
|
673
|
+
...(proxyPort ? { proxyPort } : {}),
|
|
674
|
+
},
|
|
675
|
+
proxyPort || 0,
|
|
676
|
+
);
|
|
677
|
+
console.error(
|
|
678
|
+
`[watchdog] 已下发 failover 命令 (触发源: ${cmdId}, 通道: ${proxyPort || "主控制端"})`,
|
|
679
|
+
);
|
|
628
680
|
} catch (e) {
|
|
629
681
|
console.error(`[watchdog] 下发 failover 失败: ${e.message}`);
|
|
630
682
|
}
|
|
631
683
|
}
|
|
632
684
|
|
|
633
|
-
/**
|
|
685
|
+
/**
|
|
686
|
+
* 下发强切 failover(force: 无视 daemon 冷却期,直接轮换候选)
|
|
687
|
+
* 客户端异常告警无法归因到具体通道 → 对全部代理通道各发一条强切
|
|
688
|
+
* (默认通道=主控制端 + 其余实例通道;主控制端的代理端口通道与默认通道
|
|
689
|
+
* 是同一控制器双写,跳过避免同一次切换被切两次)
|
|
690
|
+
*/
|
|
634
691
|
async _forceFailover(reason) {
|
|
635
692
|
try {
|
|
636
693
|
const ipc = await import("../clash/ipc.js");
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
694
|
+
const mainPort = mainProxyPortOf(getClashHealth());
|
|
695
|
+
const ports = [
|
|
696
|
+
0,
|
|
697
|
+
...getClashHealthChannels()
|
|
698
|
+
.map((ch) => ch.port)
|
|
699
|
+
.filter((p) => p !== mainPort),
|
|
700
|
+
];
|
|
701
|
+
for (const p of ports) {
|
|
702
|
+
ipc.writeCommand(
|
|
703
|
+
{
|
|
704
|
+
action: "failover",
|
|
705
|
+
force: true,
|
|
706
|
+
reason: reason || "客户端异常强切",
|
|
707
|
+
...(p ? { proxyPort: p } : {}),
|
|
708
|
+
},
|
|
709
|
+
p,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
642
712
|
console.error(
|
|
643
|
-
`[watchdog] 已下发强切命令 (原因: ${reason || "客户端异常"})`,
|
|
713
|
+
`[watchdog] 已下发强切命令 (${ports.length} 个通道, 原因: ${reason || "客户端异常"})`,
|
|
644
714
|
);
|
|
645
715
|
} catch (e) {
|
|
646
716
|
console.error(`[watchdog] 下发强切失败: ${e.message}`);
|
package/src/watchdog/env-init.js
CHANGED
|
@@ -146,11 +146,24 @@ async function killTree(pid) {
|
|
|
146
146
|
*/
|
|
147
147
|
export async function takeoverEnvironment({ spawnDaemon, selfPid, log = () => {} } = {}) {
|
|
148
148
|
const procs = listProcesses();
|
|
149
|
+
// 自身祖先链一律跳过: 启动包装层(schtasks 的 cmd /c node cli.js watchdog…、
|
|
150
|
+
// Start-Process 的 powershell 等)命令行同样命中 agent 签名,若误判为"旧 agent"
|
|
151
|
+
// 会 taskkill /T 树杀连坐树内的自己(214 实采踩坑:watchdog 启动后即自杀)。
|
|
152
|
+
// 祖先链只可能是本次启动的包装层,绝不可能是需要接管的"旧实例"。
|
|
153
|
+
const byPid = new Map(procs.map((p) => [p.pid, p]));
|
|
154
|
+
const ancestors = new Set();
|
|
155
|
+
let cur = byPid.get(selfPid)?.ppid;
|
|
156
|
+
while (cur && cur > 1 && !ancestors.has(cur) && ancestors.size < 32) {
|
|
157
|
+
ancestors.add(cur);
|
|
158
|
+
cur = byPid.get(cur)?.ppid;
|
|
159
|
+
}
|
|
160
|
+
|
|
149
161
|
const agents = [];
|
|
150
162
|
const daemons = [];
|
|
151
163
|
|
|
152
164
|
for (const p of procs) {
|
|
153
165
|
if (p.pid === selfPid) continue;
|
|
166
|
+
if (ancestors.has(p.pid)) continue; // 祖先链跳过(防树杀自杀)
|
|
154
167
|
if (isAgentCmd(p.cmd)) agents.push(p);
|
|
155
168
|
else if (spawnDaemon && isDaemonCmd(p.cmd)) daemons.push(p);
|
|
156
169
|
}
|
|
@@ -740,18 +740,29 @@ export class ProcessManager extends EventEmitter {
|
|
|
740
740
|
* @returns {Promise<{restarted:number}>}
|
|
741
741
|
*/
|
|
742
742
|
async restartAll(reason) {
|
|
743
|
+
return this.restartMatching(reason, () => true);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* 按条件选择性强制重启(错峰停止 + 错峰启动,同 restartAll 语义)
|
|
748
|
+
* 多实例场景: 代理切换/故障切换只重启使用对应通道(--proxy 端口)的命令,
|
|
749
|
+
* 避免误伤其它通道的命令。
|
|
750
|
+
* @param {string} reason 重启原因(日志)
|
|
751
|
+
* @param {(cmdDef: object) => boolean} matchFn 命令匹配函数
|
|
752
|
+
*/
|
|
753
|
+
async restartMatching(reason, matchFn) {
|
|
743
754
|
const toRestart = [];
|
|
744
755
|
for (const [, info] of this.processes) {
|
|
745
|
-
if (!info.stopped && info.child && info.cmdDef) {
|
|
756
|
+
if (!info.stopped && info.child && info.cmdDef && matchFn(info.cmdDef)) {
|
|
746
757
|
toRestart.push(info.cmdDef);
|
|
747
758
|
}
|
|
748
759
|
}
|
|
749
760
|
if (!toRestart.length) {
|
|
750
|
-
console.error(`[watchdog] ${reason}
|
|
761
|
+
console.error(`[watchdog] ${reason},无匹配的运行中命令,跳过重启`);
|
|
751
762
|
return { restarted: 0 };
|
|
752
763
|
}
|
|
753
764
|
console.error(
|
|
754
|
-
`[watchdog] ${reason}
|
|
765
|
+
`[watchdog] ${reason},强制重启匹配命令 (${toRestart.length}个)...`,
|
|
755
766
|
);
|
|
756
767
|
// 错峰停止:第一个立即停,之后每个间隔 RESTART_STOP_STAGGER_MS(平滑释放资源,避免同时杀 N 个浏览器)
|
|
757
768
|
for (let i = 0; i < toRestart.length; i++) {
|