tt-help-cli-ycl 1.4.155 → 1.4.157
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 +49 -0
- package/src/clash/checker.js +28 -11
- package/src/clash/daemon.js +112 -1
- package/src/clash/runtime.js +823 -0
- package/src/cli/comments.js +7 -0
- package/src/cli/explore.js +12 -1
- package/src/cli/refresh-mini.js +13 -1
- package/src/cli/refresh.js +13 -1
- package/src/lib/app-config.js +25 -1
- package/src/lib/args.js +9 -0
- package/src/lib/client-locations.js +70 -0
- package/src/watch/public/app.js +163 -2
- package/src/watch/public/index.html +17 -0
- package/src/watch/watchdog-server.js +53 -0
- package/src/watchdog/agent.js +39 -0
- package/src/watchdog/env-init.js +187 -0
- package/src/watchdog/process-manager.js +17 -6
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mihomo 内核运行时 — runtime.js
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* 1. 随包分发: 从 vendor/mihomo/ 解压对应平台内核 + metacubexd 面板到 ~/.tt-help/mihomo/
|
|
6
|
+
* 2. config.yaml 生成: proxy-providers(订阅)+ GLOBAL include-all 桥接 + external-ui 面板
|
|
7
|
+
* 3. 进程托管: 先发现后拉起(幂等,不会起双实例)、detached 运行(daemon 挂了代理不陪葬)、
|
|
8
|
+
* 拉起冷却(防崩溃循环)、端口预检(被 Verge 等占用时不抢)
|
|
9
|
+
*
|
|
10
|
+
* 订阅来源: ~/.tt-help.json → clash.subscriptions(setSubscription 命令应用后回写持久化)
|
|
11
|
+
* 端口/secret: 与 DISCOVER_DEFAULTS 一致(9097/abcdefg/7897),保证 daemon 发现逻辑零改动
|
|
12
|
+
*
|
|
13
|
+
* 被调用方: daemon.js(自动托管)、CLI(tt-help proxy,共用同一套)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, statSync, readdirSync, copyFileSync, openSync, renameSync } from "fs";
|
|
17
|
+
import net from "net";
|
|
18
|
+
import { homedir, platform, arch } from "os";
|
|
19
|
+
import { join, dirname } from "path";
|
|
20
|
+
import { spawn, spawnSync, execSync } from "child_process";
|
|
21
|
+
import { gunzipSync } from "zlib";
|
|
22
|
+
import { createHash } from "crypto";
|
|
23
|
+
import { fileURLToPath } from "url";
|
|
24
|
+
import { parseNetstatListeningPids } from "../lib/browser/cdp.js";
|
|
25
|
+
import { DISCOVER_DEFAULTS } from "./discover.js";
|
|
26
|
+
|
|
27
|
+
const IS_WIN = platform() === "win32";
|
|
28
|
+
|
|
29
|
+
// ── 路径 ──
|
|
30
|
+
|
|
31
|
+
/** 包根目录(src/clash/ → 上两级) */
|
|
32
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
33
|
+
/** 随包内核目录 */
|
|
34
|
+
export const VENDOR_DIR = join(PKG_ROOT, "vendor", "mihomo");
|
|
35
|
+
/** 运行时目录 */
|
|
36
|
+
export const RUNTIME_DIR = join(homedir(), ".tt-help", "mihomo");
|
|
37
|
+
|
|
38
|
+
export function runtimePaths() {
|
|
39
|
+
return {
|
|
40
|
+
dir: RUNTIME_DIR,
|
|
41
|
+
bin: join(RUNTIME_DIR, "bin", IS_WIN ? "mihomo.exe" : "mihomo"),
|
|
42
|
+
ui: join(RUNTIME_DIR, "ui"),
|
|
43
|
+
configFile: join(RUNTIME_DIR, "config.yaml"),
|
|
44
|
+
logFile: join(RUNTIME_DIR, "run.log"),
|
|
45
|
+
stampFile: join(RUNTIME_DIR, ".installed"),
|
|
46
|
+
providersDir: join(RUNTIME_DIR, "providers"),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── 平台标识与内核获取清单(npm 包不再内置二进制,运行时多级获取)──
|
|
51
|
+
|
|
52
|
+
export const MIHOMO_VERSION = "v1.19.31";
|
|
53
|
+
|
|
54
|
+
/** 内核/面板获取清单: 版本钉死 + sha256 强校验(升级 mihomo = 改此表 + 传服务器) */
|
|
55
|
+
const VENDOR_MANIFEST = {
|
|
56
|
+
"darwin-arm64": { file: `mihomo-darwin-arm64-${MIHOMO_VERSION}.gz`, sha256: "d131f44b3deb2a8356f7ac75048ad67a10d53243323951c4f3cda7b672922963" },
|
|
57
|
+
"darwin-amd64": { file: `mihomo-darwin-amd64-${MIHOMO_VERSION}.gz`, sha256: "3546681ebef3415e5dcbe7210a61aa80748136e95e6552768fd883df345508ed" },
|
|
58
|
+
"windows-amd64": { file: `mihomo-windows-amd64-${MIHOMO_VERSION}.zip`, sha256: "38b2420799d9e7cde77ec1a19c7150dd17ca77f7fb82d9f62cb8763a307eee67" },
|
|
59
|
+
};
|
|
60
|
+
const UI_MANIFEST = { file: "metacubexd-dist.tgz", sha256: "93eb334af7e37d213317347c01de31bd28e6c591c9b90e86496d257f15135ed9" };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 内核/面板下载源(顺序 failover):
|
|
64
|
+
* 1. ~/.tt-help.json → clash.vendorBase 或环境变量 TT_VENDOR_BASE(覆盖)
|
|
65
|
+
* 2. 自建服务器公网(201 watch 静态路由 /scripts/vendor,免鉴权)
|
|
66
|
+
* 3. 自建服务器内网(农场机器直连)
|
|
67
|
+
* 4. GitHub Releases(仅海外/兜底;农场不可达属预期)
|
|
68
|
+
*/
|
|
69
|
+
export function vendorBases() {
|
|
70
|
+
const urls = [];
|
|
71
|
+
const push = (u) => {
|
|
72
|
+
if (u) urls.push(String(u).replace(/\/+$/, ""));
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
const cfgPath = join(homedir(), ".tt-help.json");
|
|
76
|
+
if (existsSync(cfgPath)) {
|
|
77
|
+
const cfg = JSON.parse(readFileSync(cfgPath, "utf-8"));
|
|
78
|
+
push(cfg?.clash?.vendorBase);
|
|
79
|
+
}
|
|
80
|
+
} catch {}
|
|
81
|
+
push(process.env.TT_VENDOR_BASE);
|
|
82
|
+
push("http://117.71.53.99:17301/scripts/vendor");
|
|
83
|
+
push("http://172.18.154.201:3001/scripts/vendor");
|
|
84
|
+
push("https://github.com/MetaCubeX/mihomo/releases/download");
|
|
85
|
+
return [...new Set(urls)];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** node 平台/arch → mihomo 发布命名(win32→windows, x64→amd64) */
|
|
89
|
+
function platformKey() {
|
|
90
|
+
const p = platform() === "win32" ? "windows" : platform();
|
|
91
|
+
const a = arch() === "arm64" ? "arm64" : "amd64";
|
|
92
|
+
return `${p}-${a}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 定位 vendor 中当前平台的内核压缩包(.gz=unix 单文件 / .zip=windows) */
|
|
96
|
+
export function findVendorCore() {
|
|
97
|
+
if (!existsSync(VENDOR_DIR)) return null;
|
|
98
|
+
const prefix = `mihomo-${platformKey()}-`;
|
|
99
|
+
for (const f of readdirSync(VENDOR_DIR)) {
|
|
100
|
+
if (f.startsWith(prefix) && (f.endsWith(".gz") || f.endsWith(".zip"))) {
|
|
101
|
+
const p = join(VENDOR_DIR, f);
|
|
102
|
+
const st = statSync(p);
|
|
103
|
+
return { path: p, file: f, size: st.size, mtimeMs: st.mtimeMs };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function findVendorUI() {
|
|
110
|
+
const p = join(VENDOR_DIR, "metacubexd-dist.tgz");
|
|
111
|
+
if (!existsSync(p)) return null;
|
|
112
|
+
const st = statSync(p);
|
|
113
|
+
return { path: p, size: st.size, mtimeMs: st.mtimeMs };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── 解压落位 ──
|
|
117
|
+
|
|
118
|
+
function readStamp() {
|
|
119
|
+
const { stampFile } = runtimePaths();
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(readFileSync(stampFile, "utf-8"));
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function writeStamp(stamp) {
|
|
128
|
+
const { stampFile } = runtimePaths();
|
|
129
|
+
writeFileSync(stampFile, JSON.stringify(stamp));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 已安装且与 vendor 一致(size+mtime 比对)→ 跳过重复解压 */
|
|
133
|
+
function installUpToDate(art, stampKey) {
|
|
134
|
+
const stamp = readStamp();
|
|
135
|
+
if (!stamp || !stamp[stampKey]) return false;
|
|
136
|
+
return stamp[stampKey].size === art.size && stamp[stampKey].mtimeMs === art.mtimeMs && existsSync(stamp[stampKey].target);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sha256File(file) {
|
|
140
|
+
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 从压缩包安装内核(sha256 强校验 + 临时文件原子替换,不伤及运行中的旧内核)
|
|
145
|
+
* @param {string} artPath 压缩包路径(vendor 或下载缓存)
|
|
146
|
+
* @param {{file:string, sha256:string}} entry 清单项
|
|
147
|
+
*/
|
|
148
|
+
async function installCoreFromArt(artPath, entry, { log = () => {} } = {}) {
|
|
149
|
+
const { bin } = runtimePaths();
|
|
150
|
+
const actual = sha256File(artPath);
|
|
151
|
+
if (actual !== entry.sha256) {
|
|
152
|
+
return { ok: false, error: `内核 sha256 校验失败(期望 ${entry.sha256.slice(0, 12)}…,实际 ${actual.slice(0, 12)}…)` };
|
|
153
|
+
}
|
|
154
|
+
mkdirSync(dirname(bin), { recursive: true });
|
|
155
|
+
if (entry.file.endsWith(".gz")) {
|
|
156
|
+
// unix 单文件: gunzip → 临时文件 → 原子替换(运行中旧内核不受影响)
|
|
157
|
+
const tmp = `${bin}.tmp-${Date.now()}`;
|
|
158
|
+
writeFileSync(tmp, gunzipSync(readFileSync(artPath)));
|
|
159
|
+
if (!IS_WIN) spawnSync("chmod", ["755", tmp]);
|
|
160
|
+
renameSync(tmp, bin);
|
|
161
|
+
} else {
|
|
162
|
+
// windows zip: tar(bsdtar) 解压 → 找 exe → 原子替换
|
|
163
|
+
const tmp = join(RUNTIME_DIR, `.extract-${Date.now()}`);
|
|
164
|
+
mkdirSync(tmp, { recursive: true });
|
|
165
|
+
const r = spawnSync("tar", ["-xf", artPath, "-C", tmp], { encoding: "utf-8" });
|
|
166
|
+
const exe = findFileRecursive(tmp, /^mihomo[\w.-]*\.exe$/i);
|
|
167
|
+
if (!exe) {
|
|
168
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
169
|
+
return { ok: false, error: `zip 中未找到 mihomo.exe(tar: ${r.stderr || r.stdout || "无输出"})` };
|
|
170
|
+
}
|
|
171
|
+
copyFileSync(exe, bin);
|
|
172
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
const stamp = readStamp() || {};
|
|
175
|
+
stamp.core = { version: MIHOMO_VERSION, sha256: entry.sha256, target: bin };
|
|
176
|
+
writeStamp(stamp);
|
|
177
|
+
log(`✓ 内核就位 (${MIHOMO_VERSION})`);
|
|
178
|
+
return { ok: true, bin };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 确保内核二进制就位(多级获取: 本地缓存 → 包内 vendor → 服务器下载 → GitHub 兼底)
|
|
183
|
+
* @returns {Promise<{ok:boolean, bin?:string, source?:string, error?:string}>}
|
|
184
|
+
*/
|
|
185
|
+
export async function ensureBinary({ log = () => {} } = {}) {
|
|
186
|
+
const { bin } = runtimePaths();
|
|
187
|
+
const entry = VENDOR_MANIFEST[platformKey()];
|
|
188
|
+
if (!entry) return { ok: false, error: `不支持的平台: ${platformKey()}` };
|
|
189
|
+
|
|
190
|
+
// 1) 本地缓存: 版本与清单一致 → 跳过(老机器平滑:无 version 字段的旧 stamp 会被后续步骤补齐)
|
|
191
|
+
const stamp = readStamp();
|
|
192
|
+
if (existsSync(bin) && stamp?.core?.version === MIHOMO_VERSION) {
|
|
193
|
+
return { ok: true, bin, source: "cache" };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 2) 包内 vendor(dev / npm link / 手动放置)
|
|
197
|
+
const art = findVendorCore();
|
|
198
|
+
if (art) {
|
|
199
|
+
if (installUpToDate(art, "core") && existsSync(bin)) {
|
|
200
|
+
// 旧版 stamp 补写 version 字段,下次直接命中缓存
|
|
201
|
+
const s = readStamp() || {};
|
|
202
|
+
s.core = { ...(s.core || {}), version: MIHOMO_VERSION, target: bin };
|
|
203
|
+
writeStamp(s);
|
|
204
|
+
return { ok: true, bin, source: "vendor" };
|
|
205
|
+
}
|
|
206
|
+
log(`📦 使用包内内核: ${art.file}`);
|
|
207
|
+
return installCoreFromArt(art.path, entry, { log });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 3) 下载链(自建服务器 → GitHub 兼底)
|
|
211
|
+
const dlFile = join(RUNTIME_DIR, `.dl-${entry.file}`);
|
|
212
|
+
mkdirSync(RUNTIME_DIR, { recursive: true });
|
|
213
|
+
for (const base of vendorBases()) {
|
|
214
|
+
const url = `${base}/${MIHOMO_VERSION}/${entry.file}`;
|
|
215
|
+
let ok = false;
|
|
216
|
+
for (let t = 1; t <= 2 && !ok; t++) {
|
|
217
|
+
try {
|
|
218
|
+
log(`⬇️ 下载内核(${t}/2): ${url}`);
|
|
219
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(300000) });
|
|
220
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
221
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
222
|
+
if (buf.length < 1024 * 1024) throw new Error(`文件过小 (${buf.length}B),疑似错误页`);
|
|
223
|
+
writeFileSync(dlFile, buf);
|
|
224
|
+
ok = true;
|
|
225
|
+
} catch (e) {
|
|
226
|
+
log(`⚠ 下载失败: ${e.message}`);
|
|
227
|
+
if (t < 2) await delay(2000);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (ok) {
|
|
231
|
+
const r = await installCoreFromArt(dlFile, entry, { log });
|
|
232
|
+
try {
|
|
233
|
+
rmSync(dlFile, { force: true });
|
|
234
|
+
} catch {}
|
|
235
|
+
if (r.ok) return { ...r, source: `download:${base}` };
|
|
236
|
+
log(`⚠ ${r.error}(来自 ${base})`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
error: `内核获取失败(已尝试 ${vendorBases().length} 个源)。可手动下载 ${entry.file} 放到 ${dirname(bin)}`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 确保面板静态文件就位(metacubexd,多级获取;非关键路径,失败仅警告)
|
|
247
|
+
* @returns {Promise<{ok:boolean, ui?:string, error?:string}>}
|
|
248
|
+
*/
|
|
249
|
+
export async function ensureUI({ log = () => {} } = {}) {
|
|
250
|
+
const { ui } = runtimePaths();
|
|
251
|
+
const indexFile = join(ui, "index.html");
|
|
252
|
+
|
|
253
|
+
// 1) 本地缓存: 版本一致 → 跳过
|
|
254
|
+
const stamp = readStamp();
|
|
255
|
+
if (existsSync(indexFile) && stamp?.ui?.version === MIHOMO_VERSION) return { ok: true, ui };
|
|
256
|
+
|
|
257
|
+
// 2) 包内 vendor
|
|
258
|
+
const art = findVendorUI();
|
|
259
|
+
if (art) {
|
|
260
|
+
const r = installUIFromArt(art.path, { log });
|
|
261
|
+
if (r.ok) return { ok: true, ui };
|
|
262
|
+
return r;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 3) 下载链(不含 GitHub,面板非关键)
|
|
266
|
+
const dlFile = join(RUNTIME_DIR, `.dl-${UI_MANIFEST.file}`);
|
|
267
|
+
mkdirSync(RUNTIME_DIR, { recursive: true });
|
|
268
|
+
for (const base of vendorBases().slice(0, 3)) {
|
|
269
|
+
const url = `${base}/${MIHOMO_VERSION}/${UI_MANIFEST.file}`;
|
|
270
|
+
try {
|
|
271
|
+
log(`⬇️ 下载面板: ${url}`);
|
|
272
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(120000) });
|
|
273
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
274
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
275
|
+
if (buf.length < 1024) throw new Error("文件过小");
|
|
276
|
+
writeFileSync(dlFile, buf);
|
|
277
|
+
const r = installUIFromArt(dlFile, { log });
|
|
278
|
+
try {
|
|
279
|
+
rmSync(dlFile, { force: true });
|
|
280
|
+
} catch {}
|
|
281
|
+
return r;
|
|
282
|
+
} catch (e) {
|
|
283
|
+
log(`⚠ 面板下载失败: ${e.message}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return { ok: false, error: `面板获取失败(非关键,不影响代理)` };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function installUIFromArt(artPath, { log = () => {} } = {}) {
|
|
290
|
+
const { ui } = runtimePaths();
|
|
291
|
+
const actual = sha256File(artPath);
|
|
292
|
+
if (actual !== UI_MANIFEST.sha256) {
|
|
293
|
+
return { ok: false, error: `面板 sha256 校验失败` };
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
rmSync(ui, { recursive: true, force: true });
|
|
297
|
+
mkdirSync(ui, { recursive: true });
|
|
298
|
+
const r = spawnSync("tar", ["-xzf", artPath, "-C", ui], { encoding: "utf-8" });
|
|
299
|
+
if (r.status !== 0 || !existsSync(join(ui, "index.html"))) {
|
|
300
|
+
return { ok: false, error: `面板解压失败: ${r.stderr || "未生成 index.html"}` };
|
|
301
|
+
}
|
|
302
|
+
const stamp = readStamp() || {};
|
|
303
|
+
stamp.ui = { version: MIHOMO_VERSION, sha256: UI_MANIFEST.sha256, target: join(ui, "index.html") };
|
|
304
|
+
writeStamp(stamp);
|
|
305
|
+
log("✓ 面板就位");
|
|
306
|
+
return { ok: true, ui };
|
|
307
|
+
} catch (e) {
|
|
308
|
+
return { ok: false, error: `面板解压失败: ${e.message}` };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function findFileRecursive(dir, regex) {
|
|
313
|
+
try {
|
|
314
|
+
for (const f of readdirSync(dir, { withFileTypes: true })) {
|
|
315
|
+
const p = join(dir, f.name);
|
|
316
|
+
if (f.isDirectory()) {
|
|
317
|
+
const hit = findFileRecursive(p, regex);
|
|
318
|
+
if (hit) return hit;
|
|
319
|
+
} else if (regex.test(f.name)) {
|
|
320
|
+
return p;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
} catch {}
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── 订阅配置(~/.tt-help.json → clash.subscriptions) ──
|
|
328
|
+
|
|
329
|
+
/** 归一化: ["url" | {name,url}] → [{name,url}];非法 URL 丢弃;中文名清洗后为空则自动编 sub-N */
|
|
330
|
+
export function normalizeSubscriptions(list) {
|
|
331
|
+
const out = [];
|
|
332
|
+
for (const it of Array.isArray(list) ? list : []) {
|
|
333
|
+
const url = typeof it === "string" ? it.trim() : it && String(it.url || "").trim();
|
|
334
|
+
if (!url || !/^https?:\/\//.test(url)) continue;
|
|
335
|
+
let name = typeof it === "object" && it.name ? String(it.name).trim() : "";
|
|
336
|
+
name = name.replace(/[^\w-]/g, "").slice(0, 32);
|
|
337
|
+
if (!name || /^\d+$/.test(name)) name = ""; // 纯数字/空名回退自动命名
|
|
338
|
+
if (!name) name = `sub-${out.length + 1}`;
|
|
339
|
+
if (out.some((s) => s.name === name)) name = `${name}-${out.length + 1}`;
|
|
340
|
+
out.push({ name, url });
|
|
341
|
+
}
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** 读本机已持久化的订阅(~/.tt-help.json → clash.subscriptions) */
|
|
346
|
+
export function readStoredSubscriptions() {
|
|
347
|
+
try {
|
|
348
|
+
const p = join(homedir(), ".tt-help.json");
|
|
349
|
+
if (!existsSync(p)) return [];
|
|
350
|
+
const cfg = JSON.parse(readFileSync(p, "utf-8"));
|
|
351
|
+
return normalizeSubscriptions((cfg.clash && cfg.clash.subscriptions) || []);
|
|
352
|
+
} catch {
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** 持久化订阅到 ~/.tt-help.json(应用成功后调用,保证 daemon 重启后自恢复) */
|
|
358
|
+
export function storeSubscriptions(list) {
|
|
359
|
+
const p = join(homedir(), ".tt-help.json");
|
|
360
|
+
let cfg = {};
|
|
361
|
+
try {
|
|
362
|
+
cfg = JSON.parse(readFileSync(p, "utf-8"));
|
|
363
|
+
} catch {}
|
|
364
|
+
if (!cfg.clash || typeof cfg.clash !== "object") cfg.clash = {};
|
|
365
|
+
cfg.clash.subscriptions = normalizeSubscriptions(list);
|
|
366
|
+
writeFileSync(p, JSON.stringify(cfg, null, 2));
|
|
367
|
+
return cfg.clash.subscriptions;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── config.yaml 生成 ──
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* 生成 mihomo 配置(与实测验证过的模板一致):
|
|
374
|
+
* global 模式 + GLOBAL include-all 桥接(provider 节点进 GLOBAL,daemon 切换零改动)
|
|
375
|
+
* external-ui 挂面板(仅启动时生效,所以必须生成时就写入)
|
|
376
|
+
*/
|
|
377
|
+
export function buildConfig({ subscriptions, apiPort, secret, mixedPort }) {
|
|
378
|
+
const lines = [
|
|
379
|
+
"# 由 tt-help runtime 自动生成 — 手动修改会被覆盖",
|
|
380
|
+
`mixed-port: ${mixedPort}`,
|
|
381
|
+
`external-controller: 127.0.0.1:${apiPort}`,
|
|
382
|
+
`secret: "${secret}"`,
|
|
383
|
+
"mode: global",
|
|
384
|
+
"log-level: info",
|
|
385
|
+
"external-ui: ui",
|
|
386
|
+
"",
|
|
387
|
+
];
|
|
388
|
+
if (subscriptions.length) {
|
|
389
|
+
lines.push("proxy-providers:");
|
|
390
|
+
for (const s of subscriptions) {
|
|
391
|
+
lines.push(
|
|
392
|
+
` ${s.name}:`,
|
|
393
|
+
" type: http",
|
|
394
|
+
` url: ${s.url}`,
|
|
395
|
+
" interval: 86400",
|
|
396
|
+
` path: ./providers/${s.name}.yaml`,
|
|
397
|
+
" proxy: DIRECT",
|
|
398
|
+
" health-check:",
|
|
399
|
+
" enable: true",
|
|
400
|
+
" url: http://www.gstatic.com/generate_204",
|
|
401
|
+
" interval: 3600",
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
lines.push("");
|
|
405
|
+
}
|
|
406
|
+
lines.push(
|
|
407
|
+
"proxy-groups:",
|
|
408
|
+
" - name: GLOBAL",
|
|
409
|
+
" type: select",
|
|
410
|
+
" include-all: true",
|
|
411
|
+
"",
|
|
412
|
+
);
|
|
413
|
+
return lines.join("\n");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 写 config.yaml(内容不变则跳过,返回是否变化)
|
|
418
|
+
* @returns {{ok:boolean, changed:boolean, error?:string}}
|
|
419
|
+
*/
|
|
420
|
+
export function writeConfig({ subscriptions, apiPort, secret, mixedPort }) {
|
|
421
|
+
const { configFile } = runtimePaths();
|
|
422
|
+
try {
|
|
423
|
+
mkdirSync(RUNTIME_DIR, { recursive: true });
|
|
424
|
+
mkdirSync(join(RUNTIME_DIR, "providers"), { recursive: true });
|
|
425
|
+
const content = buildConfig({ subscriptions, apiPort, secret, mixedPort });
|
|
426
|
+
const prev = existsSync(configFile) ? readFileSync(configFile, "utf-8") : "";
|
|
427
|
+
if (prev === content) return { ok: true, changed: false };
|
|
428
|
+
writeFileSync(configFile, content);
|
|
429
|
+
return { ok: true, changed: true, prevContent: prev };
|
|
430
|
+
} catch (e) {
|
|
431
|
+
return { ok: false, changed: false, error: e.message };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── 探测 ──
|
|
436
|
+
|
|
437
|
+
/** TCP 端口是否有进程监听(跨平台,无外部依赖) */
|
|
438
|
+
export function isPortListening(port, timeoutMs = 800) {
|
|
439
|
+
return new Promise((resolve) => {
|
|
440
|
+
const sock = net.connect({ port, host: "127.0.0.1" });
|
|
441
|
+
const done = (r) => {
|
|
442
|
+
sock.destroy();
|
|
443
|
+
resolve(r);
|
|
444
|
+
};
|
|
445
|
+
sock.setTimeout(timeoutMs);
|
|
446
|
+
sock.once("connect", () => done(true));
|
|
447
|
+
sock.once("timeout", () => done(false));
|
|
448
|
+
sock.once("error", () => done(false));
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** GET /version 探测(带 secret),返回 mihomo 是否可用 */
|
|
453
|
+
export async function controllerAlive(apiPort, secret, timeoutMs = 2500) {
|
|
454
|
+
try {
|
|
455
|
+
const headers = {};
|
|
456
|
+
if (secret) headers.Authorization = `Bearer ${secret}`;
|
|
457
|
+
const res = await fetch(`http://127.0.0.1:${apiPort}/version`, {
|
|
458
|
+
headers,
|
|
459
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
460
|
+
});
|
|
461
|
+
if (res.status !== 200) return false;
|
|
462
|
+
const j = await res.json().catch(() => null);
|
|
463
|
+
return typeof j?.version === "string";
|
|
464
|
+
} catch {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── 端口占用者识别与接管(订阅 = 接管授权,2026-09-25 设计)──
|
|
470
|
+
|
|
471
|
+
/** 找到监听指定端口的 pid(跨平台;失败返回空数组) */
|
|
472
|
+
function getListeningPids(port) {
|
|
473
|
+
try {
|
|
474
|
+
if (IS_WIN) {
|
|
475
|
+
const out = execSync("netstat -ano -p TCP", { encoding: "utf-8", timeout: 10000, windowsHide: true });
|
|
476
|
+
return parseNetstatListeningPids(out, port).map(Number);
|
|
477
|
+
}
|
|
478
|
+
const out = execSync(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`, { encoding: "utf-8", timeout: 8000 });
|
|
479
|
+
return out.split(/\s+/).filter(Boolean).map(Number);
|
|
480
|
+
} catch {
|
|
481
|
+
return [];
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** 读进程命令行(跨平台;失败返回空串) */
|
|
486
|
+
function getProcessCmd(pid) {
|
|
487
|
+
try {
|
|
488
|
+
if (IS_WIN) {
|
|
489
|
+
// 优先 wmic(旧系统);新系统已移除 wmic → 回退 PowerShell(2026-09-25 213 实测)
|
|
490
|
+
try {
|
|
491
|
+
const out = execSync(`wmic process where processid=${pid} get commandline /format:list`, {
|
|
492
|
+
encoding: "utf-8",
|
|
493
|
+
timeout: 10000,
|
|
494
|
+
windowsHide: true,
|
|
495
|
+
});
|
|
496
|
+
const m = out.match(/CommandLine=(.*)/i);
|
|
497
|
+
if (m && m[1].trim()) return m[1].trim();
|
|
498
|
+
} catch {}
|
|
499
|
+
const ps = execSync(
|
|
500
|
+
`powershell -NoProfile -Command "(Get-CimInstance Win32_Process -Filter \\"ProcessId=${pid}\\").CommandLine"`,
|
|
501
|
+
{ encoding: "utf-8", timeout: 15000, windowsHide: true },
|
|
502
|
+
);
|
|
503
|
+
return ps.trim();
|
|
504
|
+
}
|
|
505
|
+
return execSync(`ps -p ${pid} -o command=`, { encoding: "utf-8", timeout: 8000 }).trim();
|
|
506
|
+
} catch {
|
|
507
|
+
return "";
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const CLASH_CORE_RE = /mihomo|clash|verge/i;
|
|
512
|
+
const RUNTIME_DIR_RE = /[\\/]\.tt-help[\\/]mihomo[\\/]/;
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* 清理端口上的 clash 内核签名占用者(Verge 内核等,仅在订阅接管时调用)。
|
|
516
|
+
* @returns {Promise<'killed'|'none'|'foreign'>} killed=已清理并等端口释放; none=端口已空; foreign=非内核程序,不动
|
|
517
|
+
*/
|
|
518
|
+
async function killClashCoreOnPort(port, { log = () => {} } = {}) {
|
|
519
|
+
const pids = getListeningPids(port).filter((pid) => pid !== process.pid);
|
|
520
|
+
if (!pids.length) return "none";
|
|
521
|
+
let foreign = false;
|
|
522
|
+
const killed = [];
|
|
523
|
+
for (const pid of pids) {
|
|
524
|
+
const cmd = getProcessCmd(pid);
|
|
525
|
+
if (!CLASH_CORE_RE.test(cmd)) {
|
|
526
|
+
foreign = true;
|
|
527
|
+
log(`⚠ 端口 ${port} 占用者非 clash 内核 (pid ${pid}),不自动清理`);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
log(`🧹 接管: 清理端口 ${port} 上的 clash 内核 (pid ${pid}, ${cmd.slice(0, 80)})`);
|
|
531
|
+
try {
|
|
532
|
+
if (IS_WIN) {
|
|
533
|
+
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore", timeout: 15000, windowsHide: true });
|
|
534
|
+
} else {
|
|
535
|
+
process.kill(pid, "SIGTERM");
|
|
536
|
+
}
|
|
537
|
+
killed.push(pid);
|
|
538
|
+
} catch {}
|
|
539
|
+
}
|
|
540
|
+
if (foreign) return "foreign";
|
|
541
|
+
if (!killed.length) return "none";
|
|
542
|
+
// unix TERM 可能被忽略 → 残留补 KILL;等端口释放
|
|
543
|
+
for (let i = 0; i < 10; i++) {
|
|
544
|
+
await delay(500);
|
|
545
|
+
if (!(await isPortListening(port))) break;
|
|
546
|
+
if (i === 5) {
|
|
547
|
+
for (const pid of killed) {
|
|
548
|
+
try {
|
|
549
|
+
if (!IS_WIN) process.kill(pid, "SIGKILL");
|
|
550
|
+
} catch {}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return "killed";
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** 当前 apiPort 控制端是否为我们 runtime 拉起的 mihomo(路径在 ~/.tt-help/mihomo 下) */
|
|
558
|
+
export async function runtimeControllerIsOurs(apiPort = DISCOVER_DEFAULTS.port) {
|
|
559
|
+
for (const pid of getListeningPids(apiPort)) {
|
|
560
|
+
const cmd = getProcessCmd(pid);
|
|
561
|
+
if (cmd && RUNTIME_DIR_RE.test(cmd)) return true;
|
|
562
|
+
}
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** 回收我们拉起的内置 mihomo(清空订阅时调用;外来内核不动) */
|
|
567
|
+
export async function stopRuntimeMihomo({
|
|
568
|
+
apiPort = DISCOVER_DEFAULTS.port,
|
|
569
|
+
mixedPort = DISCOVER_DEFAULTS.proxyPort,
|
|
570
|
+
log = () => {},
|
|
571
|
+
} = {}) {
|
|
572
|
+
let killed = 0;
|
|
573
|
+
for (const port of [apiPort, mixedPort]) {
|
|
574
|
+
for (const pid of getListeningPids(port)) {
|
|
575
|
+
const cmd = getProcessCmd(pid);
|
|
576
|
+
if (!cmd || !RUNTIME_DIR_RE.test(cmd)) continue;
|
|
577
|
+
log(`🧹 回收内置 mihomo (pid ${pid})`);
|
|
578
|
+
try {
|
|
579
|
+
if (IS_WIN) {
|
|
580
|
+
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore", timeout: 15000, windowsHide: true });
|
|
581
|
+
} else {
|
|
582
|
+
process.kill(pid, "SIGTERM");
|
|
583
|
+
}
|
|
584
|
+
killed++;
|
|
585
|
+
} catch {}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return killed;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ── 进程托管 ──
|
|
592
|
+
|
|
593
|
+
let _lastSpawnAt = 0;
|
|
594
|
+
const SPAWN_COOLDOWN_MS = 60_000;
|
|
595
|
+
|
|
596
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* 确保 mihomo 运行(订阅 = 接管授权):
|
|
600
|
+
* - 未配置订阅 → no-subscription,不拉起不占位不碰任何现有代理(用户可自用 Verge)
|
|
601
|
+
* - 控制端已可用(Verge / 上次拉起的 mihomo)→ adopted,不动(force 模式跳过)
|
|
602
|
+
* - 端口被 clash 内核签名进程占用 → 清理后拉起(接管);非内核程序 → port-busy 不抢
|
|
603
|
+
* - 拉起冷却期内 → cooldown(force 绕过)
|
|
604
|
+
* - 解压 + 生成配置 + detached 拉起 + 等 API → started / failed
|
|
605
|
+
*
|
|
606
|
+
* @returns {Promise<{status:'no-subscription'|'adopted'|'started'|'port-busy'|'cooldown'|'no-vendor'|'failed', error?:string, bin?:string}>}
|
|
607
|
+
*/
|
|
608
|
+
export async function ensureMihomoRunning({
|
|
609
|
+
subscriptions,
|
|
610
|
+
force = false,
|
|
611
|
+
apiPort = DISCOVER_DEFAULTS.port,
|
|
612
|
+
secret = DISCOVER_DEFAULTS.secret,
|
|
613
|
+
mixedPort = DISCOVER_DEFAULTS.proxyPort,
|
|
614
|
+
log = () => {},
|
|
615
|
+
} = {}) {
|
|
616
|
+
// 1) 订阅 = 接管授权: 无订阅不拉起、不占位、不碰任何现有代理(用户可自用 Verge)
|
|
617
|
+
const subs = normalizeSubscriptions(subscriptions || readStoredSubscriptions());
|
|
618
|
+
if (!subs.length) {
|
|
619
|
+
log("⏭ 未配置订阅,内置 mihomo 不参与(后台配置订阅后自动接管)");
|
|
620
|
+
return { status: "no-subscription" };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// 2) 控制端已可用 → 认领(force=订阅接管模式跳过,用于换掉外来内核)
|
|
624
|
+
if (!force && (await controllerAlive(apiPort, secret))) return { status: "adopted" };
|
|
625
|
+
|
|
626
|
+
// 3) 冷却期(force=用户主动订阅动作,绕过)
|
|
627
|
+
const gap = Date.now() - _lastSpawnAt;
|
|
628
|
+
if (!force && gap < SPAWN_COOLDOWN_MS) {
|
|
629
|
+
return { status: "cooldown", error: `拉起冷却中 ${Math.ceil((SPAWN_COOLDOWN_MS - gap) / 1000)}s` };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// 4) 端口占用 → 仅清理 clash 内核签名占用者(Verge 内核等),其余不抢
|
|
633
|
+
for (const p of [apiPort, mixedPort]) {
|
|
634
|
+
if (await isPortListening(p)) {
|
|
635
|
+
const kr = await killClashCoreOnPort(p, { log });
|
|
636
|
+
if (kr !== "killed" && kr !== "none") {
|
|
637
|
+
return { status: "port-busy", error: `端口 ${p} 被非内核程序占用,不自动清理` };
|
|
638
|
+
}
|
|
639
|
+
if (await isPortListening(p)) {
|
|
640
|
+
return { status: "port-busy", error: `端口 ${p} 清理后仍被占用` };
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// 5) 落位 + 生成配置
|
|
646
|
+
_lastSpawnAt = Date.now();
|
|
647
|
+
const coreR = await ensureBinary({ log });
|
|
648
|
+
if (!coreR.ok) return { status: "no-vendor", error: coreR.error };
|
|
649
|
+
const uiR = await ensureUI({ log });
|
|
650
|
+
if (!uiR.ok) log(`⚠ 面板就位失败(不影响代理): ${uiR.error}`);
|
|
651
|
+
const cfgR = writeConfig({ subscriptions: subs, apiPort, secret, mixedPort });
|
|
652
|
+
if (!cfgR.ok) return { status: "failed", error: `config.yaml 写入失败: ${cfgR.error}` };
|
|
653
|
+
|
|
654
|
+
// 6) detached 拉起(daemon 退出不影响代理),日志追加到 run.log
|
|
655
|
+
const paths = runtimePaths();
|
|
656
|
+
let logFd;
|
|
657
|
+
try {
|
|
658
|
+
logFd = openSync(paths.logFile, "a");
|
|
659
|
+
} catch {
|
|
660
|
+
logFd = "ignore";
|
|
661
|
+
}
|
|
662
|
+
const child = spawn(coreR.bin, ["-d", RUNTIME_DIR, "-f", paths.configFile], {
|
|
663
|
+
detached: true,
|
|
664
|
+
stdio: ["ignore", logFd, logFd],
|
|
665
|
+
windowsHide: true,
|
|
666
|
+
});
|
|
667
|
+
child.unref();
|
|
668
|
+
|
|
669
|
+
// 7) 等 API 就绪(并确认监听者确实是本次拉起的进程,防止旧内核仍在位造成假阳性)
|
|
670
|
+
const t0 = Date.now();
|
|
671
|
+
while (Date.now() - t0 < 20_000) {
|
|
672
|
+
await delay(600);
|
|
673
|
+
if (await controllerAlive(apiPort, secret, 1200)) {
|
|
674
|
+
if (!(await runtimeControllerIsOurs(apiPort))) {
|
|
675
|
+
return { status: "failed", error: "API 已就绪但监听进程非本次拉起的内置内核(端口被旧内核抢占)" };
|
|
676
|
+
}
|
|
677
|
+
log(`✓ 内置 mihomo 已拉起 (pid ${child.pid}),面板: http://127.0.0.1:${apiPort}/ui/`);
|
|
678
|
+
return { status: "started", pid: child.pid, bin: coreR.bin };
|
|
679
|
+
}
|
|
680
|
+
if (child.exitCode !== null) {
|
|
681
|
+
return { status: "failed", error: `mihomo 进程退出 (code ${child.exitCode}),见 ${paths.logFile}` };
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return { status: "failed", error: "mihomo 启动后 API 20s 未就绪" };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* 应用订阅变更: 写 config → 热加载(不重启进程)→ 校验 provider 上线
|
|
689
|
+
* 必须在目标控制端 API 可用时调用(由 daemon 在控制器上下文中调用)
|
|
690
|
+
* @returns {Promise<{ok:boolean, changed?:boolean, providers?:Array, error?:string}>}
|
|
691
|
+
*/
|
|
692
|
+
export async function applySubscriptions(
|
|
693
|
+
subscriptions,
|
|
694
|
+
{ apiPort = DISCOVER_DEFAULTS.port, secret = DISCOVER_DEFAULTS.secret, mixedPort = DISCOVER_DEFAULTS.proxyPort, log = () => {} } = {},
|
|
695
|
+
) {
|
|
696
|
+
const subs = normalizeSubscriptions(subscriptions);
|
|
697
|
+
if (!subs.length) return { ok: false, error: "订阅列表为空" };
|
|
698
|
+
|
|
699
|
+
const cfgR = writeConfig({ subscriptions: subs, apiPort, secret, mixedPort });
|
|
700
|
+
if (!cfgR.ok) return { ok: false, error: cfgR.error };
|
|
701
|
+
|
|
702
|
+
if (!cfgR.changed) {
|
|
703
|
+
log("订阅配置无变化,跳过热加载");
|
|
704
|
+
return { ok: true, changed: false };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// 热加载(带 path 明确指向运行时配置);失败则回滚盘上配置与内核状态一致
|
|
708
|
+
try {
|
|
709
|
+
const headers = { "Content-Type": "application/json" };
|
|
710
|
+
if (secret) headers.Authorization = `Bearer ${secret}`;
|
|
711
|
+
const res = await fetch(`http://127.0.0.1:${apiPort}/configs?force=true`, {
|
|
712
|
+
method: "PUT",
|
|
713
|
+
headers,
|
|
714
|
+
body: JSON.stringify({ path: runtimePaths().configFile }),
|
|
715
|
+
signal: AbortSignal.timeout(15_000),
|
|
716
|
+
});
|
|
717
|
+
if (res.status !== 204) {
|
|
718
|
+
const t = await res.text().catch(() => "");
|
|
719
|
+
await rollbackConfig(cfgR.prevContent, { apiPort, secret });
|
|
720
|
+
return { ok: false, error: `热加载 HTTP ${res.status}: ${t.slice(0, 120)}(已回滚原配置)` };
|
|
721
|
+
}
|
|
722
|
+
} catch (e) {
|
|
723
|
+
await rollbackConfig(cfgR.prevContent, { apiPort, secret });
|
|
724
|
+
return { ok: false, error: `热加载失败: ${e.message}(已回滚盘上配置)` };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const v = await verifyProviders(subs, { apiPort, secret, log });
|
|
728
|
+
if (!v.ok && cfgR.prevContent) {
|
|
729
|
+
// 新订阅不可用(URL 失效/机场挂了)→ 回滚到上一个可用配置,保持代理不断
|
|
730
|
+
await rollbackConfig(cfgR.prevContent, { apiPort, secret });
|
|
731
|
+
log("⚠ 新订阅不可用,已回滚到原配置(持久化未变更,代理保持可用)");
|
|
732
|
+
return { ...v, rolledBack: true };
|
|
733
|
+
}
|
|
734
|
+
return v;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** 回滚 config.yaml 并热加载(保持盘上配置与运行内核一致) */
|
|
738
|
+
async function rollbackConfig(content, { apiPort = DISCOVER_DEFAULTS.port, secret = DISCOVER_DEFAULTS.secret } = {}) {
|
|
739
|
+
try {
|
|
740
|
+
if (!content) return;
|
|
741
|
+
writeFileSync(runtimePaths().configFile, content);
|
|
742
|
+
const headers = { "Content-Type": "application/json" };
|
|
743
|
+
if (secret) headers.Authorization = `Bearer ${secret}`;
|
|
744
|
+
await fetch(`http://127.0.0.1:${apiPort}/configs?force=true`, {
|
|
745
|
+
method: "PUT",
|
|
746
|
+
headers,
|
|
747
|
+
body: JSON.stringify({ path: runtimePaths().configFile }),
|
|
748
|
+
signal: AbortSignal.timeout(15_000),
|
|
749
|
+
});
|
|
750
|
+
} catch {}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** 等待并校验 provider 上线(热应用/接管共用) */
|
|
754
|
+
export async function verifyProviders(
|
|
755
|
+
subs,
|
|
756
|
+
{ apiPort = DISCOVER_DEFAULTS.port, secret = DISCOVER_DEFAULTS.secret, log = () => {}, waitMs = 3000 } = {},
|
|
757
|
+
) {
|
|
758
|
+
await delay(waitMs);
|
|
759
|
+
try {
|
|
760
|
+
const headers = {};
|
|
761
|
+
if (secret) headers.Authorization = `Bearer ${secret}`;
|
|
762
|
+
const res = await fetch(`http://127.0.0.1:${apiPort}/providers/proxies`, {
|
|
763
|
+
headers,
|
|
764
|
+
signal: AbortSignal.timeout(10_000),
|
|
765
|
+
});
|
|
766
|
+
const data = await res.json();
|
|
767
|
+
const providers = [];
|
|
768
|
+
for (const [name, p] of Object.entries(data.providers || {})) {
|
|
769
|
+
if (p.vehicleType !== "HTTP") continue;
|
|
770
|
+
const si = p.subscriptionInfo || {};
|
|
771
|
+
providers.push({
|
|
772
|
+
name,
|
|
773
|
+
proxies: (p.proxies || []).length,
|
|
774
|
+
upload: si.Upload || 0,
|
|
775
|
+
download: si.Download || 0,
|
|
776
|
+
total: si.Total || 0,
|
|
777
|
+
expire: si.Expire || 0,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
const got = new Map(providers.map((x) => [x.name, x]));
|
|
781
|
+
// 死 URL 的 provider 也会出现在列表里但 0 节点 → 必须节点数 > 0 才算上线
|
|
782
|
+
const missing = subs.filter((s) => !((got.get(s.name)?.proxies || 0) > 0)).map((m) => m.name);
|
|
783
|
+
if (missing.length) {
|
|
784
|
+
return { ok: false, error: `provider 未上线或 0 节点: ${missing.join(",")}`, providers };
|
|
785
|
+
}
|
|
786
|
+
log(`✓ 订阅应用完成: ${providers.map((p) => `${p.name}(${p.proxies}节点)`).join(", ")}`);
|
|
787
|
+
return { ok: true, changed: true, providers };
|
|
788
|
+
} catch (e) {
|
|
789
|
+
return { ok: false, error: `provider 校验失败: ${e.message}` };
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* 用户订阅应用入口(后台配置下发唯一路径)。
|
|
795
|
+
*
|
|
796
|
+
* 订阅 = 接管授权(2026-09-25 设计):
|
|
797
|
+
* - 内置内核在位 → 热应用(不中断代理;盘上配置一致则短路,顺便自愈崩溃残留)
|
|
798
|
+
* - 外来内核(Verge 等)占位或无控制端 → 清理端口内核 + 带订阅拉起内置 mihomo
|
|
799
|
+
* - 新订阅不可用(0 节点/热加载失败)→ 自动回滚上一个可用配置,代理不断
|
|
800
|
+
*/
|
|
801
|
+
export async function applyUserSubscriptions(
|
|
802
|
+
subs,
|
|
803
|
+
{ apiPort = DISCOVER_DEFAULTS.port, secret = DISCOVER_DEFAULTS.secret, log = () => {} } = {},
|
|
804
|
+
) {
|
|
805
|
+
subs = normalizeSubscriptions(subs);
|
|
806
|
+
if (!subs.length) return { ok: false, error: "订阅列表为空" };
|
|
807
|
+
|
|
808
|
+
const alive = await controllerAlive(apiPort, secret);
|
|
809
|
+
const ours = alive && (await runtimeControllerIsOurs(apiPort));
|
|
810
|
+
|
|
811
|
+
if (ours) {
|
|
812
|
+
// 热应用;盘上配置已一致时由 applySubscriptions 内部短路(顺便自愈崩溃残留)
|
|
813
|
+
return applySubscriptions(subs, { apiPort, secret, log });
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// 外来内核占位或无控制端 → 接管
|
|
817
|
+
const r = await ensureMihomoRunning({ subscriptions: subs, force: true, apiPort, secret, log });
|
|
818
|
+
if (r.status !== "started") {
|
|
819
|
+
return { ok: false, error: `接管失败: ${r.status}${r.error ? ` ${r.error}` : ""}` };
|
|
820
|
+
}
|
|
821
|
+
const v = await verifyProviders(subs, { apiPort, secret, log });
|
|
822
|
+
return v.ok ? { ok: true, changed: true, providers: v.providers } : v;
|
|
823
|
+
}
|