tt-help-cli-ycl 1.4.171 → 1.4.175
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/reset-login-pool.mjs +66 -0
- package/scripts/test-login-pool-e2e-real.mjs +134 -0
- package/scripts/test-login-pool-ui.mjs +211 -0
- package/scripts/test-login-pool.mjs +381 -0
- package/scripts/test-subscription-allocator.mjs +52 -0
- package/scripts/test-watchdog-subs-retry.mjs +123 -0
- package/src/clash/runtime.js +8 -4
- package/src/cli/explore.js +269 -23
- package/src/cli/search.js +241 -56
- package/src/cli/watchdog.js +34 -0
- package/src/lib/args.js +15 -2
- package/src/lib/browser/cdp.js +12 -2
- package/src/lib/browser/health-checker.js +16 -13
- package/src/lib/linux-env-init.js +258 -0
- package/src/lib/login-pool-client.js +156 -0
- package/src/scraper/modules/follow-extractor.js +23 -5
- package/src/scraper/modules/guess-extractor.js +4 -1
- package/src/watch/login-pool.js +755 -0
- package/src/watch/public/app.js +209 -25
- package/src/watch/public/index.html +19 -1
- package/src/watch/server.js +252 -0
- package/src/watch/subscription-allocator.js +18 -5
- package/src/watch/subscription-center.js +18 -2
- package/src/watch/watchdog-server.js +113 -0
- package/src/watchdog/agent.js +89 -194
- package/src/watchdog/login-apply.js +232 -0
- package/src/watchdog/login-import.js +177 -0
- package/src/watchdog/upgrader.js +59 -5
|
@@ -14,13 +14,18 @@
|
|
|
14
14
|
* 3 粘性 槽位原位保留——订阅还活着就不挪窝(端口 = 槽位序);
|
|
15
15
|
* 不在池子的存量 URL 状态未知,同样视为可用(迁移无感)
|
|
16
16
|
* 4 节点内摊开 同一机器槽位尽量用不同订阅;count > 可用去重数才回绕
|
|
17
|
-
* 5 跨机器错开
|
|
18
|
-
* (占用数 = 该订阅在所有机器 alloc
|
|
17
|
+
* 5 跨机器错开 补槽时优先选全集群「加权占用」最小的可用订阅
|
|
18
|
+
* (占用数 = 该订阅在所有机器 alloc 里出现的次数;
|
|
19
|
+
* 加权占用 = 占用数 / weight,weight 缺省 1 → 退化为大家 1:1:1:1 摊开,与无权重时代数完全一致)
|
|
19
20
|
* 6 重复合法 回绕重复按池子顺序取
|
|
20
21
|
* 7 空保护 可用集为空 → alloc 原地不动 + 标黄,绝不下发空列表
|
|
21
22
|
* 8 决定性 纯函数 + 池子顺序决胜;心跳重跑输入不变则结果恒等,天然粘性
|
|
22
23
|
*
|
|
23
|
-
*
|
|
24
|
+
* 权重语义: weight 只影响跨机器份额,不破坏粘性——存量槽照旧占位计数,
|
|
25
|
+
* 份额超了后续新槽自然不再选它,平滑收敛到 weight 比例;
|
|
26
|
+
* 节点内摊开(规则4)不受权重影响,机器内部仍优先用不同订阅。
|
|
27
|
+
*
|
|
28
|
+
* 选槽打分(取最小): (本节点其它槽位已用 ? 1 : 0) → 全局占用数/weight 升序 → 池子顺序。
|
|
24
29
|
* 全局占用数每次现算(遍历所有节点 alloc,几十个节点毫秒级);分配时机 = 心跳惰性计算,
|
|
25
30
|
* 结果与存量不同才写回配置(下一行 sendJSON 读到的即是新 alloc,同一个心跳就下发)。
|
|
26
31
|
*
|
|
@@ -86,11 +91,16 @@ function scoreLess(a, b) {
|
|
|
86
91
|
* @returns {{ alloc: Array<{name,url}>, warn: string|null }}
|
|
87
92
|
* warn: "pool-empty" 可用集为空且存在补不上的槽(规则 #7)
|
|
88
93
|
*/
|
|
89
|
-
export function allocateSubscriptions({ available, count, prevAlloc, usage, isAvailUrl }) {
|
|
94
|
+
export function allocateSubscriptions({ available, count, prevAlloc, usage, weights, isAvailUrl }) {
|
|
90
95
|
const prev = Array.isArray(prevAlloc) ? prevAlloc.map(normEntry) : [];
|
|
91
96
|
const n = Math.max(1, Math.min(ALLOC_MAX_INSTANCES, parseInt(count, 10) || 1));
|
|
92
97
|
const avail = Array.isArray(available) ? available : [];
|
|
93
98
|
const use = usage && typeof usage === "object" ? usage : {};
|
|
99
|
+
const wt = weights && typeof weights === "object" ? weights : {};
|
|
100
|
+
const weightOf = (url) => {
|
|
101
|
+
const w = parseInt(wt[url], 10);
|
|
102
|
+
return Number.isFinite(w) && w > 0 ? w : 1; // 非法/缺省一律按 1,行为与无权重版一致
|
|
103
|
+
};
|
|
94
104
|
|
|
95
105
|
const nodeUsed = new Map(); // 本节点新 alloc 内的占用(含粘性保留槽,跨节点占用走 usage)
|
|
96
106
|
const globalUsed = (url) => (use[url] || 0) + (nodeUsed.get(url) || 0);
|
|
@@ -120,7 +130,7 @@ export function allocateSubscriptions({ available, count, prevAlloc, usage, isAv
|
|
|
120
130
|
for (let k = 0; k < avail.length; k++) {
|
|
121
131
|
const s = avail[k];
|
|
122
132
|
const inNode = (nodeUsed.get(s.url) || 0) > 0;
|
|
123
|
-
const score = [inNode ? 1 : 0, globalUsed(s.url), k];
|
|
133
|
+
const score = [inNode ? 1 : 0, globalUsed(s.url) / weightOf(s.url), k];
|
|
124
134
|
if (!bestScore || scoreLess(score, bestScore)) {
|
|
125
135
|
best = s;
|
|
126
136
|
bestScore = score;
|
|
@@ -199,6 +209,8 @@ export function reconcileAutoAlloc(userId) {
|
|
|
199
209
|
const views = getSubscriptionView();
|
|
200
210
|
const viewByUrl = new Map(views.map((v) => [v.url, v]));
|
|
201
211
|
const available = views.filter(isSubAvailable);
|
|
212
|
+
const weights = {};
|
|
213
|
+
for (const v of views) weights[v.url] = v.weight;
|
|
202
214
|
|
|
203
215
|
// 全局占用数现算:其它节点 alloc 里该 URL 出现的次数(本节点走分配内计数)
|
|
204
216
|
const usage = {};
|
|
@@ -220,6 +232,7 @@ export function reconcileAutoAlloc(userId) {
|
|
|
220
232
|
count,
|
|
221
233
|
prevAlloc,
|
|
222
234
|
usage,
|
|
235
|
+
weights,
|
|
223
236
|
isAvailUrl: (u) => isAllocUrlAvailable(u, viewByUrl),
|
|
224
237
|
});
|
|
225
238
|
|
|
@@ -45,6 +45,7 @@ const HISTORY_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 快照历史保留 30
|
|
|
45
45
|
const RESETS_MAX = 20; // 重置事件最多保留条数
|
|
46
46
|
const NODE_REPORT_STALE_MS = 26 * 60 * 60 * 1000; // 节点代报数据超过 26h 视为陈旧
|
|
47
47
|
const SUBS_MAX = 64; // 订阅池容量上限
|
|
48
|
+
const WEIGHT_MAX = 10; // 自动分配权重上限(1=普通份额,越大占比越高;下限恒为 1,无 0 值语义)
|
|
48
49
|
const UA = "clash.meta/1.19.31"; // 机场按 UA 发货:浏览器 UA 拿到 HTML 页,Clash UA 才带头
|
|
49
50
|
const SAVE_DEBOUNCE_MS = 3 * 1000;
|
|
50
51
|
|
|
@@ -423,7 +424,14 @@ function cleanText(v, max) {
|
|
|
423
424
|
return typeof v === "string" ? v.trim().slice(0, max || 120) : "";
|
|
424
425
|
}
|
|
425
426
|
|
|
426
|
-
|
|
427
|
+
/** 权重归一化:非法/缺省 → 1;钳位 1..WEIGHT_MAX(只入整数) */
|
|
428
|
+
function normalizeWeight(v) {
|
|
429
|
+
const n = parseInt(v, 10);
|
|
430
|
+
if (!Number.isFinite(n)) return 1;
|
|
431
|
+
return Math.max(1, Math.min(WEIGHT_MAX, n));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function addSubscription({ name, url, note, weight } = {}) {
|
|
427
435
|
load();
|
|
428
436
|
url = cleanText(url, 2048);
|
|
429
437
|
if (!url || !/^https?:\/\//.test(url))
|
|
@@ -438,6 +446,7 @@ export function addSubscription({ name, url, note } = {}) {
|
|
|
438
446
|
name: cleanText(name, 64) || `订阅-${_subs.length + 1}`,
|
|
439
447
|
url,
|
|
440
448
|
note: cleanText(note, 200),
|
|
449
|
+
weight: normalizeWeight(weight),
|
|
441
450
|
enabled: true,
|
|
442
451
|
addedAt: now,
|
|
443
452
|
updatedAt: now,
|
|
@@ -452,7 +461,7 @@ export function addSubscription({ name, url, note } = {}) {
|
|
|
452
461
|
return { ok: true, entry };
|
|
453
462
|
}
|
|
454
463
|
|
|
455
|
-
export function updateSubscription({ id, name, url, note, enabled } = {}) {
|
|
464
|
+
export function updateSubscription({ id, name, url, note, enabled, weight } = {}) {
|
|
456
465
|
load();
|
|
457
466
|
const entry = _subs.find((s) => s.id === id);
|
|
458
467
|
if (!entry) return { ok: false, error: "订阅不存在" };
|
|
@@ -470,6 +479,12 @@ export function updateSubscription({ id, name, url, note, enabled } = {}) {
|
|
|
470
479
|
if (name !== undefined) entry.name = cleanText(name, 64) || entry.name;
|
|
471
480
|
if (note !== undefined) entry.note = cleanText(note, 200);
|
|
472
481
|
if (enabled !== undefined) entry.enabled = !!enabled;
|
|
482
|
+
if (weight !== undefined) {
|
|
483
|
+
const w = normalizeWeight(weight);
|
|
484
|
+
if (w !== (entry.weight || 1))
|
|
485
|
+
console.error(`[订阅中心] ⚖ 「${entry.name}」分配权重 ${entry.weight || 1} → ${w}`);
|
|
486
|
+
entry.weight = w;
|
|
487
|
+
}
|
|
473
488
|
entry.updatedAt = Date.now();
|
|
474
489
|
saveNow();
|
|
475
490
|
console.error(
|
|
@@ -532,6 +547,7 @@ export function getSubscriptionView() {
|
|
|
532
547
|
id: e.id,
|
|
533
548
|
name: e.name,
|
|
534
549
|
note: e.note || "",
|
|
550
|
+
weight: e.weight || 1,
|
|
535
551
|
enabled: e.enabled !== false,
|
|
536
552
|
addedAt: e.addedAt || 0,
|
|
537
553
|
// URL 仅控制页面可见,含 token 不进任何服务端日志
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
probeSubscription,
|
|
31
31
|
} from "./subscription-center.js";
|
|
32
32
|
import { reconcileAutoAlloc, ALLOC_MAX_INSTANCES } from "./subscription-allocator.js";
|
|
33
|
+
import { heartbeatLoginPool, getLoginPoolView, addAccount, updateAccount, deleteAccount, assignAccount, releaseAccount } from "./login-pool.js";
|
|
33
34
|
|
|
34
35
|
const homeDir = homedir() || process.env.HOME || process.env.USERPROFILE || "";
|
|
35
36
|
const WATCHDOG_CONFIG_PATH = join(homeDir, ".tt-help-watchdog.json");
|
|
@@ -58,6 +59,8 @@ const clashCmdQueue = new Map();
|
|
|
58
59
|
// 登录检测手动重检队列:clientId → true(后台页面点「重检」下发,随心跳取走即清空,一次性)
|
|
59
60
|
const loginRecheckQueue = new Map();
|
|
60
61
|
|
|
62
|
+
// 登录池命令队列在 login-pool.js 内部管理(userId → [applyLogin/clearLogin],取走即清空)
|
|
63
|
+
|
|
61
64
|
// 国家过滤配置幂等下发记录:userId → JSON.stringify(countries)
|
|
62
65
|
// 仅当配置变化时才入队,避免每次心跳都触发 daemon 刷新候选集
|
|
63
66
|
const lastSentCountries = new Map();
|
|
@@ -558,6 +561,14 @@ async function handleHeartbeat(params, body, res) {
|
|
|
558
561
|
const loginRecheck = loginRecheckQueue.get(clientId) || false;
|
|
559
562
|
if (loginRecheck) loginRecheckQueue.delete(clientId);
|
|
560
563
|
|
|
564
|
+
// 登录池:续租 + 健康采集 + 取走 applyLogin/clearLogin 命令(一次性)
|
|
565
|
+
let loginPoolCmds;
|
|
566
|
+
try {
|
|
567
|
+
loginPoolCmds = heartbeatLoginPool(userId, body);
|
|
568
|
+
} catch (e) {
|
|
569
|
+
console.error(`[watchdog] 登录池心跳处理异常: ${e.message}`);
|
|
570
|
+
}
|
|
571
|
+
|
|
561
572
|
const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
|
562
573
|
console.error(
|
|
563
574
|
`[watchdog ${ts}] 心跳: ${clientId} (${body.hostname || "?"}, user=${userId || "?"}) ${body.processes?.length || 0}进程, 返回${commands.length}条命令${clashCommands.length ? ", " + clashCommands.length + "条Clash命令" : ""}`,
|
|
@@ -575,6 +586,7 @@ async function handleHeartbeat(params, body, res) {
|
|
|
575
586
|
autoUpgrade: getAutoUpgradeStored(userId),
|
|
576
587
|
loginPorts: getLoginPortsStored(userId),
|
|
577
588
|
loginRecheck: loginRecheck || undefined,
|
|
589
|
+
loginPool: loginPoolCmds,
|
|
578
590
|
serverTime: Date.now(),
|
|
579
591
|
});
|
|
580
592
|
}
|
|
@@ -595,6 +607,8 @@ async function handleGetClients(params, body, res) {
|
|
|
595
607
|
configPath: WATCHDOG_CONFIG_PATH,
|
|
596
608
|
// 订阅中心视图(探测/代报快照 + 状态推导 + 引用关系;独立文件,读多写少)
|
|
597
609
|
subscriptions: getSubscriptionViewSafe(),
|
|
610
|
+
// 登录池视图(脱敏,永不含 cookies 明文;独立文件)
|
|
611
|
+
loginPool: getLoginPoolViewSafe(),
|
|
598
612
|
});
|
|
599
613
|
}
|
|
600
614
|
|
|
@@ -607,6 +621,15 @@ function getSubscriptionViewSafe() {
|
|
|
607
621
|
}
|
|
608
622
|
}
|
|
609
623
|
|
|
624
|
+
function getLoginPoolViewSafe() {
|
|
625
|
+
try {
|
|
626
|
+
return getLoginPoolView();
|
|
627
|
+
} catch (e) {
|
|
628
|
+
console.error(`[watchdog] 登录池视图构建失败: ${e.message}`);
|
|
629
|
+
return { accounts: [], total: 0 };
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
610
633
|
// ── 订阅中心 API(Phase 1: 池子 CRUD + 主动探测 + 只读引用展示,不改任何节点行为) ──
|
|
611
634
|
|
|
612
635
|
async function handleSubscriptionsList(params, body, res) {
|
|
@@ -1118,6 +1141,72 @@ async function handleRecheckLogin(params, body, res) {
|
|
|
1118
1141
|
sendJSON(res, 200, { ok: true, message: "已下发重检,客户端下次心跳时执行" });
|
|
1119
1142
|
}
|
|
1120
1143
|
|
|
1144
|
+
// ── 登录池 API(脱敏视图;cookies 明文只在心跳响应通道下行,永不经 GET API) ──
|
|
1145
|
+
|
|
1146
|
+
function loginPoolError(res, e) {
|
|
1147
|
+
console.error(`[watchdog] 登录池操作失败: ${e.message}`);
|
|
1148
|
+
sendJSON(res, 400, { error: e.message });
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
async function handleLoginPoolView(params, body, res) {
|
|
1152
|
+
try {
|
|
1153
|
+
sendJSON(res, 200, { ok: true, ...getLoginPoolView() });
|
|
1154
|
+
} catch (e) {
|
|
1155
|
+
loginPoolError(res, e);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
async function handleLoginPoolAdd(params, body, res) {
|
|
1160
|
+
try {
|
|
1161
|
+
const view = addAccount(body || {});
|
|
1162
|
+
console.error(`[watchdog] 登录池入池: ${view.username}(${view.cookieCount} 条 cookies)`);
|
|
1163
|
+
sendJSON(res, 200, { ok: true, account: view });
|
|
1164
|
+
} catch (e) {
|
|
1165
|
+
loginPoolError(res, e);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
async function handleLoginPoolUpdate(params, body, res) {
|
|
1170
|
+
try {
|
|
1171
|
+
const view = updateAccount(body || {});
|
|
1172
|
+
sendJSON(res, 200, { ok: true, account: view });
|
|
1173
|
+
} catch (e) {
|
|
1174
|
+
loginPoolError(res, e);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
async function handleLoginPoolDelete(params, body, res) {
|
|
1179
|
+
try {
|
|
1180
|
+
const r = deleteAccount((body || {}).id);
|
|
1181
|
+
console.error(`[watchdog] 登录池删除: ${r.username}`);
|
|
1182
|
+
sendJSON(res, 200, { ok: true, ...r });
|
|
1183
|
+
} catch (e) {
|
|
1184
|
+
loginPoolError(res, e);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
async function handleLoginPoolAssign(params, body, res) {
|
|
1189
|
+
try {
|
|
1190
|
+
const view = assignAccount(body || {});
|
|
1191
|
+
console.error(
|
|
1192
|
+
`[watchdog] 登录池指派: ${view.username} → ${body.userId}:${body.port}(下次心跳下发 applyLogin)`,
|
|
1193
|
+
);
|
|
1194
|
+
sendJSON(res, 200, { ok: true, account: view });
|
|
1195
|
+
} catch (e) {
|
|
1196
|
+
loginPoolError(res, e);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
async function handleLoginPoolRelease(params, body, res) {
|
|
1201
|
+
try {
|
|
1202
|
+
const view = releaseAccount(body || {});
|
|
1203
|
+
console.error(`[watchdog] 登录池释放: ${view.username}`);
|
|
1204
|
+
sendJSON(res, 200, { ok: true, account: view });
|
|
1205
|
+
} catch (e) {
|
|
1206
|
+
loginPoolError(res, e);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1121
1210
|
// ── 公开 ──
|
|
1122
1211
|
|
|
1123
1212
|
export function registerWatchdogRoutes(httpServer) {
|
|
@@ -1226,6 +1315,30 @@ export async function tryHandleWatchdogRoute(
|
|
|
1226
1315
|
await handleRecheckLogin(params, body, res);
|
|
1227
1316
|
return true;
|
|
1228
1317
|
}
|
|
1318
|
+
if (method === "GET" && routePath === "/api/watchdog/login-pool") {
|
|
1319
|
+
await handleLoginPoolView(params, body, res);
|
|
1320
|
+
return true;
|
|
1321
|
+
}
|
|
1322
|
+
if (method === "POST" && routePath === "/api/watchdog/login-pool/add") {
|
|
1323
|
+
await handleLoginPoolAdd(params, body, res);
|
|
1324
|
+
return true;
|
|
1325
|
+
}
|
|
1326
|
+
if (method === "POST" && routePath === "/api/watchdog/login-pool/update") {
|
|
1327
|
+
await handleLoginPoolUpdate(params, body, res);
|
|
1328
|
+
return true;
|
|
1329
|
+
}
|
|
1330
|
+
if (method === "POST" && routePath === "/api/watchdog/login-pool/delete") {
|
|
1331
|
+
await handleLoginPoolDelete(params, body, res);
|
|
1332
|
+
return true;
|
|
1333
|
+
}
|
|
1334
|
+
if (method === "POST" && routePath === "/api/watchdog/login-pool/assign") {
|
|
1335
|
+
await handleLoginPoolAssign(params, body, res);
|
|
1336
|
+
return true;
|
|
1337
|
+
}
|
|
1338
|
+
if (method === "POST" && routePath === "/api/watchdog/login-pool/release") {
|
|
1339
|
+
await handleLoginPoolRelease(params, body, res);
|
|
1340
|
+
return true;
|
|
1341
|
+
}
|
|
1229
1342
|
if (method === "POST" && routePath === "/api/watchdog/request-logs") {
|
|
1230
1343
|
await handleRequestLogs(params, body, res);
|
|
1231
1344
|
return true;
|
package/src/watchdog/agent.js
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
DETECT_CAPTCHA_COUNT_THRESHOLD,
|
|
34
34
|
} from "./client-error-monitor.js";
|
|
35
35
|
import { takeoverEnvironment } from "./env-init.js";
|
|
36
|
+
import { applyLoginToPort, clearLoginOnPort } from "./login-apply.js";
|
|
36
37
|
|
|
37
38
|
// ── 看门狗自身日志入环(供后台页面按需拉取,经 report-logs 回传落盘) ──
|
|
38
39
|
// 包装 console.error:本进程所有 [watchdog]/[llm-analyzer]/[upgrade] 等日志
|
|
@@ -259,14 +260,12 @@ export class WatchdogAgent {
|
|
|
259
260
|
* @param {string} opts.clientId - 客户端标识
|
|
260
261
|
* @param {number} opts.intervalMs - 心跳间隔(毫秒)
|
|
261
262
|
* @param {boolean} [opts.spawnDaemon=true] - 是否自动启动 clash daemon
|
|
262
|
-
* @param {number} [opts.loginCheckIntervalMs] - 登录检测周期(毫秒),默认 2 小时
|
|
263
263
|
*/
|
|
264
264
|
constructor({
|
|
265
265
|
serverUrl,
|
|
266
266
|
clientId,
|
|
267
267
|
intervalMs = 15000,
|
|
268
268
|
spawnDaemon = true,
|
|
269
|
-
loginCheckIntervalMs = 2 * 60 * 60 * 1000,
|
|
270
269
|
}) {
|
|
271
270
|
this.serverUrl = serverUrl.replace(/\/+$/, "");
|
|
272
271
|
this.clientId = clientId;
|
|
@@ -275,16 +274,13 @@ export class WatchdogAgent {
|
|
|
275
274
|
writeStartedFile();
|
|
276
275
|
this.intervalMs = intervalMs;
|
|
277
276
|
this.spawnDaemon = !!spawnDaemon;
|
|
278
|
-
this.
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
this.
|
|
283
|
-
this.
|
|
284
|
-
this.
|
|
285
|
-
this._loginChecking = false; // 防重入
|
|
286
|
-
this._lastLoginCheckAt = 0; // 上次检测时间戳
|
|
287
|
-
this.loginCheckTimer = null;
|
|
277
|
+
this.loginPorts = []; // 服务端下发的登录检测端口列表(账号启动导入的来源)
|
|
278
|
+
this._poolQueue = []; // 登录池命令队列(applyLogin/clearLogin,串行执行)
|
|
279
|
+
this._poolBusy = false; // 池命令 worker 防重入
|
|
280
|
+
this._poolReports = []; // 池命令执行结果(随下次心跳上报后清空)
|
|
281
|
+
this._proxySubsSig = null; // 已转发的订阅配置指纹(变化才转发)
|
|
282
|
+
this._proxySubsSentAt = 0; // 最近一次转发时间戳(daemon applyResult.at 与之比较,只认新失败)
|
|
283
|
+
this._loginImportDone = false; // 登录态导入:启动首跳执行一次的标记
|
|
288
284
|
this.running = false;
|
|
289
285
|
this.heartbeatTimer = null;
|
|
290
286
|
this.thresholdTimer = null;
|
|
@@ -344,6 +340,17 @@ export class WatchdogAgent {
|
|
|
344
340
|
`[watchdog] 升级: ${isGlobalInstall() ? "已启用" : "跳过(源码模式)"}`,
|
|
345
341
|
);
|
|
346
342
|
|
|
343
|
+
// Linux 环境自举(幂等、失败不阻断;Windows/macOS 路径零改动)——
|
|
344
|
+
// headless 自动补 DISPLAY(Xvfb)、Edge 探测、能力随心跳上报,详见 linux-env-init.js
|
|
345
|
+
if (process.platform === "linux") {
|
|
346
|
+
try {
|
|
347
|
+
const { ensureLinuxEnv } = await import("../lib/linux-env-init.js");
|
|
348
|
+
this.envCapability = await ensureLinuxEnv();
|
|
349
|
+
} catch (e) {
|
|
350
|
+
console.error(`[watchdog] ⚠ Linux 环境自举失败(不阻断): ${e.message}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
347
354
|
// 把 clientId 写入 ~/.tt-help.json:业务进程上报异常时携带,看门狗按
|
|
348
355
|
// clientId(每台唯一)关联本机条目;克隆机群 hostname 重复不可用
|
|
349
356
|
if (!setWatchdogClientId(this.clientId)) {
|
|
@@ -416,14 +423,6 @@ export class WatchdogAgent {
|
|
|
416
423
|
this._checkProxySwitch();
|
|
417
424
|
}, SWITCH_SCAN_INTERVAL_MS);
|
|
418
425
|
|
|
419
|
-
// 登录检测调度:每 60s 检查一次是否到点(首次 60s 后检测,之后每 loginCheckIntervalMs)
|
|
420
|
-
this.loginCheckTimer = setInterval(() => {
|
|
421
|
-
this._maybeRunLoginCheck();
|
|
422
|
-
}, 60_000);
|
|
423
|
-
setTimeout(() => {
|
|
424
|
-
this._maybeRunLoginCheck();
|
|
425
|
-
}, 60_000);
|
|
426
|
-
|
|
427
426
|
// LLM 日志分析(每 10 分钟调服务端接口)
|
|
428
427
|
this._startLlmAnalyzer();
|
|
429
428
|
|
|
@@ -459,10 +458,6 @@ export class WatchdogAgent {
|
|
|
459
458
|
clearInterval(this._switchCheckTimer);
|
|
460
459
|
this._switchCheckTimer = null;
|
|
461
460
|
}
|
|
462
|
-
if (this.loginCheckTimer) {
|
|
463
|
-
clearInterval(this.loginCheckTimer);
|
|
464
|
-
this.loginCheckTimer = null;
|
|
465
|
-
}
|
|
466
461
|
if (this.llmAnalyzerTimer) {
|
|
467
462
|
clearInterval(this.llmAnalyzerTimer);
|
|
468
463
|
this.llmAnalyzerTimer = null;
|
|
@@ -799,7 +794,8 @@ export class WatchdogAgent {
|
|
|
799
794
|
llmAnalysis: this._llmResults,
|
|
800
795
|
clashHealth: getClashHealth(), // daemon 写入的主控制端健康状态(兼容旧版页面)
|
|
801
796
|
clashHealthChannels: getClashHealthChannels(), // 多控制端各通道健康状态
|
|
802
|
-
|
|
797
|
+
loginPool: this._poolReports.length > 0 ? { applied: this._poolReports.splice(0) } : undefined,
|
|
798
|
+
env: this.envCapability || null, // Linux 环境能力(display/Edge 就绪度;旧服务端忽略)
|
|
803
799
|
};
|
|
804
800
|
}
|
|
805
801
|
|
|
@@ -831,6 +827,44 @@ export class WatchdogAgent {
|
|
|
831
827
|
const data = await resp.json();
|
|
832
828
|
this.consecutiveFailures = 0;
|
|
833
829
|
|
|
830
|
+
// 服务端下发的登录检测端口列表(先于业务拉起处理——启动导入依赖它)
|
|
831
|
+
if (Array.isArray(data.loginPorts)) {
|
|
832
|
+
const key = data.loginPorts.join(",");
|
|
833
|
+
if (key !== this._loginPortsKey) {
|
|
834
|
+
this._loginPortsKey = key;
|
|
835
|
+
this.loginPorts = data.loginPorts;
|
|
836
|
+
console.error(
|
|
837
|
+
`[watchdog] 登录检测端口(启动导入来源): ${key || "未配置"}`,
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// 登录态导入(启动首次 loginPorts 到达时执行一次,先于业务拉起;独立步骤,失败不阻塞)
|
|
843
|
+
// 服务端可下发 loginImport:false 按节点关闭(如无需同步的机器)
|
|
844
|
+
if (
|
|
845
|
+
Array.isArray(data.loginPorts) &&
|
|
846
|
+
data.loginPorts.length > 0 &&
|
|
847
|
+
data.loginImport !== false &&
|
|
848
|
+
!this._loginImportDone
|
|
849
|
+
) {
|
|
850
|
+
this._loginImportDone = true;
|
|
851
|
+
try {
|
|
852
|
+
const { syncLoginStates } = await import("./login-import.js");
|
|
853
|
+
const cfg = getClientConfig();
|
|
854
|
+
const sum = await syncLoginStates({
|
|
855
|
+
serverUrl: this.serverUrl,
|
|
856
|
+
userId: cfg.userId,
|
|
857
|
+
ports: data.loginPorts,
|
|
858
|
+
proxyServer: cfg.proxy || null,
|
|
859
|
+
});
|
|
860
|
+
console.error(
|
|
861
|
+
`[watchdog] 登录态导入完成: 导入 ${sum.imported.length} · 跳过 ${sum.skipped.length} · 失败 ${sum.failed.length}`,
|
|
862
|
+
);
|
|
863
|
+
} catch (e) {
|
|
864
|
+
console.error(`[watchdog] 登录态导入失败(不阻塞启动): ${e.message}`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
834
868
|
// 同步进程命令
|
|
835
869
|
if (Array.isArray(data.commands)) {
|
|
836
870
|
const prev = this.processManager.runningCount;
|
|
@@ -870,6 +904,24 @@ export class WatchdogAgent {
|
|
|
870
904
|
applyClientConfig(data.clientConfig);
|
|
871
905
|
}
|
|
872
906
|
// 服务端下发的代理订阅配置(变化才转发给 daemon 热应用;daemon 侧幂等)
|
|
907
|
+
// 重试约定:daemon 应用失败不持久化、结果写 clash-health.json(meta.proxy.applyResult),
|
|
908
|
+
// 靠 agent 检测到失败后重置 sig 重发全量。若不重置,sig 已消耗、重试链断裂
|
|
909
|
+
//(两侧约定脱节 → 永远停在「未配置订阅,不拉起」,F-11 踩坑)。
|
|
910
|
+
if (this._proxySubsSig) {
|
|
911
|
+
const ar = getClashHealth()?.meta?.proxy?.applyResult;
|
|
912
|
+
// ar.at > 转发时间戳:只认本轮转发之后新产生的失败结果,避免旧结果误触发重复重发
|
|
913
|
+
if (
|
|
914
|
+
ar &&
|
|
915
|
+
ar.ok === false &&
|
|
916
|
+
(!this._proxySubsSentAt || ar.at > this._proxySubsSentAt)
|
|
917
|
+
) {
|
|
918
|
+
console.error(
|
|
919
|
+
`[watchdog] daemon 订阅应用失败: ${ar.error || "未知原因"} → 重置订阅签名,本轮心跳重发`,
|
|
920
|
+
);
|
|
921
|
+
this._proxySubsSig = null;
|
|
922
|
+
this._proxySubsSentAt = 0;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
873
925
|
if (
|
|
874
926
|
data.proxySettings &&
|
|
875
927
|
Array.isArray(data.proxySettings.subscriptions)
|
|
@@ -877,6 +929,7 @@ export class WatchdogAgent {
|
|
|
877
929
|
const sig = JSON.stringify(data.proxySettings.subscriptions);
|
|
878
930
|
if (sig !== this._proxySubsSig) {
|
|
879
931
|
this._proxySubsSig = sig;
|
|
932
|
+
this._proxySubsSentAt = Date.now();
|
|
880
933
|
try {
|
|
881
934
|
const ipc = await import("../clash/ipc.js");
|
|
882
935
|
ipc.writeCommand({
|
|
@@ -903,22 +956,18 @@ export class WatchdogAgent {
|
|
|
903
956
|
if (data.autoUpgrade !== undefined) {
|
|
904
957
|
this._applyAutoUpgradeConfig(data.autoUpgrade);
|
|
905
958
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
console.error(
|
|
914
|
-
`[watchdog] 登录检测端口: ${key || "未配置"}(周期 ${Math.round(this.loginCheckIntervalMs / 60000)} 分钟)`,
|
|
915
|
-
);
|
|
959
|
+
|
|
960
|
+
// 登录池命令(applyLogin/clearLogin):入队串行执行,结果随下次心跳上报
|
|
961
|
+
if (data.loginPool && Array.isArray(data.loginPool.commands) && data.loginPool.commands.length > 0) {
|
|
962
|
+
for (const c of data.loginPool.commands) {
|
|
963
|
+
if (c && (c.action === "applyLogin" || c.action === "clearLogin") && c.port) {
|
|
964
|
+
this._poolQueue.push(c);
|
|
965
|
+
}
|
|
916
966
|
}
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
this.
|
|
921
|
-
console.error("[watchdog] 服务端下发登录重检,下一跳立即执行");
|
|
967
|
+
console.error(
|
|
968
|
+
`[watchdog] 收到登录池命令 ${data.loginPool.commands.length} 条(队列 ${this._poolQueue.length}),后台串行执行`,
|
|
969
|
+
);
|
|
970
|
+
this._drainPoolQueue();
|
|
922
971
|
}
|
|
923
972
|
|
|
924
973
|
// 心跳日志:仅状态变化或每 10 次打印一次
|
|
@@ -973,161 +1022,7 @@ export class WatchdogAgent {
|
|
|
973
1022
|
}
|
|
974
1023
|
}
|
|
975
1024
|
|
|
976
|
-
/**
|
|
977
|
-
* 登录检测调度入口:到点且服务端配置了端口时执行一次检测
|
|
978
|
-
*/
|
|
979
|
-
_maybeRunLoginCheck() {
|
|
980
|
-
if (!this.running || this._loginChecking) return;
|
|
981
|
-
if (!Array.isArray(this.loginPorts) || this.loginPorts.length === 0) return;
|
|
982
|
-
if (Date.now() - this._lastLoginCheckAt < this.loginCheckIntervalMs) return;
|
|
983
|
-
this._lastLoginCheckAt = Date.now(); // 先占位,防 60s tick 重入
|
|
984
|
-
this._runLoginCheck().catch((e) => {
|
|
985
|
-
console.error(`[watchdog] 登录检测执行失败: ${e.message}`);
|
|
986
|
-
});
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
/**
|
|
990
|
-
* 执行一次登录检测:spawn 子进程跑 tt-help login-check(一次性短任务,不进 ProcessManager)
|
|
991
|
-
*/
|
|
992
|
-
async _runLoginCheck() {
|
|
993
|
-
this._loginChecking = true;
|
|
994
|
-
const ports = this.loginPorts;
|
|
995
|
-
console.error(`[watchdog] 开始登录检测 (端口: ${ports.join(",")})...`);
|
|
996
|
-
try {
|
|
997
|
-
// 全局安装用 tt-help 命令;源码模式用 node cli.js
|
|
998
|
-
const cliPath = join(__dirname, "..", "..", "cli.js");
|
|
999
|
-
const cmd = isGlobalInstall() ? "tt-help" : process.execPath;
|
|
1000
|
-
const args = isGlobalInstall()
|
|
1001
|
-
? ["login-check", ports.join(","), "--json"]
|
|
1002
|
-
: [cliPath, "login-check", ports.join(","), "--json"];
|
|
1003
|
-
// 带上 ~/.tt-help.json 里配置的代理(服务端可通过 clientConfig 下发)
|
|
1004
|
-
const cfg = getClientConfig();
|
|
1005
|
-
if (cfg.proxy) args.push("--proxy", cfg.proxy);
|
|
1006
|
-
|
|
1007
|
-
const { stdout, stderr, code } = await new Promise((resolve) => {
|
|
1008
|
-
// Windows 下 tt-help 是 .cmd 包装,必须 shell:true 才能被 cmd.exe 解析
|
|
1009
|
-
// (与 process-manager.startCommand 的 Windows 处理保持一致)
|
|
1010
|
-
const child = IS_WIN
|
|
1011
|
-
? spawn([cmd, ...args].join(" "), {
|
|
1012
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1013
|
-
shell: true,
|
|
1014
|
-
windowsHide: true,
|
|
1015
|
-
})
|
|
1016
|
-
: spawn(cmd, args, {
|
|
1017
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1018
|
-
});
|
|
1019
|
-
let out = "",
|
|
1020
|
-
err = "";
|
|
1021
|
-
child.stdout.on("data", (d) => (out += d));
|
|
1022
|
-
child.stderr.on("data", (d) => (err += d));
|
|
1023
|
-
child.on("error", (e) =>
|
|
1024
|
-
resolve({ stdout: out, stderr: err + e.message, code: -1 }),
|
|
1025
|
-
);
|
|
1026
|
-
child.on("close", (c) =>
|
|
1027
|
-
resolve({ stdout: out, stderr: err, code: c }),
|
|
1028
|
-
);
|
|
1029
|
-
// 超时保护 10 分钟(单端口含启动探测最长约 2 分钟)
|
|
1030
|
-
setTimeout(
|
|
1031
|
-
() => {
|
|
1032
|
-
try {
|
|
1033
|
-
child.kill("SIGKILL");
|
|
1034
|
-
} catch {
|
|
1035
|
-
/* ignore */
|
|
1036
|
-
}
|
|
1037
|
-
},
|
|
1038
|
-
10 * 60 * 1000,
|
|
1039
|
-
);
|
|
1040
|
-
});
|
|
1041
1025
|
|
|
1042
|
-
if (code !== 0) {
|
|
1043
|
-
// 部分端口失败时 login-check 也会输出完整 JSON(退出码 2)。
|
|
1044
|
-
// 优先采用 stdout 里的 JSON 结果,仅解析失败才记为 error。
|
|
1045
|
-
let partial = null;
|
|
1046
|
-
try {
|
|
1047
|
-
const parsed = JSON.parse(String(stdout).trim());
|
|
1048
|
-
if (Array.isArray(parsed)) partial = parsed;
|
|
1049
|
-
} catch {
|
|
1050
|
-
// stdout 不是 JSON → 走错误分支
|
|
1051
|
-
}
|
|
1052
|
-
if (partial) {
|
|
1053
|
-
this.loginReport = { ports: partial, checkedAt: Date.now() };
|
|
1054
|
-
console.error(
|
|
1055
|
-
`[watchdog] 登录检测完成(部分失败): ${
|
|
1056
|
-
String(stderr)
|
|
1057
|
-
.split("\n")
|
|
1058
|
-
.filter((l) => l.includes("探测失败"))
|
|
1059
|
-
.slice(-3)
|
|
1060
|
-
.join(" | ")
|
|
1061
|
-
.slice(-300) || "见端口状态"
|
|
1062
|
-
}`,
|
|
1063
|
-
);
|
|
1064
|
-
return;
|
|
1065
|
-
}
|
|
1066
|
-
this.loginReport = {
|
|
1067
|
-
error: `exit ${code}: ${String(stderr).slice(-500)}`,
|
|
1068
|
-
checkedAt: Date.now(),
|
|
1069
|
-
};
|
|
1070
|
-
console.error(`[watchdog] 登录检测失败: ${this.loginReport.error}`);
|
|
1071
|
-
return;
|
|
1072
|
-
}
|
|
1073
|
-
const parsed = JSON.parse(String(stdout).trim());
|
|
1074
|
-
if (!Array.isArray(parsed)) throw new Error("结果不是数组");
|
|
1075
|
-
this.loginReport = { ports: parsed, checkedAt: Date.now() };
|
|
1076
|
-
const summary = parsed
|
|
1077
|
-
.map(
|
|
1078
|
-
(r) =>
|
|
1079
|
-
`${r.port}:${r.status}${r.status === "logged_in" && r.account ? "@" + r.account : ""}`,
|
|
1080
|
-
)
|
|
1081
|
-
.join(" ");
|
|
1082
|
-
console.error(`[watchdog] 登录检测完成: ${summary}`);
|
|
1083
|
-
} catch (e) {
|
|
1084
|
-
this.loginReport = { error: e.message, checkedAt: Date.now() };
|
|
1085
|
-
console.error(`[watchdog] 登录检测异常: ${e.message}`);
|
|
1086
|
-
} finally {
|
|
1087
|
-
this._loginChecking = false;
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
/**
|
|
1092
|
-
* 处理服务端下发的日志拉取请求(后台页面点击"日志"按钮触发)
|
|
1093
|
-
* 收集本机日志(watchdog 自身环形缓冲 / 进程日志只读快照)→ POST 回传服务端落盘
|
|
1094
|
-
* @param {Array<{cmdId:string, tail?:number}>} requests
|
|
1095
|
-
*/
|
|
1096
|
-
async _handleLogRequests(requests) {
|
|
1097
|
-
for (const req of requests) {
|
|
1098
|
-
const cmdId = req && req.cmdId ? String(req.cmdId) : "";
|
|
1099
|
-
if (!cmdId) continue;
|
|
1100
|
-
const tail = Math.max(1, Math.min(Number(req.tail) || 500, 500));
|
|
1101
|
-
let logs = [];
|
|
1102
|
-
if (cmdId === "__watchdog__") {
|
|
1103
|
-
logs = getWatchdogSelfLogs(tail);
|
|
1104
|
-
} else {
|
|
1105
|
-
logs = this.processManager.getProcessLogSnapshot(cmdId, tail);
|
|
1106
|
-
}
|
|
1107
|
-
try {
|
|
1108
|
-
const resp = await fetch(`${this.serverUrl}/api/watchdog/report-logs`, {
|
|
1109
|
-
method: "POST",
|
|
1110
|
-
headers: { "Content-Type": "application/json" },
|
|
1111
|
-
body: JSON.stringify({
|
|
1112
|
-
clientId: this.clientId,
|
|
1113
|
-
cmdId,
|
|
1114
|
-
logs,
|
|
1115
|
-
capturedAt: Date.now(),
|
|
1116
|
-
}),
|
|
1117
|
-
signal: AbortSignal.timeout(20000),
|
|
1118
|
-
});
|
|
1119
|
-
if (!resp.ok) {
|
|
1120
|
-
console.error(
|
|
1121
|
-
`[watchdog] 日志回传失败: ${cmdId} HTTP ${resp.status}`,
|
|
1122
|
-
);
|
|
1123
|
-
continue;
|
|
1124
|
-
}
|
|
1125
|
-
console.error(`[watchdog] 日志回传完成: ${cmdId} (${logs.length}行)`);
|
|
1126
|
-
} catch (e) {
|
|
1127
|
-
console.error(`[watchdog] 日志回传失败: ${cmdId}: ${e.message}`);
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1131
1026
|
|
|
1132
1027
|
/**
|
|
1133
1028
|
* 启动 LLM 日志分析定时器(每 10 分钟调服务端接口)
|