tt-help-cli-ycl 1.4.172 → 1.4.176
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 +90 -44
- package/src/watch/public/index.html +17 -3
- 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 +87 -281
- package/src/watchdog/login-apply.js +232 -0
- package/src/watchdog/login-import.js +180 -0
- package/src/watchdog/upgrader.js +59 -5
|
@@ -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,17 +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._loginRecheckPending = false; // 服务端下发的手动重检待执行标记
|
|
288
|
-
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; // 登录态导入:启动首跳执行一次的标记
|
|
289
284
|
this.running = false;
|
|
290
285
|
this.heartbeatTimer = null;
|
|
291
286
|
this.thresholdTimer = null;
|
|
@@ -345,6 +340,17 @@ export class WatchdogAgent {
|
|
|
345
340
|
`[watchdog] 升级: ${isGlobalInstall() ? "已启用" : "跳过(源码模式)"}`,
|
|
346
341
|
);
|
|
347
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
|
+
|
|
348
354
|
// 把 clientId 写入 ~/.tt-help.json:业务进程上报异常时携带,看门狗按
|
|
349
355
|
// clientId(每台唯一)关联本机条目;克隆机群 hostname 重复不可用
|
|
350
356
|
if (!setWatchdogClientId(this.clientId)) {
|
|
@@ -417,14 +423,6 @@ export class WatchdogAgent {
|
|
|
417
423
|
this._checkProxySwitch();
|
|
418
424
|
}, SWITCH_SCAN_INTERVAL_MS);
|
|
419
425
|
|
|
420
|
-
// 登录检测调度:每 60s 检查一次是否到点(首次 60s 后检测,之后每 loginCheckIntervalMs)
|
|
421
|
-
this.loginCheckTimer = setInterval(() => {
|
|
422
|
-
this._maybeRunLoginCheck();
|
|
423
|
-
}, 60_000);
|
|
424
|
-
setTimeout(() => {
|
|
425
|
-
this._maybeRunLoginCheck();
|
|
426
|
-
}, 60_000);
|
|
427
|
-
|
|
428
426
|
// LLM 日志分析(每 10 分钟调服务端接口)
|
|
429
427
|
this._startLlmAnalyzer();
|
|
430
428
|
|
|
@@ -460,10 +458,6 @@ export class WatchdogAgent {
|
|
|
460
458
|
clearInterval(this._switchCheckTimer);
|
|
461
459
|
this._switchCheckTimer = null;
|
|
462
460
|
}
|
|
463
|
-
if (this.loginCheckTimer) {
|
|
464
|
-
clearInterval(this.loginCheckTimer);
|
|
465
|
-
this.loginCheckTimer = null;
|
|
466
|
-
}
|
|
467
461
|
if (this.llmAnalyzerTimer) {
|
|
468
462
|
clearInterval(this.llmAnalyzerTimer);
|
|
469
463
|
this.llmAnalyzerTimer = null;
|
|
@@ -800,7 +794,8 @@ export class WatchdogAgent {
|
|
|
800
794
|
llmAnalysis: this._llmResults,
|
|
801
795
|
clashHealth: getClashHealth(), // daemon 写入的主控制端健康状态(兼容旧版页面)
|
|
802
796
|
clashHealthChannels: getClashHealthChannels(), // 多控制端各通道健康状态
|
|
803
|
-
|
|
797
|
+
loginPool: this._poolReports.length > 0 ? { applied: this._poolReports.splice(0) } : undefined,
|
|
798
|
+
env: this.envCapability || null, // Linux 环境能力(display/Edge 就绪度;旧服务端忽略)
|
|
804
799
|
};
|
|
805
800
|
}
|
|
806
801
|
|
|
@@ -832,6 +827,44 @@ export class WatchdogAgent {
|
|
|
832
827
|
const data = await resp.json();
|
|
833
828
|
this.consecutiveFailures = 0;
|
|
834
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
|
+
|
|
835
868
|
// 同步进程命令
|
|
836
869
|
if (Array.isArray(data.commands)) {
|
|
837
870
|
const prev = this.processManager.runningCount;
|
|
@@ -871,6 +904,24 @@ export class WatchdogAgent {
|
|
|
871
904
|
applyClientConfig(data.clientConfig);
|
|
872
905
|
}
|
|
873
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
|
+
}
|
|
874
925
|
if (
|
|
875
926
|
data.proxySettings &&
|
|
876
927
|
Array.isArray(data.proxySettings.subscriptions)
|
|
@@ -878,6 +929,7 @@ export class WatchdogAgent {
|
|
|
878
929
|
const sig = JSON.stringify(data.proxySettings.subscriptions);
|
|
879
930
|
if (sig !== this._proxySubsSig) {
|
|
880
931
|
this._proxySubsSig = sig;
|
|
932
|
+
this._proxySubsSentAt = Date.now();
|
|
881
933
|
try {
|
|
882
934
|
const ipc = await import("../clash/ipc.js");
|
|
883
935
|
ipc.writeCommand({
|
|
@@ -904,26 +956,18 @@ export class WatchdogAgent {
|
|
|
904
956
|
if (data.autoUpgrade !== undefined) {
|
|
905
957
|
this._applyAutoUpgradeConfig(data.autoUpgrade);
|
|
906
958
|
}
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
console.error(
|
|
915
|
-
`[watchdog] 登录检测端口: ${key || "未配置"}(周期 ${Math.round(this.loginCheckIntervalMs / 60000)} 分钟)`,
|
|
916
|
-
);
|
|
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
|
+
}
|
|
917
966
|
}
|
|
918
|
-
}
|
|
919
|
-
// 服务端下发「立即重检」:标记待重检 + 清零上次检测时间,60s 调度器下一跳执行
|
|
920
|
-
// (重检只检上次未通过的端口,范围在调度器消费时按最新 loginReport 计算)
|
|
921
|
-
if (data.loginRecheck) {
|
|
922
|
-
this._loginRecheckPending = true;
|
|
923
|
-
this._lastLoginCheckAt = 0;
|
|
924
967
|
console.error(
|
|
925
|
-
|
|
968
|
+
`[watchdog] 收到登录池命令 ${data.loginPool.commands.length} 条(队列 ${this._poolQueue.length}),后台串行执行`,
|
|
926
969
|
);
|
|
970
|
+
this._drainPoolQueue();
|
|
927
971
|
}
|
|
928
972
|
|
|
929
973
|
// 心跳日志:仅状态变化或每 10 次打印一次
|
|
@@ -978,245 +1022,7 @@ export class WatchdogAgent {
|
|
|
978
1022
|
}
|
|
979
1023
|
}
|
|
980
1024
|
|
|
981
|
-
/**
|
|
982
|
-
* 登录检测调度入口:到点且服务端配置了端口时执行一次检测
|
|
983
|
-
*/
|
|
984
|
-
_maybeRunLoginCheck() {
|
|
985
|
-
if (!this.running || this._loginChecking) return;
|
|
986
|
-
if (!Array.isArray(this.loginPorts) || this.loginPorts.length === 0) return;
|
|
987
|
-
if (Date.now() - this._lastLoginCheckAt < this.loginCheckIntervalMs) return;
|
|
988
|
-
|
|
989
|
-
// 手动重检:只检上次未通过的端口;无有效历史结果退化为全量,全部已登录则跳过
|
|
990
|
-
let ports = this.loginPorts;
|
|
991
|
-
if (this._loginRecheckPending) {
|
|
992
|
-
this._loginRecheckPending = false;
|
|
993
|
-
const resolved = this._resolveRecheckPorts();
|
|
994
|
-
if (resolved) {
|
|
995
|
-
if (resolved.length === 0) {
|
|
996
|
-
console.error("[watchdog] 重检跳过: 上次全部已登录,无需重检");
|
|
997
|
-
return; // 不占位,2h 周期照常
|
|
998
|
-
}
|
|
999
|
-
ports = resolved;
|
|
1000
|
-
console.error(
|
|
1001
|
-
`[watchdog] 手动重检: 仅检测上次未通过的端口 (${ports.join(",")})`,
|
|
1002
|
-
);
|
|
1003
|
-
}
|
|
1004
|
-
// resolved === null → 无有效历史结果,保持全量
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
this._lastLoginCheckAt = Date.now(); // 先占位,防 60s tick 重入
|
|
1008
|
-
this._runLoginCheck(ports).catch((e) => {
|
|
1009
|
-
console.error(`[watchdog] 登录检测执行失败: ${e.message}`);
|
|
1010
|
-
});
|
|
1011
|
-
}
|
|
1012
1025
|
|
|
1013
|
-
/**
|
|
1014
|
-
* 计算手动重检端口范围:上次检测未通过(status !== "logged_in")的端口,
|
|
1015
|
-
* 含配置了但上次没检到的端口(同样视为未通过)。
|
|
1016
|
-
* @returns {number[]|null} 端口子集;null = 无有效历史结果(调用方退化为全量)
|
|
1017
|
-
*/
|
|
1018
|
-
_resolveRecheckPorts() {
|
|
1019
|
-
const report = this.loginReport;
|
|
1020
|
-
if (
|
|
1021
|
-
!report ||
|
|
1022
|
-
report.error ||
|
|
1023
|
-
!Array.isArray(report.ports) ||
|
|
1024
|
-
report.ports.length === 0
|
|
1025
|
-
) {
|
|
1026
|
-
return null;
|
|
1027
|
-
}
|
|
1028
|
-
const last = new Map(report.ports.map((r) => [Number(r.port), r.status]));
|
|
1029
|
-
return this.loginPorts.filter((p) => last.get(Number(p)) !== "logged_in");
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
/**
|
|
1033
|
-
* 合并登录检测结果:本次检测过的端口用新结果,未检端口保留上次结果。
|
|
1034
|
-
* 全量检测时未检端口为空,行为等同整体替换(与合并前一致)。
|
|
1035
|
-
* @param {Array|null} results 本次端口级结果(null = 无结果,仅保留旧数据)
|
|
1036
|
-
* @param {number[]} checkedPorts 本次实际检测的端口
|
|
1037
|
-
* @param {string} [error] 本次检测的整级错误(可选)
|
|
1038
|
-
*/
|
|
1039
|
-
_mergeLoginReport(results, checkedPorts, error) {
|
|
1040
|
-
const prev =
|
|
1041
|
-
this.loginReport && Array.isArray(this.loginReport.ports)
|
|
1042
|
-
? this.loginReport.ports
|
|
1043
|
-
: [];
|
|
1044
|
-
const checked = new Set(checkedPorts.map(Number));
|
|
1045
|
-
const order = this.loginPorts.map(Number);
|
|
1046
|
-
const byPort = new Map();
|
|
1047
|
-
// 未被本次覆盖的配置端口保留旧结果(配置外残留丢弃,全量检测时行为等同整体替换)
|
|
1048
|
-
for (const r of prev) {
|
|
1049
|
-
const key = Number(r.port);
|
|
1050
|
-
if (!checked.has(key) && order.includes(key)) byPort.set(key, r);
|
|
1051
|
-
}
|
|
1052
|
-
if (Array.isArray(results)) {
|
|
1053
|
-
for (const r of results) byPort.set(Number(r.port), r);
|
|
1054
|
-
}
|
|
1055
|
-
const ports = [...byPort.values()].sort((a, b) => {
|
|
1056
|
-
const ia = order.indexOf(Number(a.port));
|
|
1057
|
-
const ib = order.indexOf(Number(b.port));
|
|
1058
|
-
return (ia < 0 ? order.length : ia) - (ib < 0 ? order.length : ib);
|
|
1059
|
-
});
|
|
1060
|
-
const report = { ports, checkedAt: Date.now() };
|
|
1061
|
-
if (error) report.error = error;
|
|
1062
|
-
return report;
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
/**
|
|
1066
|
-
* 整级失败时的 loginReport:子集重检失败保留未检端口历史结果,全量检测失败整体记错(原行为)
|
|
1067
|
-
*/
|
|
1068
|
-
_failedLoginReport(checkedPorts, error) {
|
|
1069
|
-
if (checkedPorts.length < this.loginPorts.length) {
|
|
1070
|
-
return this._mergeLoginReport(null, checkedPorts, error);
|
|
1071
|
-
}
|
|
1072
|
-
return { error, checkedAt: Date.now() };
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
/**
|
|
1076
|
-
* 执行一次登录检测:spawn 子进程跑 tt-help login-check(一次性短任务,不进 ProcessManager)
|
|
1077
|
-
*/
|
|
1078
|
-
async _runLoginCheck(portsOverride) {
|
|
1079
|
-
this._loginChecking = true;
|
|
1080
|
-
const ports =
|
|
1081
|
-
Array.isArray(portsOverride) && portsOverride.length > 0
|
|
1082
|
-
? portsOverride
|
|
1083
|
-
: this.loginPorts;
|
|
1084
|
-
console.error(`[watchdog] 开始登录检测 (端口: ${ports.join(",")})...`);
|
|
1085
|
-
try {
|
|
1086
|
-
// 全局安装用 tt-help 命令;源码模式用 node cli.js
|
|
1087
|
-
const cliPath = join(__dirname, "..", "..", "cli.js");
|
|
1088
|
-
const cmd = isGlobalInstall() ? "tt-help" : process.execPath;
|
|
1089
|
-
const args = isGlobalInstall()
|
|
1090
|
-
? ["login-check", ports.join(","), "--json"]
|
|
1091
|
-
: [cliPath, "login-check", ports.join(","), "--json"];
|
|
1092
|
-
// 带上 ~/.tt-help.json 里配置的代理(服务端可通过 clientConfig 下发)
|
|
1093
|
-
const cfg = getClientConfig();
|
|
1094
|
-
if (cfg.proxy) args.push("--proxy", cfg.proxy);
|
|
1095
|
-
|
|
1096
|
-
const { stdout, stderr, code } = await new Promise((resolve) => {
|
|
1097
|
-
// Windows 下 tt-help 是 .cmd 包装,必须 shell:true 才能被 cmd.exe 解析
|
|
1098
|
-
// (与 process-manager.startCommand 的 Windows 处理保持一致)
|
|
1099
|
-
const child = IS_WIN
|
|
1100
|
-
? spawn([cmd, ...args].join(" "), {
|
|
1101
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1102
|
-
shell: true,
|
|
1103
|
-
windowsHide: true,
|
|
1104
|
-
})
|
|
1105
|
-
: spawn(cmd, args, {
|
|
1106
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1107
|
-
});
|
|
1108
|
-
let out = "",
|
|
1109
|
-
err = "";
|
|
1110
|
-
child.stdout.on("data", (d) => (out += d));
|
|
1111
|
-
child.stderr.on("data", (d) => (err += d));
|
|
1112
|
-
child.on("error", (e) =>
|
|
1113
|
-
resolve({ stdout: out, stderr: err + e.message, code: -1 }),
|
|
1114
|
-
);
|
|
1115
|
-
child.on("close", (c) =>
|
|
1116
|
-
resolve({ stdout: out, stderr: err, code: c }),
|
|
1117
|
-
);
|
|
1118
|
-
// 超时保护 10 分钟(单端口含启动探测最长约 2 分钟)
|
|
1119
|
-
setTimeout(
|
|
1120
|
-
() => {
|
|
1121
|
-
try {
|
|
1122
|
-
child.kill("SIGKILL");
|
|
1123
|
-
} catch {
|
|
1124
|
-
/* ignore */
|
|
1125
|
-
}
|
|
1126
|
-
},
|
|
1127
|
-
10 * 60 * 1000,
|
|
1128
|
-
);
|
|
1129
|
-
});
|
|
1130
|
-
|
|
1131
|
-
if (code !== 0) {
|
|
1132
|
-
// 部分端口失败时 login-check 也会输出完整 JSON(退出码 2)。
|
|
1133
|
-
// 优先采用 stdout 里的 JSON 结果,仅解析失败才记为 error。
|
|
1134
|
-
let partial = null;
|
|
1135
|
-
try {
|
|
1136
|
-
const parsed = JSON.parse(String(stdout).trim());
|
|
1137
|
-
if (Array.isArray(parsed)) partial = parsed;
|
|
1138
|
-
} catch {
|
|
1139
|
-
// stdout 不是 JSON → 走错误分支
|
|
1140
|
-
}
|
|
1141
|
-
if (partial) {
|
|
1142
|
-
this.loginReport = this._mergeLoginReport(partial, ports);
|
|
1143
|
-
console.error(
|
|
1144
|
-
`[watchdog] 登录检测完成(部分失败): ${
|
|
1145
|
-
String(stderr)
|
|
1146
|
-
.split("\n")
|
|
1147
|
-
.filter((l) => l.includes("探测失败"))
|
|
1148
|
-
.slice(-3)
|
|
1149
|
-
.join(" | ")
|
|
1150
|
-
.slice(-300) || "见端口状态"
|
|
1151
|
-
}`,
|
|
1152
|
-
);
|
|
1153
|
-
return;
|
|
1154
|
-
}
|
|
1155
|
-
this.loginReport = this._failedLoginReport(
|
|
1156
|
-
ports,
|
|
1157
|
-
`exit ${code}: ${String(stderr).slice(-500)}`,
|
|
1158
|
-
);
|
|
1159
|
-
console.error(`[watchdog] 登录检测失败: ${this.loginReport.error}`);
|
|
1160
|
-
return;
|
|
1161
|
-
}
|
|
1162
|
-
const parsed = JSON.parse(String(stdout).trim());
|
|
1163
|
-
if (!Array.isArray(parsed)) throw new Error("结果不是数组");
|
|
1164
|
-
this.loginReport = this._mergeLoginReport(parsed, ports);
|
|
1165
|
-
const summary = parsed
|
|
1166
|
-
.map(
|
|
1167
|
-
(r) =>
|
|
1168
|
-
`${r.port}:${r.status}${r.status === "logged_in" && r.account ? "@" + r.account : ""}`,
|
|
1169
|
-
)
|
|
1170
|
-
.join(" ");
|
|
1171
|
-
console.error(`[watchdog] 登录检测完成: ${summary}`);
|
|
1172
|
-
} catch (e) {
|
|
1173
|
-
this.loginReport = this._failedLoginReport(ports, e.message);
|
|
1174
|
-
console.error(`[watchdog] 登录检测异常: ${e.message}`);
|
|
1175
|
-
} finally {
|
|
1176
|
-
this._loginChecking = false;
|
|
1177
|
-
}
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
/**
|
|
1181
|
-
* 处理服务端下发的日志拉取请求(后台页面点击"日志"按钮触发)
|
|
1182
|
-
* 收集本机日志(watchdog 自身环形缓冲 / 进程日志只读快照)→ POST 回传服务端落盘
|
|
1183
|
-
* @param {Array<{cmdId:string, tail?:number}>} requests
|
|
1184
|
-
*/
|
|
1185
|
-
async _handleLogRequests(requests) {
|
|
1186
|
-
for (const req of requests) {
|
|
1187
|
-
const cmdId = req && req.cmdId ? String(req.cmdId) : "";
|
|
1188
|
-
if (!cmdId) continue;
|
|
1189
|
-
const tail = Math.max(1, Math.min(Number(req.tail) || 500, 500));
|
|
1190
|
-
let logs = [];
|
|
1191
|
-
if (cmdId === "__watchdog__") {
|
|
1192
|
-
logs = getWatchdogSelfLogs(tail);
|
|
1193
|
-
} else {
|
|
1194
|
-
logs = this.processManager.getProcessLogSnapshot(cmdId, tail);
|
|
1195
|
-
}
|
|
1196
|
-
try {
|
|
1197
|
-
const resp = await fetch(`${this.serverUrl}/api/watchdog/report-logs`, {
|
|
1198
|
-
method: "POST",
|
|
1199
|
-
headers: { "Content-Type": "application/json" },
|
|
1200
|
-
body: JSON.stringify({
|
|
1201
|
-
clientId: this.clientId,
|
|
1202
|
-
cmdId,
|
|
1203
|
-
logs,
|
|
1204
|
-
capturedAt: Date.now(),
|
|
1205
|
-
}),
|
|
1206
|
-
signal: AbortSignal.timeout(20000),
|
|
1207
|
-
});
|
|
1208
|
-
if (!resp.ok) {
|
|
1209
|
-
console.error(
|
|
1210
|
-
`[watchdog] 日志回传失败: ${cmdId} HTTP ${resp.status}`,
|
|
1211
|
-
);
|
|
1212
|
-
continue;
|
|
1213
|
-
}
|
|
1214
|
-
console.error(`[watchdog] 日志回传完成: ${cmdId} (${logs.length}行)`);
|
|
1215
|
-
} catch (e) {
|
|
1216
|
-
console.error(`[watchdog] 日志回传失败: ${cmdId}: ${e.message}`);
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
1026
|
|
|
1221
1027
|
/**
|
|
1222
1028
|
* 启动 LLM 日志分析定时器(每 10 分钟调服务端接口)
|