tt-help-cli-ycl 1.4.75 → 1.4.78
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/api.js +168 -0
- package/src/clash/checker.js +166 -0
- package/src/clash/daemon.js +242 -0
- package/src/clash/health-checker.js +248 -0
- package/src/clash/index.js +7 -0
- package/src/clash/ipc.js +75 -0
- package/src/cli/clash-daemon.js +18 -0
- package/src/lib/args.js +38 -0
- package/src/main.js +3 -0
- package/src/watch/public/app.js +6 -0
- package/src/watch/watchdog-server.js +5 -0
- package/src/watchdog/agent.js +151 -3
- package/src/watchdog/process-manager.js +2 -0
- package/src/watchdog/reporter.js +24 -1
package/package.json
CHANGED
package/src/clash/api.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clash Verge (mihomo) RESTful API 客户端
|
|
3
|
+
*
|
|
4
|
+
* API 参考: https://wiki.metacubex.one/en/api
|
|
5
|
+
* 认证方式: Authorization: Bearer ${secret}
|
|
6
|
+
* 依赖 Clash Verge GUI 运行时绑定的 TCP 127.0.0.1:9097
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const BASE_URL = "http://127.0.0.1:9097";
|
|
10
|
+
const SECRET = "abcdefg";
|
|
11
|
+
|
|
12
|
+
function headers() {
|
|
13
|
+
return {
|
|
14
|
+
Authorization: `Bearer ${SECRET}`,
|
|
15
|
+
"Content-Type": "application/json",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function request(path, options = {}) {
|
|
20
|
+
const url = `${BASE_URL}${path}`;
|
|
21
|
+
const res = await fetch(url, {
|
|
22
|
+
...options,
|
|
23
|
+
headers: { ...headers(), ...options.headers },
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
const body = await res.text().catch(() => "");
|
|
27
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}: ${body}`);
|
|
28
|
+
}
|
|
29
|
+
if (res.status === 204) return null;
|
|
30
|
+
const text = await res.text();
|
|
31
|
+
return text ? JSON.parse(text) : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ===== 代理相关 =====
|
|
35
|
+
|
|
36
|
+
export async function getProxies() {
|
|
37
|
+
return request("/proxies");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function getProxy(name) {
|
|
41
|
+
return request(`/proxies/${encodeURIComponent(name)}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function switchProxy(groupName, nodeName) {
|
|
45
|
+
return request(`/proxies/${encodeURIComponent(groupName)}`, {
|
|
46
|
+
method: "PUT",
|
|
47
|
+
body: JSON.stringify({ name: nodeName }),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function getGroups() {
|
|
52
|
+
const data = await getProxies();
|
|
53
|
+
const proxies = data.proxies || {};
|
|
54
|
+
const groups = [];
|
|
55
|
+
const groupTypes = new Set([
|
|
56
|
+
"Selector",
|
|
57
|
+
"URLTest",
|
|
58
|
+
"Fallback",
|
|
59
|
+
"LoadBalance",
|
|
60
|
+
]);
|
|
61
|
+
for (const [name, info] of Object.entries(proxies)) {
|
|
62
|
+
if (groupTypes.has(info.type)) {
|
|
63
|
+
groups.push({
|
|
64
|
+
name,
|
|
65
|
+
type: info.type,
|
|
66
|
+
now: info.now || "",
|
|
67
|
+
all: info.all || [],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return groups;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function getNodes() {
|
|
75
|
+
const data = await getProxies();
|
|
76
|
+
const proxies = data.proxies || {};
|
|
77
|
+
const nodes = [];
|
|
78
|
+
const groupTypes = new Set([
|
|
79
|
+
"Selector",
|
|
80
|
+
"URLTest",
|
|
81
|
+
"Fallback",
|
|
82
|
+
"LoadBalance",
|
|
83
|
+
]);
|
|
84
|
+
for (const [name, info] of Object.entries(proxies)) {
|
|
85
|
+
if (!groupTypes.has(info.type)) {
|
|
86
|
+
nodes.push({
|
|
87
|
+
name,
|
|
88
|
+
type: info.type,
|
|
89
|
+
alive: info.alive ?? false,
|
|
90
|
+
delay:
|
|
91
|
+
info.history?.length > 0
|
|
92
|
+
? info.history[info.history.length - 1].delay
|
|
93
|
+
: null,
|
|
94
|
+
udp: info.udp ?? false,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return nodes;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function getConfigs() {
|
|
102
|
+
return request("/configs");
|
|
103
|
+
}
|
|
104
|
+
export async function patchConfigs(patch) {
|
|
105
|
+
return request("/configs", { method: "PATCH", body: JSON.stringify(patch) });
|
|
106
|
+
}
|
|
107
|
+
export async function reloadConfigs() {
|
|
108
|
+
return request("/configs?force=true", { method: "PUT" });
|
|
109
|
+
}
|
|
110
|
+
export async function getVersion() {
|
|
111
|
+
return request("/version");
|
|
112
|
+
}
|
|
113
|
+
export async function getMemory() {
|
|
114
|
+
return request("/memory");
|
|
115
|
+
}
|
|
116
|
+
export async function getConnections() {
|
|
117
|
+
return request("/connections");
|
|
118
|
+
}
|
|
119
|
+
export async function closeConnection(id) {
|
|
120
|
+
return request(`/connections/${id}`, { method: "DELETE" });
|
|
121
|
+
}
|
|
122
|
+
export async function closeAllConnections() {
|
|
123
|
+
return request("/connections", { method: "DELETE" });
|
|
124
|
+
}
|
|
125
|
+
export async function getRules() {
|
|
126
|
+
return request("/rules");
|
|
127
|
+
}
|
|
128
|
+
export async function getProxyProviders() {
|
|
129
|
+
return request("/providers/proxies");
|
|
130
|
+
}
|
|
131
|
+
export async function updateProxyProvider(name) {
|
|
132
|
+
return request(`/providers/proxies/${encodeURIComponent(name)}`, {
|
|
133
|
+
method: "PUT",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
export async function healthCheckProvider(name) {
|
|
137
|
+
return request(`/providers/proxies/${encodeURIComponent(name)}/healthcheck`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function getCurrentNode(groupName) {
|
|
141
|
+
const proxy = await getProxy(groupName);
|
|
142
|
+
return proxy.now || null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function listGroupNodes(groupName) {
|
|
146
|
+
const proxy = await getProxy(groupName);
|
|
147
|
+
return proxy.all || [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function getAliveNodes() {
|
|
151
|
+
const nodes = await getNodes();
|
|
152
|
+
return nodes
|
|
153
|
+
.filter((n) => n.alive && n.delay !== null && n.delay > 0)
|
|
154
|
+
.sort((a, b) => (a.delay ?? Infinity) - (b.delay ?? Infinity));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function switchToFastest(groupName) {
|
|
158
|
+
const alive = await getAliveNodes();
|
|
159
|
+
if (alive.length === 0) throw new Error("没有存活的节点");
|
|
160
|
+
const groupNodes = await listGroupNodes(groupName);
|
|
161
|
+
for (const node of alive) {
|
|
162
|
+
if (groupNodes.includes(node.name)) {
|
|
163
|
+
await switchProxy(groupName, node.name);
|
|
164
|
+
return node;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
throw new Error("存活节点都不在目标组中");
|
|
168
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 节点健康检测 — 纯函数模块
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* 1. 检测 Clash Verge 进程存活(跨平台 mac/win)
|
|
6
|
+
* 2. 检测当前代理节点对目标 URL 的健康状态
|
|
7
|
+
* 3. 健康节点列表持久化
|
|
8
|
+
* 4. 故障切换候选节点选取
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from "child_process";
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { tmpdir } from "os";
|
|
14
|
+
|
|
15
|
+
const IS_WIN = process.platform === "win32";
|
|
16
|
+
const HEALTHY_FILE = join(tmpdir(), "clash-healthy-nodes.json");
|
|
17
|
+
|
|
18
|
+
export const CONFIG = {
|
|
19
|
+
testUrl: "https://www.tiktok.com/explore",
|
|
20
|
+
proxyUrl: "http://127.0.0.1:7897",
|
|
21
|
+
apiPort: 9097,
|
|
22
|
+
groupName: "GLOBAL",
|
|
23
|
+
curlTimeout: 12,
|
|
24
|
+
minPageSize: 50 * 1024,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// ---- 进程检测 ----
|
|
28
|
+
|
|
29
|
+
export function checkClashAlive() {
|
|
30
|
+
try {
|
|
31
|
+
if (IS_WIN) {
|
|
32
|
+
const r = spawnSync(
|
|
33
|
+
"tasklist",
|
|
34
|
+
["/FI", "IMAGENAME eq verge-mihomo.exe", "/NH"],
|
|
35
|
+
{
|
|
36
|
+
encoding: "utf-8",
|
|
37
|
+
timeout: 5000,
|
|
38
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
if (r.stdout?.includes("verge-mihomo.exe")) return true;
|
|
42
|
+
} else {
|
|
43
|
+
const r = spawnSync("pgrep", ["-x", "verge-mihomo"], {
|
|
44
|
+
encoding: "utf-8",
|
|
45
|
+
timeout: 5000,
|
|
46
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
47
|
+
});
|
|
48
|
+
if (r.status === 0 && r.stdout.trim()) return true;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
/* fallthrough */
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const exe = IS_WIN ? "curl.exe" : "curl";
|
|
56
|
+
const r = spawnSync(
|
|
57
|
+
exe,
|
|
58
|
+
["-s", "--max-time", "3", `http://127.0.0.1:${CONFIG.apiPort}/version`],
|
|
59
|
+
{
|
|
60
|
+
encoding: "utf-8",
|
|
61
|
+
timeout: 5000,
|
|
62
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
return r.stdout.includes('"version"');
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- 节点健康检测 ----
|
|
72
|
+
|
|
73
|
+
export function checkNodeHealth(url = CONFIG.testUrl) {
|
|
74
|
+
const args = [
|
|
75
|
+
"-s",
|
|
76
|
+
"-w",
|
|
77
|
+
"\n__CURL_META__%{http_code}|%{time_total}|%{size_download}",
|
|
78
|
+
"--proxy",
|
|
79
|
+
CONFIG.proxyUrl,
|
|
80
|
+
"--max-time",
|
|
81
|
+
String(CONFIG.curlTimeout),
|
|
82
|
+
"-L",
|
|
83
|
+
"-A",
|
|
84
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
85
|
+
url,
|
|
86
|
+
];
|
|
87
|
+
const result = spawnSync("curl", args, {
|
|
88
|
+
encoding: "utf-8",
|
|
89
|
+
timeout: (CONFIG.curlTimeout + 5) * 1000,
|
|
90
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
91
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const stdout = result.stdout || "";
|
|
95
|
+
const idx = stdout.lastIndexOf("__CURL_META__");
|
|
96
|
+
let body = "",
|
|
97
|
+
metaLine = "";
|
|
98
|
+
if (idx >= 0) {
|
|
99
|
+
body = stdout.substring(0, idx).trimEnd();
|
|
100
|
+
metaLine = stdout.substring(idx + 13).trim();
|
|
101
|
+
}
|
|
102
|
+
const parts = metaLine.split("|");
|
|
103
|
+
const httpCode = parseInt(parts[0]) || 0;
|
|
104
|
+
const timeTotal = parseFloat(parts[1]) || 0;
|
|
105
|
+
const sizeDownload = parseInt(parts[2]) || 0;
|
|
106
|
+
|
|
107
|
+
let title = "";
|
|
108
|
+
const m = body.match(/<title>([^<]*)<\/title>/i);
|
|
109
|
+
if (m) title = m[1].trim();
|
|
110
|
+
|
|
111
|
+
let error = null;
|
|
112
|
+
if (result.error) error = result.error.message;
|
|
113
|
+
else if (result.status !== 0) error = `curl exit ${result.status}`;
|
|
114
|
+
|
|
115
|
+
const healthy =
|
|
116
|
+
!error &&
|
|
117
|
+
httpCode === 200 &&
|
|
118
|
+
sizeDownload >= CONFIG.minPageSize &&
|
|
119
|
+
title.includes("TikTok") &&
|
|
120
|
+
!title.startsWith("About |") &&
|
|
121
|
+
!body.includes("SlardarWAF") &&
|
|
122
|
+
!body.includes("_wafchallengeid");
|
|
123
|
+
|
|
124
|
+
return { healthy, httpCode, timeTotal, sizeDownload, title, error };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- 健康节点列表持久化 ----
|
|
128
|
+
|
|
129
|
+
export function loadHealthyList() {
|
|
130
|
+
try {
|
|
131
|
+
if (existsSync(HEALTHY_FILE)) {
|
|
132
|
+
const d = JSON.parse(readFileSync(HEALTHY_FILE, "utf-8"));
|
|
133
|
+
return {
|
|
134
|
+
updated: d.updated || "",
|
|
135
|
+
nodes: Array.isArray(d.nodes) ? d.nodes : [],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error(`[checker] 读取健康列表失败: ${e.message}`);
|
|
140
|
+
}
|
|
141
|
+
return { updated: "", nodes: [] };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function saveHealthyList(nodes) {
|
|
145
|
+
try {
|
|
146
|
+
writeFileSync(
|
|
147
|
+
HEALTHY_FILE,
|
|
148
|
+
JSON.stringify({ updated: new Date().toISOString(), nodes }, null, 2),
|
|
149
|
+
"utf-8",
|
|
150
|
+
);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.error(`[checker] 保存健康列表失败: ${e.message}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- 故障切换候选选取 ----
|
|
157
|
+
|
|
158
|
+
export function pickFailoverNode(currentNode, healthyNodes) {
|
|
159
|
+
if (!healthyNodes?.length) return null;
|
|
160
|
+
const candidates = healthyNodes.filter((n) => n !== currentNode);
|
|
161
|
+
if (!candidates.length) {
|
|
162
|
+
console.error(`[checker] 无可用候选节点(共${healthyNodes.length}个)`);
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
return candidates[Math.floor(Math.random() * candidates.length)];
|
|
166
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClashDaemon — 代理节点健康守护
|
|
3
|
+
*
|
|
4
|
+
* 定时巡检当前节点 TikTok 可达性,异常时自动故障切换。
|
|
5
|
+
* 定期执行全量基准测速刷新健康节点列表。
|
|
6
|
+
* 通过 IPC 文件向 watchdog 上报状态。
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "child_process";
|
|
9
|
+
import { join, dirname } from "path";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
11
|
+
import { readFileSync, existsSync } from "fs";
|
|
12
|
+
import * as api from "./api.js";
|
|
13
|
+
import * as checker from "./checker.js";
|
|
14
|
+
import * as ipc from "./ipc.js";
|
|
15
|
+
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
function findBenchmarkScript() {
|
|
19
|
+
const candidates = [
|
|
20
|
+
join(
|
|
21
|
+
__dirname,
|
|
22
|
+
"..",
|
|
23
|
+
"..",
|
|
24
|
+
"..",
|
|
25
|
+
"clash-verge-explore",
|
|
26
|
+
"speedtest-tiktok.mjs",
|
|
27
|
+
),
|
|
28
|
+
join(__dirname, "speedtest-tiktok.mjs"),
|
|
29
|
+
];
|
|
30
|
+
for (const p of candidates) if (existsSync(p)) return p;
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class ClashDaemon {
|
|
35
|
+
constructor(opts = {}) {
|
|
36
|
+
this.intervalMs = (opts.intervalSec || 30) * 1000;
|
|
37
|
+
this.benchmarkMs = (opts.benchmarkMin || 40) * 60 * 1000;
|
|
38
|
+
this.dryRun = !!opts.dryRun;
|
|
39
|
+
this.benchmarkScript = findBenchmarkScript();
|
|
40
|
+
this._running = false;
|
|
41
|
+
this._timers = [];
|
|
42
|
+
this._benchmarkRunning = false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async start() {
|
|
46
|
+
if (this._running) return;
|
|
47
|
+
this._running = true;
|
|
48
|
+
this._logBanner();
|
|
49
|
+
this._setupSignals();
|
|
50
|
+
|
|
51
|
+
const list = checker.loadHealthyList();
|
|
52
|
+
const ageMin = list.updated
|
|
53
|
+
? Math.round((Date.now() - new Date(list.updated).getTime()) / 60000)
|
|
54
|
+
: Infinity;
|
|
55
|
+
const isStale = !list.nodes.length || ageMin >= this.benchmarkMs / 60000;
|
|
56
|
+
|
|
57
|
+
if (list.nodes.length) {
|
|
58
|
+
this._log(
|
|
59
|
+
`已加载 ${list.nodes.length} 个健康节点${isStale ? ` (已过期 ${ageMin}min,立即刷新)` : ""}`,
|
|
60
|
+
);
|
|
61
|
+
} else {
|
|
62
|
+
this._log("健康列表为空,将立即执行基准测速");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await this._healthCheck();
|
|
66
|
+
this._timers.push(setInterval(() => this._healthCheck(), this.intervalMs));
|
|
67
|
+
|
|
68
|
+
const initial = isStale ? 5000 : this.benchmarkMs;
|
|
69
|
+
setTimeout(() => {
|
|
70
|
+
this._runBenchmark();
|
|
71
|
+
this._timers.push(
|
|
72
|
+
setInterval(() => this._runBenchmark(), this.benchmarkMs),
|
|
73
|
+
);
|
|
74
|
+
}, initial);
|
|
75
|
+
|
|
76
|
+
this._log("守护进程已启动");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async stop() {
|
|
80
|
+
this._running = false;
|
|
81
|
+
this._timers.forEach(clearInterval);
|
|
82
|
+
this._timers = [];
|
|
83
|
+
this._log("守护进程已停止");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- 巡检 ----
|
|
87
|
+
|
|
88
|
+
async _healthCheck() {
|
|
89
|
+
if (!this._running || this._benchmarkRunning) return;
|
|
90
|
+
try {
|
|
91
|
+
if (!checker.checkClashAlive()) {
|
|
92
|
+
ipc.writeHealth({ node: "?", healthy: false, error: "Clash 未运行" });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const node = await api
|
|
96
|
+
.getCurrentNode(checker.CONFIG.groupName)
|
|
97
|
+
.catch(() => "?");
|
|
98
|
+
const h = checker.checkNodeHealth();
|
|
99
|
+
ipc.writeHealth({
|
|
100
|
+
node,
|
|
101
|
+
healthy: h.healthy,
|
|
102
|
+
httpCode: h.httpCode,
|
|
103
|
+
timeTotal: h.timeTotal,
|
|
104
|
+
sizeDownload: h.sizeDownload,
|
|
105
|
+
title: h.title,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (h.healthy) {
|
|
109
|
+
if (Math.random() < 0.17)
|
|
110
|
+
this._log(
|
|
111
|
+
`✓ ${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB`,
|
|
112
|
+
);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const reason =
|
|
117
|
+
h.error || `HTTP ${h.httpCode} ${(h.sizeDownload / 1024).toFixed(0)}KB`;
|
|
118
|
+
const { nodes } = checker.loadHealthyList();
|
|
119
|
+
const target = checker.pickFailoverNode(node, nodes);
|
|
120
|
+
if (!target) {
|
|
121
|
+
this._log(`✗ ${reason} — 无法切换`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (this.dryRun) {
|
|
125
|
+
this._log(`[DRY-RUN] 应切换到 ${target}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await api.switchProxy(checker.CONFIG.groupName, target);
|
|
130
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
131
|
+
const h2 = checker.checkNodeHealth();
|
|
132
|
+
this._log(
|
|
133
|
+
`⚠ ${node} → ${target} (${h2.timeTotal.toFixed(1)}s ${(h2.sizeDownload / 1024).toFixed(0)}KB)`,
|
|
134
|
+
);
|
|
135
|
+
ipc.writeHealth({
|
|
136
|
+
node: target,
|
|
137
|
+
healthy: h2.healthy,
|
|
138
|
+
httpCode: h2.httpCode,
|
|
139
|
+
timeTotal: h2.timeTotal,
|
|
140
|
+
sizeDownload: h2.sizeDownload,
|
|
141
|
+
title: h2.title,
|
|
142
|
+
lastSwitch: `${node} → ${target}`,
|
|
143
|
+
});
|
|
144
|
+
} catch (e) {
|
|
145
|
+
this._log(`巡检异常: ${e.message}`);
|
|
146
|
+
ipc.writeHealth({ node: "?", healthy: false, error: e.message });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- 基准 ----
|
|
151
|
+
|
|
152
|
+
_runBenchmark() {
|
|
153
|
+
if (!this.benchmarkScript || this._benchmarkRunning) return;
|
|
154
|
+
this._benchmarkRunning = true;
|
|
155
|
+
this._log("=== 基准测速开始 ===");
|
|
156
|
+
const start = Date.now();
|
|
157
|
+
const child = spawn(
|
|
158
|
+
process.execPath,
|
|
159
|
+
[this.benchmarkScript, "--timeout", "12"],
|
|
160
|
+
{
|
|
161
|
+
cwd: dirname(this.benchmarkScript),
|
|
162
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
let stderr = "";
|
|
166
|
+
child.stderr.on("data", (d) => {
|
|
167
|
+
stderr += d.toString();
|
|
168
|
+
});
|
|
169
|
+
child.on("close", (code) => {
|
|
170
|
+
this._benchmarkRunning = false;
|
|
171
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(0);
|
|
172
|
+
if (code === 0) {
|
|
173
|
+
try {
|
|
174
|
+
const rp = join(
|
|
175
|
+
dirname(this.benchmarkScript),
|
|
176
|
+
"speedtest-result.json",
|
|
177
|
+
);
|
|
178
|
+
if (existsSync(rp)) {
|
|
179
|
+
const nodes =
|
|
180
|
+
JSON.parse(readFileSync(rp, "utf-8")).realNodes?.map(
|
|
181
|
+
(r) => r.node,
|
|
182
|
+
) || [];
|
|
183
|
+
if (nodes.length) {
|
|
184
|
+
checker.saveHealthyList(nodes);
|
|
185
|
+
this._log(
|
|
186
|
+
`=== 基准测速完成 (${elapsed}s): ${nodes.length} 个健康节点 ===`,
|
|
187
|
+
);
|
|
188
|
+
} else this._log(`=== 基准测速完成: 0 个健康节点 ===`);
|
|
189
|
+
}
|
|
190
|
+
} catch (e) {
|
|
191
|
+
this._log(`解析结果失败: ${e.message}`);
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
this._log(`=== 基准测速失败 (exit ${code}, ${elapsed}s) ===`);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
child.on("error", (err) => {
|
|
198
|
+
this._benchmarkRunning = false;
|
|
199
|
+
this._log(`基准测速异常: ${err.message}`);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---- helpers ----
|
|
204
|
+
|
|
205
|
+
_log(msg) {
|
|
206
|
+
console.error(
|
|
207
|
+
`[daemon ${new Date().toLocaleTimeString("zh-CN", { hour12: false })}] ${msg}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
_logBanner() {
|
|
212
|
+
console.error("═".repeat(50));
|
|
213
|
+
console.error(" Clash Verge 节点健康守护");
|
|
214
|
+
console.error(
|
|
215
|
+
` 检测: ${checker.CONFIG.testUrl} | 巡检: ${this.intervalMs / 1000}s | 基准: ${this.benchmarkMs / 60000}min`,
|
|
216
|
+
);
|
|
217
|
+
console.error(
|
|
218
|
+
` 基准脚本: ${this.benchmarkScript || "(不可用)"} | 模式: ${this.dryRun ? "DRY-RUN" : "正常"}`,
|
|
219
|
+
);
|
|
220
|
+
console.error("═".repeat(50));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
_setupSignals() {
|
|
224
|
+
const s = (sig) => {
|
|
225
|
+
this._log(`收到 ${sig},退出`);
|
|
226
|
+
this.stop();
|
|
227
|
+
process.exit(0);
|
|
228
|
+
};
|
|
229
|
+
process.on("SIGINT", () => s("SIGINT"));
|
|
230
|
+
process.on("SIGTERM", () => s("SIGTERM"));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 直接运行时(watchdog spawn 或 node daemon.js),自动启动
|
|
235
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
236
|
+
const args = process.argv.slice(2);
|
|
237
|
+
new ClashDaemon({
|
|
238
|
+
intervalSec: parseInt(args[args.indexOf("--interval") + 1]) || undefined,
|
|
239
|
+
benchmarkMin: parseInt(args[args.indexOf("--benchmark") + 1]) || undefined,
|
|
240
|
+
dryRun: args.includes("--dry-run"),
|
|
241
|
+
}).start();
|
|
242
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 节点健康检测模块
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* 1. 检测 Clash Verge 进程是否存活
|
|
6
|
+
* 2. 检测当前节点对目标 URL 是否健康
|
|
7
|
+
* 3. 故障切换:从健康节点列表中随机选取并切换
|
|
8
|
+
* 4. 健康节点列表持久化
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from "child_process";
|
|
12
|
+
import * as api from "./api.js";
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
import { tmpdir } from "os";
|
|
16
|
+
|
|
17
|
+
const HEALTHY_FILE = join(tmpdir(), "clash-healthy-nodes.json");
|
|
18
|
+
|
|
19
|
+
// ====== 可配置参数 ======
|
|
20
|
+
export const CONFIG = {
|
|
21
|
+
testUrl: "https://www.tiktok.com/explore",
|
|
22
|
+
proxyUrl: "http://127.0.0.1:7897",
|
|
23
|
+
apiPort: 9097,
|
|
24
|
+
groupName: "GLOBAL",
|
|
25
|
+
curlTimeout: 12,
|
|
26
|
+
minPageSize: 50 * 1024,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ====== 进程检测(跨平台 mac/win) ======
|
|
30
|
+
|
|
31
|
+
const IS_WIN = process.platform === "win32";
|
|
32
|
+
|
|
33
|
+
export function checkClashAlive() {
|
|
34
|
+
try {
|
|
35
|
+
if (IS_WIN) {
|
|
36
|
+
const result = spawnSync(
|
|
37
|
+
"tasklist",
|
|
38
|
+
["/FI", "IMAGENAME eq verge-mihomo.exe", "/NH"],
|
|
39
|
+
{
|
|
40
|
+
encoding: "utf-8",
|
|
41
|
+
timeout: 5000,
|
|
42
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
if (result.stdout && result.stdout.includes("verge-mihomo.exe"))
|
|
46
|
+
return true;
|
|
47
|
+
} else {
|
|
48
|
+
const result = spawnSync("pgrep", ["-x", "verge-mihomo"], {
|
|
49
|
+
encoding: "utf-8",
|
|
50
|
+
timeout: 5000,
|
|
51
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
52
|
+
});
|
|
53
|
+
if (result.status === 0 && result.stdout.trim()) return true;
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
/* fallthrough */
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const curlExe = IS_WIN ? "curl.exe" : "curl";
|
|
61
|
+
const result = spawnSync(
|
|
62
|
+
curlExe,
|
|
63
|
+
["-s", "--max-time", "3", `http://127.0.0.1:${CONFIG.apiPort}/version`],
|
|
64
|
+
{
|
|
65
|
+
encoding: "utf-8",
|
|
66
|
+
timeout: 5000,
|
|
67
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
return result.stdout.includes('"version"');
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ====== 节点健康检测 ======
|
|
77
|
+
|
|
78
|
+
export function checkNodeHealth(url = CONFIG.testUrl) {
|
|
79
|
+
const args = [
|
|
80
|
+
"-s",
|
|
81
|
+
"-w",
|
|
82
|
+
"\n__CURL_META__%{http_code}|%{time_total}|%{size_download}",
|
|
83
|
+
"--proxy",
|
|
84
|
+
CONFIG.proxyUrl,
|
|
85
|
+
"--max-time",
|
|
86
|
+
String(CONFIG.curlTimeout),
|
|
87
|
+
"-L",
|
|
88
|
+
"-A",
|
|
89
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
90
|
+
url,
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const result = spawnSync("curl", args, {
|
|
94
|
+
encoding: "utf-8",
|
|
95
|
+
timeout: (CONFIG.curlTimeout + 5) * 1000,
|
|
96
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
97
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const stdout = result.stdout || "";
|
|
101
|
+
const sentinel = "__CURL_META__";
|
|
102
|
+
const idx = stdout.lastIndexOf(sentinel);
|
|
103
|
+
|
|
104
|
+
let body = "",
|
|
105
|
+
metaLine = "";
|
|
106
|
+
if (idx >= 0) {
|
|
107
|
+
body = stdout.substring(0, idx).trimEnd();
|
|
108
|
+
metaLine = stdout.substring(idx + sentinel.length).trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const parts = metaLine.split("|");
|
|
112
|
+
const httpCode = parseInt(parts[0]) || 0;
|
|
113
|
+
const timeTotal = parseFloat(parts[1]) || 0;
|
|
114
|
+
const sizeDownload = parseInt(parts[2]) || 0;
|
|
115
|
+
|
|
116
|
+
let title = "";
|
|
117
|
+
const titleMatch = body.match(/<title>([^<]*)<\/title>/i);
|
|
118
|
+
if (titleMatch) title = titleMatch[1].trim();
|
|
119
|
+
|
|
120
|
+
let error = null;
|
|
121
|
+
if (result.error) {
|
|
122
|
+
error = result.error.message || "spawn error";
|
|
123
|
+
} else if (result.status !== 0) {
|
|
124
|
+
error = `curl exit ${result.status}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const healthy =
|
|
128
|
+
error === null &&
|
|
129
|
+
httpCode === 200 &&
|
|
130
|
+
sizeDownload >= CONFIG.minPageSize &&
|
|
131
|
+
title.includes("TikTok") &&
|
|
132
|
+
!title.startsWith("About |") &&
|
|
133
|
+
!body.includes("SlardarWAF") &&
|
|
134
|
+
!body.includes("_wafchallengeid");
|
|
135
|
+
|
|
136
|
+
return { healthy, httpCode, timeTotal, sizeDownload, title, error };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ====== 健康节点列表持久化 ======
|
|
140
|
+
|
|
141
|
+
export function loadHealthyList() {
|
|
142
|
+
try {
|
|
143
|
+
if (existsSync(HEALTHY_FILE)) {
|
|
144
|
+
const data = JSON.parse(readFileSync(HEALTHY_FILE, "utf-8"));
|
|
145
|
+
return {
|
|
146
|
+
updated: data.updated || "",
|
|
147
|
+
nodes: Array.isArray(data.nodes) ? data.nodes : [],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
} catch (e) {
|
|
151
|
+
console.error(`[health] 读取健康列表失败: ${e.message}`);
|
|
152
|
+
}
|
|
153
|
+
return { updated: "", nodes: [] };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function saveHealthyList(nodes) {
|
|
157
|
+
try {
|
|
158
|
+
const data = { updated: new Date().toISOString(), nodes };
|
|
159
|
+
writeFileSync(HEALTHY_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
160
|
+
} catch (e) {
|
|
161
|
+
console.error(`[health] 保存健康列表失败: ${e.message}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ====== 故障切换 ======
|
|
166
|
+
|
|
167
|
+
export function pickFailoverNode(currentNode, healthyNodes) {
|
|
168
|
+
if (!healthyNodes || healthyNodes.length === 0) return null;
|
|
169
|
+
const candidates = healthyNodes.filter((n) => n !== currentNode);
|
|
170
|
+
if (candidates.length === 0) {
|
|
171
|
+
console.error(
|
|
172
|
+
`[health] 健康列表中无可用候选节点(共${healthyNodes.length}个),保持在当前节点`,
|
|
173
|
+
);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const idx = Math.floor(Math.random() * candidates.length);
|
|
177
|
+
return candidates[idx];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function switchToNode(nodeName) {
|
|
181
|
+
try {
|
|
182
|
+
await api.switchProxy(CONFIG.groupName, nodeName);
|
|
183
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
184
|
+
const current = await api.getCurrentNode(CONFIG.groupName);
|
|
185
|
+
if (current !== nodeName) {
|
|
186
|
+
console.error(`[health] 切换验证失败: 期望 ${nodeName}, 实际 ${current}`);
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
} catch (e) {
|
|
191
|
+
console.error(`[health] 切换节点失败: ${e.message}`);
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ====== 便捷入口 ======
|
|
197
|
+
|
|
198
|
+
export async function runHealthCheck() {
|
|
199
|
+
if (!checkClashAlive()) {
|
|
200
|
+
return { action: "skipped", detail: "Clash Verge 未运行" };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const health = checkNodeHealth();
|
|
204
|
+
if (health.healthy) {
|
|
205
|
+
return {
|
|
206
|
+
action: "ok",
|
|
207
|
+
detail: `${health.timeTotal.toFixed(1)}s ${(health.sizeDownload / 1024).toFixed(0)}KB`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const reason = health.error
|
|
212
|
+
? `错误: ${health.error}`
|
|
213
|
+
: `HTTP ${health.httpCode} ${(health.sizeDownload / 1024).toFixed(0)}KB "${health.title}"`;
|
|
214
|
+
|
|
215
|
+
let currentNode = "";
|
|
216
|
+
try {
|
|
217
|
+
currentNode = await api.getCurrentNode(CONFIG.groupName);
|
|
218
|
+
} catch (e) {
|
|
219
|
+
return { action: "failed", detail: `无法获取当前节点: ${e.message}` };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const { nodes: healthyNodes } = loadHealthyList();
|
|
223
|
+
if (healthyNodes.length === 0) {
|
|
224
|
+
return { action: "failed", detail: `${reason} — 健康列表为空,无法切换` };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const target = pickFailoverNode(currentNode, healthyNodes);
|
|
228
|
+
if (!target) {
|
|
229
|
+
return { action: "failed", detail: `${reason} — 无可用候选节点` };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const switched = await switchToNode(target);
|
|
233
|
+
if (!switched) {
|
|
234
|
+
return { action: "failed", detail: `${reason} → 切换到 ${target} 失败` };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const newHealth = checkNodeHealth();
|
|
238
|
+
if (newHealth.healthy) {
|
|
239
|
+
return {
|
|
240
|
+
action: "switched",
|
|
241
|
+
detail: `${currentNode} → ${target} (${newHealth.timeTotal.toFixed(1)}s ${(newHealth.sizeDownload / 1024).toFixed(0)}KB)`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
action: "switched",
|
|
246
|
+
detail: `${currentNode} → ${target} (切换后仍异常: ${newHealth.httpCode} ${(newHealth.sizeDownload / 1024).toFixed(0)}KB)`,
|
|
247
|
+
};
|
|
248
|
+
}
|
package/src/clash/ipc.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件 IPC 模块
|
|
3
|
+
*
|
|
4
|
+
* 与 watchdog 进程间通信,通过 tmpdir 下的 JSON 文件。
|
|
5
|
+
*
|
|
6
|
+
* clash-health.json — daemon → watchdog(代理健康状态)
|
|
7
|
+
* clash-cmd.json — watchdog → daemon(远程命令,预留)
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { tmpdir } from "os";
|
|
12
|
+
|
|
13
|
+
const HEALTH_FILE = join(tmpdir(), "clash-health.json");
|
|
14
|
+
const CMD_FILE = join(tmpdir(), "clash-cmd.json");
|
|
15
|
+
|
|
16
|
+
// ---- health(daemon → watchdog) ----
|
|
17
|
+
|
|
18
|
+
export function writeHealth(state) {
|
|
19
|
+
try {
|
|
20
|
+
writeFileSync(
|
|
21
|
+
HEALTH_FILE,
|
|
22
|
+
JSON.stringify(
|
|
23
|
+
{
|
|
24
|
+
...state,
|
|
25
|
+
lastCheck: new Date().toISOString(),
|
|
26
|
+
},
|
|
27
|
+
null,
|
|
28
|
+
2,
|
|
29
|
+
),
|
|
30
|
+
"utf-8",
|
|
31
|
+
);
|
|
32
|
+
} catch {
|
|
33
|
+
/* 非关键路径 */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readHealth() {
|
|
38
|
+
try {
|
|
39
|
+
if (!existsSync(HEALTH_FILE)) return null;
|
|
40
|
+
const data = JSON.parse(readFileSync(HEALTH_FILE, "utf-8"));
|
|
41
|
+
const age = Date.now() - new Date(data.lastCheck || 0).getTime();
|
|
42
|
+
return {
|
|
43
|
+
...data,
|
|
44
|
+
stale: age > 120_000,
|
|
45
|
+
ageSeconds: Math.round(age / 1000),
|
|
46
|
+
};
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---- command(watchdog → daemon,预留) ----
|
|
53
|
+
|
|
54
|
+
export function writeCommand(cmd) {
|
|
55
|
+
try {
|
|
56
|
+
writeFileSync(CMD_FILE, JSON.stringify(cmd, null, 2), "utf-8");
|
|
57
|
+
} catch {
|
|
58
|
+
/* 非关键路径 */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function readCommand() {
|
|
63
|
+
try {
|
|
64
|
+
if (!existsSync(CMD_FILE)) return null;
|
|
65
|
+
return JSON.parse(readFileSync(CMD_FILE, "utf-8"));
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function clearCommand() {
|
|
72
|
+
try {
|
|
73
|
+
if (existsSync(CMD_FILE)) writeFileSync(CMD_FILE, "{}", "utf-8");
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* clash-daemon CLI 命令入口
|
|
3
|
+
*
|
|
4
|
+
* 用法: tt-help clash-daemon [--interval 30] [--benchmark 40] [--dry-run]
|
|
5
|
+
*/
|
|
6
|
+
import { ClashDaemon } from "../clash/daemon.js";
|
|
7
|
+
|
|
8
|
+
export async function handleClashDaemon(options) {
|
|
9
|
+
const d = new ClashDaemon({
|
|
10
|
+
intervalSec: parseInt(options.interval) || undefined,
|
|
11
|
+
benchmarkMin: parseInt(options.benchmark) || undefined,
|
|
12
|
+
dryRun:
|
|
13
|
+
options["dry-run"] === "" ||
|
|
14
|
+
options["dry-run"] === true ||
|
|
15
|
+
process.argv.includes("--dry-run"),
|
|
16
|
+
});
|
|
17
|
+
await d.start();
|
|
18
|
+
}
|
package/src/lib/args.js
CHANGED
|
@@ -655,6 +655,40 @@ function parseWatchdogArgs(args) {
|
|
|
655
655
|
};
|
|
656
656
|
}
|
|
657
657
|
|
|
658
|
+
function parseClashDaemonArgs(args) {
|
|
659
|
+
let interval = 30;
|
|
660
|
+
let benchmark = 40;
|
|
661
|
+
let dryRun = false;
|
|
662
|
+
|
|
663
|
+
for (let i = 0; i < args.length; i++) {
|
|
664
|
+
const arg = args[i];
|
|
665
|
+
if (arg === "--interval") {
|
|
666
|
+
interval = parseInt(args[++i]) || 30;
|
|
667
|
+
} else if (arg === "--benchmark") {
|
|
668
|
+
benchmark = parseInt(args[++i]) || 40;
|
|
669
|
+
} else if (arg === "--dry-run") {
|
|
670
|
+
dryRun = true;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return {
|
|
675
|
+
subcommand: "clash-daemon",
|
|
676
|
+
interval,
|
|
677
|
+
benchmark,
|
|
678
|
+
"dry-run": dryRun,
|
|
679
|
+
urls: [],
|
|
680
|
+
outputFormat: "json",
|
|
681
|
+
exploreCount: 0,
|
|
682
|
+
showConfig: false,
|
|
683
|
+
showHelp: false,
|
|
684
|
+
customProxy: null,
|
|
685
|
+
configAction: null,
|
|
686
|
+
configValue: null,
|
|
687
|
+
pipeMode: false,
|
|
688
|
+
filterStr: null,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
658
692
|
function parseAttachArgs(args) {
|
|
659
693
|
let parallel = 1;
|
|
660
694
|
let interval = 10;
|
|
@@ -1362,6 +1396,10 @@ export function parseArgs() {
|
|
|
1362
1396
|
return parseWatchdogArgs(args.slice(1));
|
|
1363
1397
|
}
|
|
1364
1398
|
|
|
1399
|
+
if (args.length > 0 && args[0] === "clash-daemon") {
|
|
1400
|
+
return parseClashDaemonArgs(args.slice(1));
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1365
1403
|
if (args.length > 0 && args[0] === "precheck") {
|
|
1366
1404
|
return parsePrecheckArgs(args.slice(1));
|
|
1367
1405
|
}
|
package/src/main.js
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { handleRawSync } from "./cli/raw-sync.js";
|
|
24
24
|
import { handleSearch } from "./cli/search.js";
|
|
25
25
|
import { handleWatchdog } from "./cli/watchdog.js";
|
|
26
|
+
import { handleClashDaemon } from "./cli/clash-daemon.js";
|
|
26
27
|
|
|
27
28
|
async function main() {
|
|
28
29
|
const parsed = parseArgs();
|
|
@@ -68,6 +69,8 @@ async function main() {
|
|
|
68
69
|
return handleSearch(parsed);
|
|
69
70
|
case "watchdog":
|
|
70
71
|
return handleWatchdog(parsed);
|
|
72
|
+
case "clash-daemon":
|
|
73
|
+
return handleClashDaemon(parsed);
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
const {
|
package/src/watch/public/app.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
// === 2026-08-07 变更 ===
|
|
2
|
+
// 1. renderWatchdogDashboard: 客户端按名称排序(在线优先,离线排最后)
|
|
3
|
+
// 2. renderClientCard / renderOfflineCard: 离线客户端红色标题+红色边框
|
|
4
|
+
// 3. 整体字体调大一号(12px→14px, 14px→16px, 10px→12px 等)
|
|
5
|
+
// 4. 新增 wdClearLlm() + 🧹 按钮(清除指定客户端 LLM 分析提示)
|
|
6
|
+
// 5. renderCmdRows 新增 llmAnalysis 参数,显示 🤖 AI 分析行
|
|
1
7
|
const COLORS = [
|
|
2
8
|
"#fe2c55",
|
|
3
9
|
"#60a5fa",
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 独立模块。每条命令是完整命令行字符串(如 "tt-help explore --location DE"),
|
|
5
5
|
* 按 userId(~/.tt-help.json 的 userId 字段)分组存储。
|
|
6
|
+
*
|
|
7
|
+
* === 2026-08-07 变更 ===
|
|
8
|
+
* 1. 新增 llmCache 内存缓存,handleAnalyzeLogs 分析完成后写入缓存
|
|
9
|
+
* 2. getOnlineClients() 优先用缓存填充 llmAnalysis(不再依赖 agent 转发)
|
|
10
|
+
* 3. 新增 POST /api/watchdog/clear-llm 端点,手动清除指定客户端的 LLM 提示
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
13
|
import { readFileSync, writeFileSync, existsSync } from "fs";
|
package/src/watchdog/agent.js
CHANGED
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
* 3. 处理信号优雅退出
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
getSystemInfo,
|
|
12
|
+
getClientConfig,
|
|
13
|
+
getLocalIP,
|
|
14
|
+
getClashHealth,
|
|
15
|
+
} from "./reporter.js";
|
|
11
16
|
import { ProcessManager } from "./process-manager.js";
|
|
12
17
|
import {
|
|
13
18
|
getVersion,
|
|
@@ -16,12 +21,50 @@ import {
|
|
|
16
21
|
startUpgradeChecker,
|
|
17
22
|
} from "./upgrader.js";
|
|
18
23
|
import { writeFileSync, existsSync, readFileSync } from "fs";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
24
|
+
import { spawn } from "child_process";
|
|
25
|
+
import { join, dirname } from "path";
|
|
26
|
+
import { homedir, tmpdir } from "os";
|
|
27
|
+
import { fileURLToPath } from "url";
|
|
21
28
|
|
|
29
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
30
|
const homeDir = homedir() || process.env.HOME || process.env.USERPROFILE || "";
|
|
23
31
|
const TT_HELP_CONFIG = join(homeDir, ".tt-help.json");
|
|
24
32
|
|
|
33
|
+
/** 自动发现 daemon.js 路径 */
|
|
34
|
+
function findDaemonPath() {
|
|
35
|
+
// 1. 从 ~/.tt-help.json 读取配置
|
|
36
|
+
try {
|
|
37
|
+
if (existsSync(TT_HELP_CONFIG)) {
|
|
38
|
+
const cfg = JSON.parse(readFileSync(TT_HELP_CONFIG, "utf-8"));
|
|
39
|
+
if (cfg.clashDaemonPath && existsSync(cfg.clashDaemonPath)) {
|
|
40
|
+
return cfg.clashDaemonPath;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch {}
|
|
44
|
+
|
|
45
|
+
// 2. 内部打包路径(npm publish 后 src/clash/daemon.js)
|
|
46
|
+
const internalPath = join(__dirname, "..", "clash", "daemon.js");
|
|
47
|
+
if (existsSync(internalPath)) return internalPath;
|
|
48
|
+
|
|
49
|
+
// 3. 开发模式:clash-verge-explore 同级目录
|
|
50
|
+
const candidates = [
|
|
51
|
+
join(__dirname, "..", "..", "..", "clash-verge-explore", "daemon.js"),
|
|
52
|
+
join(__dirname, "..", "..", "..", "..", "clash-verge-explore", "daemon.js"),
|
|
53
|
+
join(process.cwd(), "..", "clash-verge-explore", "daemon.js"),
|
|
54
|
+
];
|
|
55
|
+
for (const p of candidates) {
|
|
56
|
+
if (existsSync(p)) return p;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// daemon 路径(启动时解析一次)
|
|
62
|
+
let _daemonPath = null;
|
|
63
|
+
function getDaemonPath() {
|
|
64
|
+
if (_daemonPath === null) _daemonPath = findDaemonPath();
|
|
65
|
+
return _daemonPath;
|
|
66
|
+
}
|
|
67
|
+
|
|
25
68
|
/** 将服务端下发的配置合并写回 ~/.tt-help.json */
|
|
26
69
|
function applyClientConfig(newCfg) {
|
|
27
70
|
try {
|
|
@@ -92,6 +135,11 @@ export class WatchdogAgent {
|
|
|
92
135
|
this._heartbeatSeq = 0; // 心跳序号
|
|
93
136
|
this._lastProcCount = -1; // 上次进程数
|
|
94
137
|
this._lastCmdCount = -1; // 上次命令数
|
|
138
|
+
// clash daemon 集成
|
|
139
|
+
this.clashDaemon = null; // daemon 子进程引用
|
|
140
|
+
this.gracePeriodMs = 120_000; // 启动宽限期 2 分钟
|
|
141
|
+
this.startTime = 0; // 启动时间戳
|
|
142
|
+
this._graceEnded = false; // 宽限期是否已结束
|
|
95
143
|
}
|
|
96
144
|
|
|
97
145
|
/**
|
|
@@ -100,6 +148,7 @@ export class WatchdogAgent {
|
|
|
100
148
|
async start() {
|
|
101
149
|
if (this.running) return;
|
|
102
150
|
this.running = true;
|
|
151
|
+
this.startTime = Date.now();
|
|
103
152
|
|
|
104
153
|
console.error(
|
|
105
154
|
`[watchdog] 启动 — 客户端: ${this.clientId}, 版本: ${this.ttHelpVersion}, 间隔: ${this.intervalMs / 1000}s`,
|
|
@@ -111,6 +160,9 @@ export class WatchdogAgent {
|
|
|
111
160
|
// 注册信号处理
|
|
112
161
|
this._setupSignalHandlers();
|
|
113
162
|
|
|
163
|
+
// 启动 clash daemon(保代理通道)
|
|
164
|
+
this._spawnClashDaemon();
|
|
165
|
+
|
|
114
166
|
// 首次立即发送心跳
|
|
115
167
|
await this._sendHeartbeat();
|
|
116
168
|
|
|
@@ -152,6 +204,8 @@ export class WatchdogAgent {
|
|
|
152
204
|
this.upgradeChecker.stop();
|
|
153
205
|
this.upgradeChecker = null;
|
|
154
206
|
}
|
|
207
|
+
// 停止 clash daemon
|
|
208
|
+
this._killClashDaemon();
|
|
155
209
|
this.processManager.stopAll();
|
|
156
210
|
console.error("[watchdog] 看门狗已停止");
|
|
157
211
|
}
|
|
@@ -178,6 +232,7 @@ export class WatchdogAgent {
|
|
|
178
232
|
config: getClientConfig(),
|
|
179
233
|
processes: this.processManager.getProcessesStatus(),
|
|
180
234
|
llmAnalysis: this._llmResults,
|
|
235
|
+
clashHealth: getClashHealth(), // daemon 写入的代理健康状态
|
|
181
236
|
};
|
|
182
237
|
}
|
|
183
238
|
|
|
@@ -244,6 +299,29 @@ export class WatchdogAgent {
|
|
|
244
299
|
console.error(
|
|
245
300
|
`[watchdog] 心跳失败 (${this.consecutiveFailures}/${this.maxFailures}): ${e.message}`,
|
|
246
301
|
);
|
|
302
|
+
|
|
303
|
+
// 启动宽限期:daemon 可能还在切节点,心跳失败不计入自杀计数
|
|
304
|
+
if (!this._graceEnded) {
|
|
305
|
+
const elapsed = Date.now() - this.startTime;
|
|
306
|
+
if (elapsed < this.gracePeriodMs) {
|
|
307
|
+
const remain = Math.round((this.gracePeriodMs - elapsed) / 1000);
|
|
308
|
+
// daemon 已报告健康 → 提前结束宽限期
|
|
309
|
+
const health = getClashHealth();
|
|
310
|
+
if (health && health.healthy) {
|
|
311
|
+
this._graceEnded = true;
|
|
312
|
+
console.error(`[watchdog] 代理已健康,宽限期提前结束`);
|
|
313
|
+
this.consecutiveFailures = 0;
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
console.error(
|
|
317
|
+
`[watchdog] 宽限期内 (剩余${remain}s),等待 daemon 修复代理...`,
|
|
318
|
+
);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
this._graceEnded = true;
|
|
322
|
+
console.error(`[watchdog] 宽限期结束,恢复正常心跳失败计数`);
|
|
323
|
+
}
|
|
324
|
+
|
|
247
325
|
if (this.consecutiveFailures >= this.maxFailures) {
|
|
248
326
|
console.error("[watchdog] 连续心跳失败次数过多,看门狗退出");
|
|
249
327
|
await this.stop();
|
|
@@ -315,6 +393,76 @@ export class WatchdogAgent {
|
|
|
315
393
|
});
|
|
316
394
|
}
|
|
317
395
|
|
|
396
|
+
/**
|
|
397
|
+
* 启动 clash daemon 子进程
|
|
398
|
+
* daemon 负责代理节点健康巡检和故障切换,watchdog 不阻塞等待
|
|
399
|
+
*/
|
|
400
|
+
_spawnClashDaemon() {
|
|
401
|
+
const daemonPath = getDaemonPath();
|
|
402
|
+
if (!daemonPath) {
|
|
403
|
+
console.error("[watchdog] 未找到 clash daemon (daemon.js),跳过代理守护");
|
|
404
|
+
console.error(
|
|
405
|
+
"[watchdog] 提示: 在 ~/.tt-help.json 中设置 clashDaemonPath",
|
|
406
|
+
);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
console.error(`[watchdog] 启动代理守护: ${daemonPath}`);
|
|
411
|
+
try {
|
|
412
|
+
const child = spawn(process.execPath, [daemonPath], {
|
|
413
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
414
|
+
detached: false,
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
child.stdout?.on("data", (d) => {
|
|
418
|
+
// daemon 日志通过 stderr 输出,stdout 静默
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
child.stderr?.on("data", (d) => {
|
|
422
|
+
const msg = d.toString().trim();
|
|
423
|
+
if (msg) {
|
|
424
|
+
console.error(`[clash-daemon] ${msg.substring(0, 300)}`);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
child.on("exit", (code, signal) => {
|
|
429
|
+
console.error(
|
|
430
|
+
`[watchdog] clash daemon 退出 (code=${code}, signal=${signal})`,
|
|
431
|
+
);
|
|
432
|
+
this.clashDaemon = null;
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
child.on("error", (err) => {
|
|
436
|
+
console.error(`[watchdog] clash daemon 启动失败: ${err.message}`);
|
|
437
|
+
this.clashDaemon = null;
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
this.clashDaemon = child;
|
|
441
|
+
} catch (e) {
|
|
442
|
+
console.error(`[watchdog] clash daemon 启动异常: ${e.message}`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* 停止 clash daemon 子进程
|
|
448
|
+
*/
|
|
449
|
+
_killClashDaemon() {
|
|
450
|
+
if (!this.clashDaemon) return;
|
|
451
|
+
try {
|
|
452
|
+
this.clashDaemon.kill("SIGTERM");
|
|
453
|
+
setTimeout(() => {
|
|
454
|
+
try {
|
|
455
|
+
if (this.clashDaemon && this.clashDaemon.exitCode === null) {
|
|
456
|
+
this.clashDaemon.kill("SIGKILL");
|
|
457
|
+
}
|
|
458
|
+
} catch {}
|
|
459
|
+
}, 3000);
|
|
460
|
+
} catch (e) {
|
|
461
|
+
console.error(`[watchdog] 停止 clash daemon 失败: ${e.message}`);
|
|
462
|
+
}
|
|
463
|
+
this.clashDaemon = null;
|
|
464
|
+
}
|
|
465
|
+
|
|
318
466
|
/**
|
|
319
467
|
* 信号处理
|
|
320
468
|
*/
|
|
@@ -16,6 +16,8 @@ import { writeFileSync, unlinkSync } from "fs";
|
|
|
16
16
|
import { tmpdir } from "os";
|
|
17
17
|
import { join } from "path";
|
|
18
18
|
|
|
19
|
+
// === 2026-08-07 变更 ===
|
|
20
|
+
// syncCommands 改为 async,批量启动时每个进程间隔 SPAWN_STAGGER_MS(30s),避免抢网络
|
|
19
21
|
const MAX_RESTART = 3;
|
|
20
22
|
const MAX_LOG_LINES = 500; // 每个进程最多保留的日志行数
|
|
21
23
|
const IS_WIN = process.platform === "win32";
|
package/src/watchdog/reporter.js
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
import os from "os";
|
|
7
7
|
import { existsSync, readFileSync } from "fs";
|
|
8
8
|
import { join } from "path";
|
|
9
|
-
import { homedir } from "os";
|
|
9
|
+
import { homedir, tmpdir } from "os";
|
|
10
10
|
|
|
11
11
|
const homeDir = homedir() || process.env.HOME || process.env.USERPROFILE || "";
|
|
12
12
|
const CONFIG_PATH = join(homeDir, ".tt-help.json");
|
|
13
|
+
const CLASH_HEALTH_FILE = join(tmpdir(), "clash-health.json");
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* 获取系统信息
|
|
@@ -67,3 +68,25 @@ export function getLocalIP() {
|
|
|
67
68
|
}
|
|
68
69
|
return "127.0.0.1";
|
|
69
70
|
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 读取 clash daemon 写入的健康状态文件
|
|
74
|
+
* @returns {object|null} { node, healthy, httpCode, timeTotal, lastCheck, lastSwitch } 或 null
|
|
75
|
+
*/
|
|
76
|
+
export function getClashHealth() {
|
|
77
|
+
try {
|
|
78
|
+
if (existsSync(CLASH_HEALTH_FILE)) {
|
|
79
|
+
const raw = readFileSync(CLASH_HEALTH_FILE, "utf-8");
|
|
80
|
+
const data = JSON.parse(raw);
|
|
81
|
+
// 检查数据是否过期(超过 2 分钟没更新视为 stale)
|
|
82
|
+
const age = Date.now() - new Date(data.lastCheck || 0).getTime();
|
|
83
|
+
if (age > 120_000) {
|
|
84
|
+
return { ...data, stale: true, ageSeconds: Math.round(age / 1000) };
|
|
85
|
+
}
|
|
86
|
+
return { ...data, stale: false };
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// 文件不存在或解析失败
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|