tt-help-cli-ycl 1.4.115 → 1.4.117

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,7 +37,6 @@ import {
37
37
  killEdgeProcesses,
38
38
  } from "../lib/browser/cdp.js";
39
39
  import { createApiClient } from "../lib/api-client.js";
40
- import { requireClashOrExit } from "../clash/checker.js";
41
40
  import { readHealth, writeCommand } from "../clash/ipc.js";
42
41
  import { spawn } from "child_process";
43
42
  import { fileURLToPath } from "url";
@@ -83,6 +82,19 @@ async function withRetry(label, fn) {
83
82
  */
84
83
  let spawnedDaemonPid = null;
85
84
 
85
+ /** 从代理 URL 解析端口(http://127.0.0.1:7890 → 7890),无法解析返回 0(默认通道) */
86
+ function proxyPortOf(url) {
87
+ if (!url) return 0;
88
+ try {
89
+ const u = new URL(url);
90
+ const p = parseInt(u.port, 10);
91
+ return p > 0 ? p : 0;
92
+ } catch {
93
+ const m = String(url).match(/:(\d+)/);
94
+ return m ? parseInt(m[1], 10) : 0;
95
+ }
96
+ }
97
+
86
98
  function spawnClashDaemon() {
87
99
  try {
88
100
  const mainJs = path.join(__dirname, "..", "main.js");
@@ -115,10 +127,10 @@ function stopSpawnedDaemon() {
115
127
  }
116
128
 
117
129
  /**
118
- * 用 daemon 的 IPC 健康状态判断其是否存活(黑盒接口:clash-health.json)
130
+ * 用 daemon 的 IPC 健康状态判断其是否存活(黑盒接口:clash-health.json,按通道端口)
119
131
  */
120
- function isDaemonAlive() {
121
- const h = readHealth();
132
+ function isDaemonAlive(port = 0) {
133
+ const h = readHealth(port);
122
134
  return !!(h && !h.stale);
123
135
  }
124
136
 
@@ -140,20 +152,13 @@ export async function handleRefreshMini(options) {
140
152
  ensureDaemon,
141
153
  } = options;
142
154
 
155
+ // 配置了什么代理就用什么代理(--proxy > ~/.tt-help.json 的 proxy);
156
+ // 未配置则不注入 proxyServer,浏览器走系统默认代理。
157
+ // 节点切换仍由 clash-daemon 负责(触发通知模式),不在此处自动解析。
143
158
  const effectiveProxy = exploreProxy || configuredProxy;
144
- let resolvedProxy = effectiveProxy;
145
- if (
146
- !effectiveProxy ||
147
- effectiveProxy.includes("127.0.0.1") ||
148
- effectiveProxy.includes("localhost")
149
- ) {
150
- // refresh-mini 必须走本地 Clash:未配置代理时自动发现,节点轮换才有意义
151
- resolvedProxy = await requireClashOrExit("refresh-mini");
152
- } else {
153
- console.error(
154
- `[警告] 使用非本地代理 ${effectiveProxy},Clash 节点轮换将无效`,
155
- );
156
- }
159
+ const resolvedProxy = effectiveProxy || null;
160
+ // 切换通知通道:按本实例的代理端口路由到 daemon 对应控制端
161
+ const proxyPort = proxyPortOf(resolvedProxy);
157
162
 
158
163
  let userId = exploreUserId || configuredUserId;
159
164
  let browser = null;
@@ -196,7 +201,7 @@ export async function handleRefreshMini(options) {
196
201
  if (exploreJobLocations) console.error(`任务国家: ${exploreJobLocations}`);
197
202
  console.error(`空闲间隔: ${exploreInterval || 30} 秒`);
198
203
  console.error(
199
- `代理: ${resolvedProxy || "无"}${
204
+ `代理: ${resolvedProxy || "系统默认"}${
200
205
  rotateMin > 0
201
206
  ? `(每 ${rotateMin} 分钟轮换节点)`
202
207
  : "(定时轮换已关闭)"
@@ -310,8 +315,8 @@ export async function handleRefreshMini(options) {
310
315
 
311
316
  // --ensure-daemon:检查 clash-daemon 是否存活,未存活则拉起(默认不检测)
312
317
  if (ensureDaemon) {
313
- if (isDaemonAlive()) {
314
- const h = readHealth();
318
+ if (isDaemonAlive(proxyPort)) {
319
+ const h = readHealth(proxyPort);
315
320
  console.error(
316
321
  `[daemon] 已运行(上次巡检 ${h?.ageSeconds ?? "?"}s 前)`,
317
322
  );
@@ -339,16 +344,23 @@ export async function handleRefreshMini(options) {
339
344
  proxySwitchCount++;
340
345
  lastProxyTriggerTime = Date.now();
341
346
  consecutiveTaskErrors = 0;
342
- const before = readHealth()?.lastSwitch || "";
347
+ const before = readHealth(proxyPort)?.lastSwitch || "";
343
348
  console.error(
344
- `[代理] 触发切换通知(${reason},累计 ${proxySwitchCount} 次),等待 daemon 回执...`,
349
+ `[代理] 触发切换通知(${reason}${proxyPort ? `,通道 ${proxyPort}` : ""},累计 ${proxySwitchCount} 次),等待 daemon 回执...`,
350
+ );
351
+ writeCommand(
352
+ {
353
+ action: "failover",
354
+ reason: `refresh-mini: ${reason}`,
355
+ proxyPort: proxyPort || undefined,
356
+ },
357
+ proxyPort,
345
358
  );
346
- writeCommand({ action: "failover", reason: `refresh-mini: ${reason}` });
347
359
 
348
360
  const deadline = Date.now() + SWITCH_RECEIPT_TIMEOUT_MS;
349
361
  while (Date.now() < deadline) {
350
362
  await sleep(3000);
351
- const h = readHealth();
363
+ const h = readHealth(proxyPort);
352
364
  if (
353
365
  h &&
354
366
  h.lastSwitch &&
package/src/lib/args.js CHANGED
@@ -758,6 +758,10 @@ function parseClashDaemonArgs(args) {
758
758
  let benchmark = 10;
759
759
  let dryRun = false;
760
760
  let country = null;
761
+ let apiPort = null;
762
+ let secret = null;
763
+ let selection = null;
764
+ const configPaths = [];
761
765
 
762
766
  for (let i = 0; i < args.length; i++) {
763
767
  const arg = args[i];
@@ -769,6 +773,15 @@ function parseClashDaemonArgs(args) {
769
773
  country = args[++i] || null;
770
774
  } else if (arg === "--dry-run") {
771
775
  dryRun = true;
776
+ } else if (arg === "--api-port") {
777
+ apiPort = parseInt(args[++i]) || 0;
778
+ } else if (arg === "--secret") {
779
+ secret = args[++i] || "";
780
+ } else if (arg === "--selection") {
781
+ selection = args[++i] || null;
782
+ } else if (arg === "--config-path") {
783
+ const p = args[++i];
784
+ if (p) configPaths.push(p);
772
785
  }
773
786
  }
774
787
 
@@ -778,6 +791,10 @@ function parseClashDaemonArgs(args) {
778
791
  benchmark,
779
792
  country,
780
793
  "dry-run": dryRun,
794
+ "api-port": apiPort,
795
+ secret,
796
+ selection,
797
+ "config-paths": configPaths,
781
798
  urls: [],
782
799
  outputFormat: "json",
783
800
  exploreCount: 0,
@@ -420,7 +420,7 @@ const HELP_TEXT = [
420
420
  " tt-help watchdog -i 30 --client-id my-server-01",
421
421
  "",
422
422
  " tt-help clash-daemon [选项]",
423
- " Clash Verge 代理节点健康守护:定时巡检 TikTok 可达性,异常时自动切换节点",
423
+ " Clash Verge / mihomo 代理节点健康守护:定时巡检 TikTok 可达性,异常时自动切换节点",
424
424
  " 看门狗启动时会自动拉起此守护进程",
425
425
  " 选项:",
426
426
  " --interval <秒> 巡检间隔,默认 120 秒(2分钟)",
@@ -428,6 +428,11 @@ const HELP_TEXT = [
428
428
  " --country <国家代码> 只保留指定国家节点,默认不过滤",
429
429
  " 支持: US, GB, DE, FR, JP, KR, SG, HK, TW, RU 等",
430
430
  " --dry-run 只检测不切换(调试用)",
431
+ " --api-port <端口> 控制 API 端口(默认自动发现;显式指定验证失败时报错)",
432
+ " --secret <secret> 控制 API 密钥(默认自动获取)",
433
+ " --selection <策略> 多控制端选择策略: system-proxy / preference / first-alive",
434
+ " --config-path <路径> 追加自定义 config.yaml 探测路径(可重复)",
435
+ " 控制端自动发现: 依次探测 Clash Verge / 69cloud 配置 → 端口枚举,按策略选定",
431
436
  " 看门狗运行时可通过 /tmp/clash-cmd.json 发命令动态调整:",
432
437
  ' {"action":"switchNode","node":"节点名"} 一次性切换到指定节点(不干扰自检)',
433
438
  ' {"action":"setCountryFilter","country":"US"} 切换国家过滤',
@@ -4055,23 +4055,41 @@ async function wdSetErrorTrigger() {
4055
4055
  }
4056
4056
  }
4057
4057
 
4058
- // ── 代理节点区域(看门狗面板中每个客户端卡片) ──
4058
+ // ── 代理节点区域(看门狗面板中每个客户端卡片,支持多控制端通道) ──
4059
4059
 
4060
4060
  function renderProxySection(c, userId) {
4061
+ var channels = c.clashHealthChannels || [];
4062
+ if (channels.length > 0) {
4063
+ var html = "";
4064
+ for (var i = 0; i < channels.length; i++) {
4065
+ html += renderProxyChannel(channels[i], userId);
4066
+ }
4067
+ return html;
4068
+ }
4069
+ // 兼容旧版:仅主控制端(legacy clashHealth)
4061
4070
  var h = c.clashHealth;
4062
4071
  if (!h || !h.node) return "";
4063
-
4064
4072
  var nodes = (h.meta && h.meta.healthyNodes) || [];
4065
4073
  var hasNodes = nodes.length > 0;
4074
+ return (
4075
+ renderProxyLine(h, nodes, userId, 0, hasNodes) +
4076
+ renderNodeList(nodes, h.node, userId, 0)
4077
+ );
4078
+ }
4066
4079
 
4080
+ /** 单个控制端通道的代理区域 */
4081
+ function renderProxyChannel(ch, userId) {
4082
+ if (!ch || !ch.node) return "";
4083
+ var nodes = (ch.meta && ch.meta.healthyNodes) || [];
4084
+ var hasNodes = nodes.length > 0;
4067
4085
  return (
4068
- renderProxyLine(h, nodes, userId, hasNodes) +
4069
- renderNodeList(nodes, h.node, userId, hasNodes)
4086
+ renderProxyLine(ch, nodes, userId, ch.port || 0, hasNodes) +
4087
+ renderNodeList(nodes, ch.node, userId, ch.port || 0)
4070
4088
  );
4071
4089
  }
4072
4090
 
4073
4091
  /** 代理状态行:当前节点 + 健康状态 + 展开按钮 */
4074
- function renderProxyLine(h, nodes, userId, hasNodes) {
4092
+ function renderProxyLine(h, nodes, userId, port, hasNodes) {
4075
4093
  var dot = h.healthy ? "#4ade80" : "#f87171";
4076
4094
  var status = h.healthy
4077
4095
  ? (h.timeTotal || 0).toFixed(1) +
@@ -4079,19 +4097,31 @@ function renderProxyLine(h, nodes, userId, hasNodes) {
4079
4097
  ((h.sizeDownload || 0) / 1024).toFixed(0) +
4080
4098
  "KB"
4081
4099
  : h.error || "异常";
4100
+ var clientTag = port
4101
+ ? h.meta && h.meta.clientName
4102
+ ? h.meta.clientName + " :" + port
4103
+ : "代理 :" + port
4104
+ : "";
4082
4105
 
4083
4106
  return styled(
4084
4107
  "div",
4085
4108
  "margin-top:6px;padding:6px 8px;background:#111;border-radius:6px;" +
4086
4109
  "display:flex;align-items:center;gap:8px;font-size:12px",
4087
4110
  styled("span", "color:" + dot + ";font-size:14px", "●") +
4111
+ (clientTag
4112
+ ? styled(
4113
+ "span",
4114
+ "color:#888;padding:0 4px;border:1px solid #333;border-radius:3px;font-size:10px",
4115
+ clientTag,
4116
+ )
4117
+ : "") +
4088
4118
  styled("span", "color:#e0e0e0;font-weight:bold", h.node) +
4089
4119
  styled("span", "color:#666", "| " + status) +
4090
4120
  (hasNodes
4091
4121
  ? tag(
4092
4122
  "button",
4093
4123
  {
4094
- onclick: "wdToggleNodes('" + esc(userId) + "')",
4124
+ onclick: "wdToggleNodes('" + esc(userId) + "'," + port + ")",
4095
4125
  style:
4096
4126
  "margin-left:auto;padding:2px 8px;font-size:11px;background:#333;" +
4097
4127
  "color:#60a5fa;border:1px solid #444;border-radius:3px;cursor:pointer",
@@ -4104,15 +4134,15 @@ function renderProxyLine(h, nodes, userId, hasNodes) {
4104
4134
  }
4105
4135
 
4106
4136
  /** 节点列表面板(默认隐藏) */
4107
- function renderNodeList(nodes, currentNode, userId, hasNodes) {
4108
- if (!hasNodes) return "";
4137
+ function renderNodeList(nodes, currentNode, userId, port) {
4138
+ if (!nodes || !nodes.length) return "";
4109
4139
 
4110
4140
  var items = "";
4111
4141
  for (var i = 0; i < nodes.length; i++) {
4112
- items += renderNodeItem(nodes[i], nodes[i] === currentNode, userId);
4142
+ items += renderNodeItem(nodes[i], nodes[i] === currentNode, userId, port);
4113
4143
  }
4114
4144
 
4115
- var id = "wdNodes-" + wdEscId(userId);
4145
+ var id = "wdNodes-" + wdEscId(userId) + "-" + port;
4116
4146
  return (
4117
4147
  '<div id="' +
4118
4148
  id +
@@ -4124,7 +4154,7 @@ function renderNodeList(nodes, currentNode, userId, hasNodes) {
4124
4154
  }
4125
4155
 
4126
4156
  /** 单个节点行 */
4127
- function renderNodeItem(name, isActive, userId) {
4157
+ function renderNodeItem(name, isActive, userId, port) {
4128
4158
  var color = isActive ? "#4ade80" : "#888";
4129
4159
  var bg = isActive ? "background:#1a3a2a;" : "";
4130
4160
  var dot = isActive ? "●" : "○";
@@ -4135,7 +4165,9 @@ function renderNodeItem(name, isActive, userId) {
4135
4165
  esc(userId) +
4136
4166
  "','" +
4137
4167
  esc(name) +
4138
- "')\" " +
4168
+ "'," +
4169
+ port +
4170
+ ')" ' +
4139
4171
  'style="margin-left:auto;padding:1px 6px;font-size:10px;background:#2563eb;' +
4140
4172
  'color:#fff;border:none;border-radius:2px;cursor:pointer">切换</button>';
4141
4173
 
@@ -4164,18 +4196,23 @@ function wdEscId(s) {
4164
4196
  return (s || "").replace(/[^a-zA-Z0-9_-]/g, "_");
4165
4197
  }
4166
4198
 
4167
- function wdToggleNodes(userId) {
4168
- var el = document.getElementById("wdNodes-" + wdEscId(userId));
4199
+ function wdToggleNodes(userId, port) {
4200
+ var el = document.getElementById(
4201
+ "wdNodes-" + wdEscId(userId) + "-" + (port || 0),
4202
+ );
4169
4203
  if (!el) return;
4170
4204
  el.style.display = el.style.display === "none" ? "block" : "none";
4171
4205
  }
4172
4206
 
4173
- function wdSwitchNode(userId, node) {
4207
+ function wdSwitchNode(userId, node, port) {
4208
+ var portLabel = port ? "(代理通道 " + port + ")" : "";
4174
4209
  if (
4175
4210
  !confirm(
4176
4211
  "确定要将 " +
4177
4212
  userId +
4178
- " 的代理切换到: " +
4213
+ " 的代理" +
4214
+ portLabel +
4215
+ "切换到: " +
4179
4216
  node +
4180
4217
  " ?\n\n切换后自动巡检会继续运行。",
4181
4218
  )
@@ -4185,7 +4222,11 @@ function wdSwitchNode(userId, node) {
4185
4222
  fetch("/api/watchdog/switch-node", {
4186
4223
  method: "POST",
4187
4224
  headers: { "Content-Type": "application/json" },
4188
- body: JSON.stringify({ userId: userId, node: node }),
4225
+ body: JSON.stringify({
4226
+ userId: userId,
4227
+ node: node,
4228
+ proxyPort: port || undefined,
4229
+ }),
4189
4230
  })
4190
4231
  .then(function (r) {
4191
4232
  return r.json();
@@ -120,6 +120,7 @@ function getOnlineClients() {
120
120
  processes,
121
121
  llmAnalysis,
122
122
  clashHealth: info.clashHealth || null,
123
+ clashHealthChannels: info.clashHealthChannels || [],
123
124
  lastHeartbeat: info.lastHeartbeat,
124
125
  registeredAt: info.registeredAt,
125
126
  online,
@@ -324,6 +325,9 @@ async function handleHeartbeat(params, body, res) {
324
325
  config: body.config || {},
325
326
  processes: body.processes || [],
326
327
  clashHealth: body.clashHealth || null,
328
+ clashHealthChannels: Array.isArray(body.clashHealthChannels)
329
+ ? body.clashHealthChannels
330
+ : [],
327
331
  });
328
332
 
329
333
  // 按 userId 返回该客户端的专属命令列表
@@ -492,16 +496,22 @@ async function handleClearLlmAnalysis(params, body, res) {
492
496
  }
493
497
 
494
498
  async function handleSwitchNode(params, body, res) {
495
- const { userId, node } = body || {};
499
+ const { userId, node, proxyPort } = body || {};
496
500
  if (!userId || !node) {
497
501
  sendJSON(res, 400, { error: "缺少 userId 或 node" });
498
502
  return;
499
503
  }
500
- // 加入 Clash 命令队列,客户端下次心跳时取走
504
+ // 加入 Clash 命令队列,客户端下次心跳时取走(proxyPort 用于路由到对应控制端)
501
505
  const queue = clashCmdQueue.get(userId) || [];
502
- queue.push({ action: "switchNode", node });
506
+ queue.push({
507
+ action: "switchNode",
508
+ node,
509
+ proxyPort: proxyPort ? Number(proxyPort) : undefined,
510
+ });
503
511
  clashCmdQueue.set(userId, queue);
504
- console.error(`[watchdog] ${userId} 节点切换已入队: → ${node}`);
512
+ console.error(
513
+ `[watchdog] ${userId} 节点切换已入队: → ${node}${proxyPort ? ` (通道 ${proxyPort})` : ""}`,
514
+ );
505
515
  sendJSON(res, 200, { ok: true, message: `将在下次心跳时切换到 ${node}` });
506
516
  }
507
517
 
@@ -12,6 +12,7 @@ import {
12
12
  getClientConfig,
13
13
  getLocalIP,
14
14
  getClashHealth,
15
+ getClashHealthChannels,
15
16
  } from "./reporter.js";
16
17
  import { ProcessManager } from "./process-manager.js";
17
18
  import {
@@ -414,7 +415,8 @@ export class WatchdogAgent {
414
415
  config: getClientConfig(),
415
416
  processes: this.processManager.getProcessesStatus(),
416
417
  llmAnalysis: this._llmResults,
417
- clashHealth: getClashHealth(), // daemon 写入的代理健康状态
418
+ clashHealth: getClashHealth(), // daemon 写入的主控制端健康状态(兼容旧版页面)
419
+ clashHealthChannels: getClashHealthChannels(), // 多控制端各通道健康状态
418
420
  };
419
421
  }
420
422
 
@@ -458,13 +460,21 @@ export class WatchdogAgent {
458
460
  }
459
461
  }
460
462
 
461
- // 处理 Clash daemon 命令(一次写入数组,daemon 轮询整批处理)
463
+ // 处理 Clash daemon 命令(按 proxyPort 分组写入对应通道,无端口走默认通道)
462
464
  if (Array.isArray(data.clashCommands) && data.clashCommands.length > 0) {
463
465
  const ipc = await import("../clash/ipc.js");
464
- ipc.writeCommand(data.clashCommands);
465
- console.error(
466
- `[watchdog] 下发 Clash 命令: ${JSON.stringify(data.clashCommands)}`,
467
- );
466
+ const byPort = new Map();
467
+ for (const cmd of data.clashCommands) {
468
+ const port = cmd && cmd.proxyPort ? Number(cmd.proxyPort) : 0;
469
+ if (!byPort.has(port)) byPort.set(port, []);
470
+ byPort.get(port).push(cmd);
471
+ }
472
+ for (const [port, cmds] of byPort) {
473
+ ipc.writeCommand(cmds, port);
474
+ console.error(
475
+ `[watchdog] 下发 Clash 命令${port ? ` (通道 ${port})` : ""}: ${JSON.stringify(cmds)}`,
476
+ );
477
+ }
468
478
  }
469
479
 
470
480
  // 服务端下发的客户端配置(远程修改 ~/.tt-help.json)
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import os from "os";
7
- import { existsSync, readFileSync } from "fs";
7
+ import { existsSync, readFileSync, readdirSync } from "fs";
8
8
  import { join } from "path";
9
9
  import { homedir, tmpdir } from "os";
10
10
 
@@ -91,3 +91,34 @@ export function getClashHealth() {
91
91
  }
92
92
  return null;
93
93
  }
94
+
95
+ /**
96
+ * 读取多控制端健康状态(daemon 多通道模式写入的 clash-health-{port}.json)
97
+ * @returns {Array<{port:number, stale:boolean, ageSeconds:number, ...health}>}
98
+ */
99
+ export function getClashHealthChannels() {
100
+ const out = [];
101
+ try {
102
+ for (const name of readdirSync(tmpdir())) {
103
+ const m = /^clash-health-(\d+)\.json$/.exec(name);
104
+ if (!m) continue;
105
+ const port = parseInt(m[1], 10);
106
+ if (!port) continue;
107
+ try {
108
+ const data = JSON.parse(readFileSync(join(tmpdir(), name), "utf-8"));
109
+ const age = Date.now() - new Date(data.lastCheck || 0).getTime();
110
+ out.push({
111
+ ...data,
112
+ port,
113
+ stale: age > 120_000,
114
+ ageSeconds: Math.round(age / 1000),
115
+ });
116
+ } catch {
117
+ /* 单个文件解析失败跳过 */
118
+ }
119
+ }
120
+ } catch {
121
+ /* 目录不可读 */
122
+ }
123
+ return out;
124
+ }