mihomo-cli 3.8.0 → 3.8.1
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/CHANGELOG.md +38 -0
- package/dist/index.js +287 -67
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.8.1] - 2026-09-01
|
|
4
|
+
|
|
5
|
+
一轮全量代码审查的修复集:1 项新发现的高危数据丢失,以及上轮审查遗留的 9 项「仍待处理」。全部经实际复现确认,修复后回归验证。
|
|
6
|
+
|
|
7
|
+
### 修复
|
|
8
|
+
|
|
9
|
+
- **并发操作会静默丢数据(高危)** - `settings.json` 无跨进程锁,两个 CLI 进程各自读旧全量再写回,后写者把先写者的改动整块抹掉,**而先写者已经打印了「已添加」**。实测 6 个并发 `sub add` 丢 3 条;慢速 `sub add`(跨网络下载)期间执行 `tunnel add`,隧道被抹成 `null`——订阅与隧道同住一个文件,互不相干的命令互相摧毁。触发条件很日常:机场慢时 `sub add` 要跑十几秒,此间在另一个终端做点别的即可
|
|
10
|
+
|
|
11
|
+
修法是新增文件锁(`O_EXCL`)把整个「读-改-写」圈起来。仅「写前重读盘」不够——读与写之间仍有窗口,实测仍丢 3 条。持锁超过 10s 视为进程已崩溃并强夺,避免一次崩溃永久锁死后续所有命令
|
|
12
|
+
|
|
13
|
+
- **内核下载来源未钉死(供应链)** - `--mirror-all` 下连 GitHub API 都走镜像,`browser_download_url` 完全由镜像说了算,而此前对非 github 的 URL 原样放行,镜像返回任意主机地址即可让 CLI 下载任意二进制;该产物随后 `chmod 755` 并在 TUN/daemon 下**以 root 运行**。现加下载 host 白名单 + 强制 https(校验在加镜像前缀之前),curl 补 `--proto '=https' --proto-redir '=https'`(实测 `-L` 会跟着 302 降级到明文 http 并落盘)与 `--max-filesize`,下载后比对 `asset.size`
|
|
14
|
+
|
|
15
|
+
- **归档 symlink 成员可 chmod 任意文件** - 名为 `mihomo`、指向任意路径的符号链接,其条目名完全合法(不含 `..`、非绝对路径),能通过路径穿越守卫,随后被当成二进制 `chmod 755`——实测把 `chmod 600` 的文件改成了 755。现同时校验条目**类型**(拒符号/硬链接)并把遍历的 `statSync` 改为 `lstatSync`
|
|
16
|
+
|
|
17
|
+
- **畸形流量响应头会抹掉已有用量数据** - `Subscription-Userinfo` 返回 `garbage` 这类无有效字段的头时,解析结果 `{}` 是 truthy,导致 upload/download/total/expire 四个字段被 `undefined` 覆盖。反直觉的是**没有响应头反而安全**(整块跳过)。另修 `expire=abc` 被塞 0 而显示成「永久有效」(垃圾值朝最误导的方向失败)、`total=1e999` 写成 `null`、负数原样入库使百分比失真
|
|
18
|
+
|
|
19
|
+
- **时钟偏移导致订阅永不自动更新** - `updated_at` 落在未来(系统时间被改过、跨时区调时)时时间差恒为负,`needsAutoUpdate` 恒 false,订阅静默过期到失联
|
|
20
|
+
|
|
21
|
+
- **`daemon off` 在 plist 被手动删除后是空操作** - 用户 `sudo rm` 掉 plist 后任务仍处装载状态,此前只看文件存在就直接返回,永不执行 `launchctl bootout`,于是 KeepAlive 继续把内核拉起——杀掉立刻重生,且 CLI 无任何途径卸载。现判据改为「plist 不在**且**无 root 内核在跑」
|
|
22
|
+
|
|
23
|
+
- **`daemon on` 遗留 root 属主 pid 文件** - 使后续 `daemon off` → `start` 撞上残留检查而拒绝启动,这个死胡同完全由 CLI 自身的 on/off 循环造成
|
|
24
|
+
|
|
25
|
+
- **热重载信任 9090 上的任意响应** - 该端口被其他服务占用且对 PUT 返回 2xx 时,CLI 打印「已重启 (保活)」而 daemon 内核仍跑旧配置,配置变更静默未生效。现先确认应答方确为 mihomo
|
|
26
|
+
|
|
27
|
+
- **`tunnel up` 期间 Ctrl+C 泄漏 ssh 进程** - 等待转发建立最长 20 秒,其间中断会留下孤儿 ssh 进程和一份声称健康的运行态文件(实测持久残留、不自愈),随后 `tunnel up` 报「已在运行」而 `status` 报「假活」,自相矛盾。现在该窗口内注册一次性清理,启动成功后立即注销以保持「隧道活过 CLI 退出」的语义
|
|
28
|
+
|
|
29
|
+
- **`__proto__` 作订阅名时缓存永远写不进** - 该名称通过校验,但在普通对象上赋值是设置原型而非自有属性,落盘为 `{}`,`updated_at` 永远缺失 → 每次 `start` 都重新下载该订阅
|
|
30
|
+
|
|
31
|
+
- **配置校验的四个静默空洞** - 均无任何告警:proxy 与 proxy-group 同名(两者都留下,mihomo 因重复名拒绝加载);`use` 引用不存在的 provider(从不校验,且使该组免于删除);节点池为空 + `include-all` 的空组保留(实际无任何出口);`proxies` 写成字符串时整体跳过校验、非法结构原样落盘
|
|
32
|
+
|
|
33
|
+
- **`--mirror` 接受非法 scheme** - 此前用 `startsWith('http')` 判断,放行 `httpfoo://x` 与明文 `http://`,并把 `ftp://e.test` 拼成 `https://ftp://e.test/`。现改用 `URL` 解析 + https 白名单
|
|
34
|
+
|
|
35
|
+
- **sudo 中间脚本的权限位不重放** - `writeFileSync` 的 `mode` 只在创建新文件时生效,前次崩溃残留的同名文件会保留旧权限(实测 0666 重写后仍是 0666),而该文件下一步就交给 sudo 执行。两处写后补显式 `chmodSync`
|
|
36
|
+
|
|
37
|
+
### 测试
|
|
38
|
+
|
|
39
|
+
- 单测 145 → 165:文件锁 6 项(含用真实子进程验证跨进程互斥、陈旧锁强夺、异常路径释放)、`parseUserInfo` 边界 8 项、配置校验空洞 6 项(含「正常配置不产生告警」的回归用例)
|
|
40
|
+
|
|
3
41
|
## [3.8.0] - 2026-09-01
|
|
4
42
|
|
|
5
43
|
### 新增
|
package/dist/index.js
CHANGED
|
@@ -3233,6 +3233,55 @@ function atomicWriteFileSync(filePath, content, options) {
|
|
|
3233
3233
|
function rmrf(dir) {
|
|
3234
3234
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
3235
3235
|
}
|
|
3236
|
+
var LOCK_STALE_MS = 1e4;
|
|
3237
|
+
var LOCK_RETRY_MS = 20;
|
|
3238
|
+
function withFileLock(filePath, fn) {
|
|
3239
|
+
const lockPath = `${filePath}.lock`;
|
|
3240
|
+
const deadline = Date.now() + LOCK_STALE_MS;
|
|
3241
|
+
let fd = null;
|
|
3242
|
+
while (fd === null) {
|
|
3243
|
+
try {
|
|
3244
|
+
fd = fs.openSync(lockPath, "wx");
|
|
3245
|
+
} catch (e) {
|
|
3246
|
+
if (e.code !== "EEXIST") throw e;
|
|
3247
|
+
let stale = false;
|
|
3248
|
+
try {
|
|
3249
|
+
stale = Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS;
|
|
3250
|
+
} catch {
|
|
3251
|
+
}
|
|
3252
|
+
if (stale) {
|
|
3253
|
+
try {
|
|
3254
|
+
fs.rmSync(lockPath, { force: true });
|
|
3255
|
+
} catch {
|
|
3256
|
+
}
|
|
3257
|
+
continue;
|
|
3258
|
+
}
|
|
3259
|
+
if (Date.now() > deadline) {
|
|
3260
|
+
try {
|
|
3261
|
+
fs.rmSync(lockPath, { force: true });
|
|
3262
|
+
} catch {
|
|
3263
|
+
}
|
|
3264
|
+
continue;
|
|
3265
|
+
}
|
|
3266
|
+
sleepSyncMs(LOCK_RETRY_MS);
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
try {
|
|
3270
|
+
return fn();
|
|
3271
|
+
} finally {
|
|
3272
|
+
try {
|
|
3273
|
+
fs.closeSync(fd);
|
|
3274
|
+
} catch {
|
|
3275
|
+
}
|
|
3276
|
+
try {
|
|
3277
|
+
fs.rmSync(lockPath, { force: true });
|
|
3278
|
+
} catch {
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
function sleepSyncMs(ms) {
|
|
3283
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
3284
|
+
}
|
|
3236
3285
|
|
|
3237
3286
|
// src/settings.ts
|
|
3238
3287
|
import fs2 from "fs";
|
|
@@ -3269,6 +3318,10 @@ function recoverCorruptedSettings() {
|
|
|
3269
3318
|
}
|
|
3270
3319
|
function writeSettings(settings) {
|
|
3271
3320
|
ensureDirs();
|
|
3321
|
+
return withFileLock(PATHS.settingsFile, () => writeSettingsUnlocked(settings));
|
|
3322
|
+
}
|
|
3323
|
+
function writeSettingsUnlocked(settings) {
|
|
3324
|
+
settingsCache = null;
|
|
3272
3325
|
const existing = readSettings();
|
|
3273
3326
|
const merged = { ...existing, ...settings };
|
|
3274
3327
|
for (const key of Object.keys(settings)) {
|
|
@@ -3278,6 +3331,14 @@ function writeSettings(settings) {
|
|
|
3278
3331
|
settingsCache = merged;
|
|
3279
3332
|
return settingsCache;
|
|
3280
3333
|
}
|
|
3334
|
+
function updateSettings(mutate) {
|
|
3335
|
+
ensureDirs();
|
|
3336
|
+
return withFileLock(PATHS.settingsFile, () => {
|
|
3337
|
+
settingsCache = null;
|
|
3338
|
+
const current = readSettings();
|
|
3339
|
+
return writeSettingsUnlocked(mutate(current));
|
|
3340
|
+
});
|
|
3341
|
+
}
|
|
3281
3342
|
function invalidateSettingsCache() {
|
|
3282
3343
|
settingsCache = null;
|
|
3283
3344
|
}
|
|
@@ -3323,10 +3384,13 @@ function maskUrl(url) {
|
|
|
3323
3384
|
}
|
|
3324
3385
|
function readSubscriptionCache() {
|
|
3325
3386
|
ensureDirs();
|
|
3387
|
+
const empty = () => /* @__PURE__ */ Object.create(null);
|
|
3326
3388
|
if (fs2.existsSync(PATHS.subscriptionsCacheFile)) {
|
|
3327
3389
|
try {
|
|
3328
3390
|
const content = fs2.readFileSync(PATHS.subscriptionsCacheFile, "utf8");
|
|
3329
|
-
|
|
3391
|
+
const parsed = JSON.parse(content);
|
|
3392
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return empty();
|
|
3393
|
+
return Object.assign(empty(), parsed);
|
|
3330
3394
|
} catch {
|
|
3331
3395
|
try {
|
|
3332
3396
|
fs2.copyFileSync(PATHS.subscriptionsCacheFile, `${PATHS.subscriptionsCacheFile}.bak`);
|
|
@@ -3334,10 +3398,10 @@ function readSubscriptionCache() {
|
|
|
3334
3398
|
} catch {
|
|
3335
3399
|
console.warn("\u8B66\u544A: \u8BA2\u9605\u7F13\u5B58\u683C\u5F0F\u635F\u574F\uFF0C\u5DF2\u5FFD\u7565");
|
|
3336
3400
|
}
|
|
3337
|
-
return
|
|
3401
|
+
return empty();
|
|
3338
3402
|
}
|
|
3339
3403
|
}
|
|
3340
|
-
return
|
|
3404
|
+
return empty();
|
|
3341
3405
|
}
|
|
3342
3406
|
function writeSubscriptionCache(cache) {
|
|
3343
3407
|
ensureDirs();
|
|
@@ -3375,31 +3439,41 @@ function validateSubscriptionName(name) {
|
|
|
3375
3439
|
}
|
|
3376
3440
|
function addSubscription(url, name = "default") {
|
|
3377
3441
|
validateSubscriptionName(name);
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3442
|
+
let duplicate = false;
|
|
3443
|
+
updateSettings((settings) => {
|
|
3444
|
+
const subs = [...getSubscriptions()];
|
|
3445
|
+
if (subs.some((s) => s.name === name)) {
|
|
3446
|
+
duplicate = true;
|
|
3447
|
+
return {};
|
|
3448
|
+
}
|
|
3449
|
+
subs.push({ name, url });
|
|
3450
|
+
const updates = { subscriptions: subs };
|
|
3451
|
+
if (!settings.active_subscription && subs.length === 1) {
|
|
3452
|
+
updates.active_subscription = name;
|
|
3453
|
+
}
|
|
3454
|
+
return updates;
|
|
3455
|
+
});
|
|
3456
|
+
if (duplicate) {
|
|
3381
3457
|
throw new CliError(`\u8BA2\u9605 "${name}" \u5DF2\u5B58\u5728\uFF0C\u8BF7\u6362\u4E2A\u540D\u79F0\uFF08mihomo sub add <url> <\u540D\u79F0>\uFF09\uFF0C\u6216\u5148\u5220\u9664\uFF08mihomo sub remove ${name}\uFF09`);
|
|
3382
3458
|
}
|
|
3383
|
-
subs.push({ name, url });
|
|
3384
|
-
const updates = { subscriptions: subs };
|
|
3385
|
-
if (!settings.active_subscription && subs.length === 1) {
|
|
3386
|
-
updates.active_subscription = name;
|
|
3387
|
-
}
|
|
3388
|
-
writeSettings(updates);
|
|
3389
3459
|
}
|
|
3390
3460
|
function removeSubscription(name) {
|
|
3391
|
-
const settings = readSettings();
|
|
3392
|
-
const subs = [...getSubscriptions()];
|
|
3393
|
-
const idx = subs.findIndex((s) => s.name === name);
|
|
3394
|
-
if (idx < 0) return null;
|
|
3395
|
-
subs.splice(idx, 1);
|
|
3396
|
-
const updates = { subscriptions: subs };
|
|
3397
3461
|
let switchedTo = null;
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3462
|
+
let found = false;
|
|
3463
|
+
updateSettings((settings) => {
|
|
3464
|
+
const subs = [...getSubscriptions()];
|
|
3465
|
+
const idx = subs.findIndex((s) => s.name === name);
|
|
3466
|
+
if (idx < 0) return {};
|
|
3467
|
+
found = true;
|
|
3468
|
+
subs.splice(idx, 1);
|
|
3469
|
+
const updates = { subscriptions: subs };
|
|
3470
|
+
if (settings.active_subscription === name) {
|
|
3471
|
+
switchedTo = subs.length > 0 ? subs[0].name : null;
|
|
3472
|
+
updates.active_subscription = switchedTo ?? void 0;
|
|
3473
|
+
}
|
|
3474
|
+
return updates;
|
|
3475
|
+
});
|
|
3476
|
+
if (!found) return null;
|
|
3403
3477
|
const cache = readSubscriptionCache();
|
|
3404
3478
|
if (cache[name]) {
|
|
3405
3479
|
delete cache[name];
|
|
@@ -3859,14 +3933,24 @@ function suggestSimilar(input, candidates) {
|
|
|
3859
3933
|
function normalizeMirrorUrl(val) {
|
|
3860
3934
|
if (!val) return null;
|
|
3861
3935
|
if (val === "direct" || val === "no" || val === "none") return null;
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3936
|
+
const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(val) ? val : `https://${val}`;
|
|
3937
|
+
let parsed;
|
|
3938
|
+
try {
|
|
3939
|
+
parsed = new URL(withScheme);
|
|
3940
|
+
} catch {
|
|
3941
|
+
throw new CliError(`\u955C\u50CF\u5730\u5740\u65E0\u6548: "${val}"`, {
|
|
3942
|
+
label: "\u53C2\u6570\u9519\u8BEF",
|
|
3943
|
+
hint: ["\u683C\u5F0F\u5982: --mirror https://gh-proxy.org/ \u6216 --mirror gh-proxy.org", "\u4E0D\u4F7F\u7528\u955C\u50CF: --no-mirror"]
|
|
3944
|
+
});
|
|
3865
3945
|
}
|
|
3866
|
-
if (
|
|
3867
|
-
|
|
3946
|
+
if (parsed.protocol !== "https:") {
|
|
3947
|
+
throw new CliError(`\u955C\u50CF\u5730\u5740\u5FC5\u987B\u4F7F\u7528 https: "${val}"`, {
|
|
3948
|
+
label: "\u53C2\u6570\u9519\u8BEF",
|
|
3949
|
+
hint: ["\u955C\u50CF\u4F1A\u4E2D\u8F6C\u5185\u6838\u4E8C\u8FDB\u5236\uFF0C\u8BE5\u4EA7\u7269\u968F\u540E\u4EE5 root \u8FD0\u884C\uFF0C\u4E0D\u5141\u8BB8\u660E\u6587\u4F20\u8F93\u3002"]
|
|
3950
|
+
});
|
|
3868
3951
|
}
|
|
3869
|
-
|
|
3952
|
+
const url = parsed.toString();
|
|
3953
|
+
return url.endsWith("/") ? url : `${url}/`;
|
|
3870
3954
|
}
|
|
3871
3955
|
function parseMirrorArg(args) {
|
|
3872
3956
|
if (!args || args.length < 2) {
|
|
@@ -4045,6 +4129,14 @@ function validateConfig(config) {
|
|
|
4045
4129
|
warnings.push(`\u79FB\u9664\u4E86 ${groupDedup.duplicates.length} \u4E2A\u91CD\u540D\u5206\u7EC4: ${groupDedup.duplicates.map((n) => `"${n}"`).join(", ")}`);
|
|
4046
4130
|
}
|
|
4047
4131
|
const validNames = /* @__PURE__ */ new Set([...BUILTIN_PROXY_NAMES, ...proxyDedup.names, ...groupDedup.names]);
|
|
4132
|
+
for (const name of groupDedup.names) {
|
|
4133
|
+
if (proxyDedup.names.has(name)) {
|
|
4134
|
+
warnings.push(`\u540D\u79F0\u51B2\u7A81: "${name}" \u540C\u65F6\u662F\u8282\u70B9\u548C\u5206\u7EC4\u540D\uFF0Cmihomo \u4F1A\u62D2\u7EDD\u52A0\u8F7D\uFF08\u8BF7\u91CD\u547D\u540D\u5176\u4E00\uFF09`);
|
|
4135
|
+
}
|
|
4136
|
+
}
|
|
4137
|
+
const providerNames = new Set(
|
|
4138
|
+
config["proxy-providers"] && typeof config["proxy-providers"] === "object" && !Array.isArray(config["proxy-providers"]) ? Object.keys(config["proxy-providers"]) : []
|
|
4139
|
+
);
|
|
4048
4140
|
const activeGroups = groupDedup.result;
|
|
4049
4141
|
const removedGroups = /* @__PURE__ */ new Set();
|
|
4050
4142
|
let changed = true;
|
|
@@ -4052,12 +4144,32 @@ function validateConfig(config) {
|
|
|
4052
4144
|
changed = false;
|
|
4053
4145
|
for (const group of activeGroups) {
|
|
4054
4146
|
if (removedGroups.has(group.name)) continue;
|
|
4147
|
+
if (group.proxies !== void 0 && !Array.isArray(group.proxies)) {
|
|
4148
|
+
if (typeof group.proxies === "string") {
|
|
4149
|
+
warnings.push(`proxy-group "${group.name}": proxies \u5E94\u4E3A\u5217\u8868\uFF0C\u5DF2\u6309\u5355\u5143\u7D20\u5904\u7406\uFF08"${group.proxies}"\uFF09`);
|
|
4150
|
+
group.proxies = [group.proxies];
|
|
4151
|
+
} else {
|
|
4152
|
+
warnings.push(`proxy-group "${group.name}": proxies \u4E0D\u662F\u5217\u8868\uFF0C\u5DF2\u5FFD\u7565\u8BE5\u5B57\u6BB5`);
|
|
4153
|
+
group.proxies = [];
|
|
4154
|
+
}
|
|
4155
|
+
}
|
|
4055
4156
|
if (!Array.isArray(group.proxies)) continue;
|
|
4157
|
+
if (Array.isArray(group.use)) {
|
|
4158
|
+
const ghosts = group.use.filter((u) => typeof u === "string" && !providerNames.has(u));
|
|
4159
|
+
if (ghosts.length > 0) {
|
|
4160
|
+
group.use = group.use.filter((u) => providerNames.has(u));
|
|
4161
|
+
warnings.push(`proxy-group "${group.name}": \u79FB\u9664\u4E86\u4E0D\u5B58\u5728\u7684 provider \u5F15\u7528 ${ghosts.map((n) => `"${n}"`).join(", ")}`);
|
|
4162
|
+
}
|
|
4163
|
+
}
|
|
4056
4164
|
const invalid = group.proxies.filter((name) => !validNames.has(name));
|
|
4057
|
-
if (invalid.length
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4165
|
+
if (invalid.length > 0) {
|
|
4166
|
+
group.proxies = group.proxies.filter((name) => validNames.has(name));
|
|
4167
|
+
warnings.push(`proxy-group "${group.name}": \u79FB\u9664\u4E86\u4E0D\u5B58\u5728\u7684\u5F15\u7528 ${invalid.map((n) => `"${n}"`).join(", ")}`);
|
|
4168
|
+
}
|
|
4169
|
+
const hasUse = Array.isArray(group.use) ? group.use.length > 0 : Boolean(group.use);
|
|
4170
|
+
const includesAll = Boolean(group["include-all"] || group["include-all-proxies"]);
|
|
4171
|
+
const includeAllUsable = includesAll && proxyDedup.result.length > 0;
|
|
4172
|
+
const hasOtherSource = hasUse || includeAllUsable;
|
|
4061
4173
|
if (group.proxies.length === 0 && !hasOtherSource) {
|
|
4062
4174
|
removedGroups.add(group.name);
|
|
4063
4175
|
validNames.delete(group.name);
|
|
@@ -4551,6 +4663,7 @@ exit 2
|
|
|
4551
4663
|
`;
|
|
4552
4664
|
const scriptPath = path4.join(DIRS.runtime, "launch-tun.sh");
|
|
4553
4665
|
fs5.writeFileSync(scriptPath, scriptContent, { mode: 448 });
|
|
4666
|
+
fs5.chmodSync(scriptPath, 448);
|
|
4554
4667
|
return scriptPath;
|
|
4555
4668
|
}
|
|
4556
4669
|
function getProcessInfo(pid) {
|
|
@@ -4920,6 +5033,7 @@ function runSudoScript(scriptBody, opts) {
|
|
|
4920
5033
|
ensureDirs();
|
|
4921
5034
|
const scriptPath = path5.join(DIRS.runtime, opts.file);
|
|
4922
5035
|
fs6.writeFileSync(scriptPath, scriptBody, { mode: 448 });
|
|
5036
|
+
fs6.chmodSync(scriptPath, 448);
|
|
4923
5037
|
try {
|
|
4924
5038
|
const result = spawnSync3("sudo", [scriptPath], { stdio: "inherit", timeout: SUDO_TIMEOUT_MS });
|
|
4925
5039
|
if (result.error) throw result.error;
|
|
@@ -4968,10 +5082,15 @@ function enableDaemon() {
|
|
|
4968
5082
|
const plistDest = shellQuote(PATHS.launchDaemonPlist);
|
|
4969
5083
|
const stage = shellQuote(stagePath);
|
|
4970
5084
|
const pattern = shellQuote(MAIN_INSTANCE_PATTERN);
|
|
5085
|
+
const pidFile = shellQuote(PATHS.pidFile);
|
|
4971
5086
|
const script = [
|
|
4972
5087
|
"#!/bin/bash",
|
|
4973
5088
|
`launchctl bootout ${target} 2>/dev/null || true`,
|
|
4974
5089
|
`pkill -9 -f ${pattern} 2>/dev/null || true`,
|
|
5090
|
+
// 一并清掉 pid 文件:`start tun` 留下的是 **root 属主**的 pid 文件,普通用户删不掉。
|
|
5091
|
+
// 不删会让后续 `daemon off` → `start` 撞上 hasRootResidue() 的拒绝启动,
|
|
5092
|
+
// 而这个死胡同完全由 CLI 自身的 on/off 循环造成
|
|
5093
|
+
`rm -f ${pidFile}`,
|
|
4975
5094
|
"sleep 0.2",
|
|
4976
5095
|
`install -m 644 -o root -g wheel ${stage} ${plistDest} || exit 2`,
|
|
4977
5096
|
`launchctl bootstrap system ${plistDest} || { launchctl bootout ${target} 2>/dev/null; rm -f ${plistDest}; exit 3; }`,
|
|
@@ -4996,7 +5115,13 @@ function enableDaemon() {
|
|
|
4996
5115
|
}
|
|
4997
5116
|
function disableDaemon() {
|
|
4998
5117
|
assertDaemonLabelSafe();
|
|
4999
|
-
|
|
5118
|
+
const plistExists = isDaemonEnabled();
|
|
5119
|
+
const rootKernelRunning = getMihomoPids().some(isProcessRoot);
|
|
5120
|
+
if (!plistExists && !rootKernelRunning) return;
|
|
5121
|
+
if (!plistExists) {
|
|
5122
|
+
console.log("\u672A\u627E\u5230 plist\uFF0C\u4F46\u68C0\u6D4B\u5230 root \u5185\u6838\u5728\u8FD0\u884C\uFF08\u53EF\u80FD plist \u88AB\u624B\u52A8\u5220\u9664\u800C\u4EFB\u52A1\u4ECD\u88C5\u8F7D\uFF09");
|
|
5123
|
+
console.log("\u5C06\u6267\u884C launchctl bootout \u5378\u8F7D\u6B8B\u7559\u4EFB\u52A1");
|
|
5124
|
+
}
|
|
5000
5125
|
const target = shellQuote(SERVICE_TARGET);
|
|
5001
5126
|
const plistDest = shellQuote(PATHS.launchDaemonPlist);
|
|
5002
5127
|
const logFile = shellQuote(PATHS.logFile);
|
|
@@ -5019,12 +5144,17 @@ function disableDaemon() {
|
|
|
5019
5144
|
}
|
|
5020
5145
|
}
|
|
5021
5146
|
async function tryHotReload() {
|
|
5147
|
+
if (!isDaemonRunning(getDaemonStatus())) return false;
|
|
5022
5148
|
const controller = new AbortController();
|
|
5023
5149
|
const timer = setTimeout(() => controller.abort(), HOT_RELOAD_TIMEOUT_MS);
|
|
5024
5150
|
const secret = readSettings().controller_secret;
|
|
5025
5151
|
const headers = { "Content-Type": "application/json" };
|
|
5026
5152
|
if (secret) headers.Authorization = `Bearer ${secret}`;
|
|
5027
5153
|
try {
|
|
5154
|
+
const probe = await fetch(`${CONTROLLER_BASE_URL}/version`, { headers, signal: controller.signal });
|
|
5155
|
+
if (!probe.ok) return false;
|
|
5156
|
+
const info = await probe.json();
|
|
5157
|
+
if (typeof info?.version !== "string") return false;
|
|
5028
5158
|
const res = await fetch(`${CONTROLLER_BASE_URL}/configs?force=true`, {
|
|
5029
5159
|
method: "PUT",
|
|
5030
5160
|
headers,
|
|
@@ -5181,15 +5311,18 @@ function saveSubscriptionConfig(subName, parsed) {
|
|
|
5181
5311
|
function parseUserInfo(header) {
|
|
5182
5312
|
if (!header) return null;
|
|
5183
5313
|
const info = {};
|
|
5184
|
-
|
|
5185
|
-
for (const part of
|
|
5186
|
-
const [
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5314
|
+
let hasAny = false;
|
|
5315
|
+
for (const part of header.split(";")) {
|
|
5316
|
+
const [rawKey, rawVal] = part.split("=");
|
|
5317
|
+
const key = rawKey?.trim();
|
|
5318
|
+
const val = rawVal?.trim();
|
|
5319
|
+
if (!key || val === void 0 || val === "") continue;
|
|
5320
|
+
const numVal = Number(val);
|
|
5321
|
+
if (!Number.isFinite(numVal) || numVal < 0) continue;
|
|
5322
|
+
info[key] = numVal;
|
|
5323
|
+
hasAny = true;
|
|
5324
|
+
}
|
|
5325
|
+
return hasAny ? info : null;
|
|
5193
5326
|
}
|
|
5194
5327
|
function parsePositiveInterval(header) {
|
|
5195
5328
|
if (!header) return null;
|
|
@@ -5215,10 +5348,11 @@ function extractSubscriptionMeta(headers) {
|
|
|
5215
5348
|
function saveSubscriptionMeta(subName, meta) {
|
|
5216
5349
|
const cacheData = { updated_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
5217
5350
|
if (meta.userInfo) {
|
|
5218
|
-
|
|
5219
|
-
cacheData.
|
|
5220
|
-
cacheData.
|
|
5221
|
-
cacheData.
|
|
5351
|
+
const { upload, download, total, expire } = meta.userInfo;
|
|
5352
|
+
if (upload !== void 0) cacheData.upload = upload;
|
|
5353
|
+
if (download !== void 0) cacheData.download = download;
|
|
5354
|
+
if (total !== void 0) cacheData.total = total;
|
|
5355
|
+
if (expire !== void 0) cacheData.expire = expire;
|
|
5222
5356
|
}
|
|
5223
5357
|
if (meta.updateInterval) cacheData.update_interval = meta.updateInterval;
|
|
5224
5358
|
if (meta.webPageUrl) cacheData.web_page_url = meta.webPageUrl;
|
|
@@ -5422,6 +5556,7 @@ function needsAutoUpdate(sub) {
|
|
|
5422
5556
|
if (!sub.updated_at) return true;
|
|
5423
5557
|
const lastUpdate = new Date(sub.updated_at).getTime();
|
|
5424
5558
|
if (Number.isNaN(lastUpdate)) return true;
|
|
5559
|
+
if (lastUpdate > Date.now()) return true;
|
|
5425
5560
|
const intervalHours = resolveUpdateInterval(sub.url, sub.update_interval);
|
|
5426
5561
|
const intervalMs = intervalHours * 60 * 60 * 1e3;
|
|
5427
5562
|
return Date.now() - lastUpdate > intervalMs;
|
|
@@ -5847,19 +5982,31 @@ function addTunnel(config) {
|
|
|
5847
5982
|
validateTunnelName(config.name);
|
|
5848
5983
|
validateTunnelHost(config.host);
|
|
5849
5984
|
validateTunnelPort(config.port);
|
|
5850
|
-
|
|
5851
|
-
|
|
5985
|
+
let duplicate = false;
|
|
5986
|
+
updateSettings(() => {
|
|
5987
|
+
const tunnels = [...getTunnels()];
|
|
5988
|
+
if (tunnels.some((t) => t.name === config.name)) {
|
|
5989
|
+
duplicate = true;
|
|
5990
|
+
return {};
|
|
5991
|
+
}
|
|
5992
|
+
tunnels.push(config);
|
|
5993
|
+
return { tunnels };
|
|
5994
|
+
});
|
|
5995
|
+
if (duplicate) {
|
|
5852
5996
|
throw new CliError(`\u96A7\u9053 "${config.name}" \u5DF2\u5B58\u5728\uFF0C\u8BF7\u6362\u4E2A\u540D\u79F0\uFF0C\u6216\u5148\u5220\u9664\uFF08mihomo tunnel rm ${config.name}\uFF09`);
|
|
5853
5997
|
}
|
|
5854
|
-
tunnels.push(config);
|
|
5855
|
-
writeSettings({ tunnels });
|
|
5856
5998
|
}
|
|
5857
5999
|
function removeTunnel(name) {
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
5861
|
-
|
|
5862
|
-
|
|
6000
|
+
let found = false;
|
|
6001
|
+
updateSettings(() => {
|
|
6002
|
+
const tunnels = [...getTunnels()];
|
|
6003
|
+
const idx = tunnels.findIndex((t) => t.name === name);
|
|
6004
|
+
if (idx < 0) return {};
|
|
6005
|
+
found = true;
|
|
6006
|
+
tunnels.splice(idx, 1);
|
|
6007
|
+
return { tunnels };
|
|
6008
|
+
});
|
|
6009
|
+
if (!found) return false;
|
|
5863
6010
|
clearTunnelRuntime(name);
|
|
5864
6011
|
return true;
|
|
5865
6012
|
}
|
|
@@ -5997,12 +6144,24 @@ async function startTunnel(name, options) {
|
|
|
5997
6144
|
started_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5998
6145
|
port: config.port
|
|
5999
6146
|
});
|
|
6000
|
-
|
|
6001
|
-
|
|
6002
|
-
|
|
6003
|
-
|
|
6147
|
+
const unregisterCleanup = registerCleanup(() => {
|
|
6148
|
+
try {
|
|
6149
|
+
process.kill(pid, "SIGKILL");
|
|
6150
|
+
} catch {
|
|
6004
6151
|
}
|
|
6005
|
-
|
|
6152
|
+
clearTunnelRuntime(name);
|
|
6153
|
+
});
|
|
6154
|
+
try {
|
|
6155
|
+
for (let i = 0; i < START_WAIT_ATTEMPTS; i++) {
|
|
6156
|
+
if (!isProcessRunning(pid)) break;
|
|
6157
|
+
if (await isPortListening(config.port)) {
|
|
6158
|
+
unregisterCleanup();
|
|
6159
|
+
return { alreadyRunning: false, pid };
|
|
6160
|
+
}
|
|
6161
|
+
await new Promise((resolve) => setTimeout(resolve, START_WAIT_INTERVAL));
|
|
6162
|
+
}
|
|
6163
|
+
} finally {
|
|
6164
|
+
unregisterCleanup();
|
|
6006
6165
|
}
|
|
6007
6166
|
clearTunnelRuntime(name);
|
|
6008
6167
|
if (isProcessRunning(pid)) {
|
|
@@ -6453,7 +6612,8 @@ async function daemonOn() {
|
|
|
6453
6612
|
printDaemonStatus();
|
|
6454
6613
|
}
|
|
6455
6614
|
function daemonOff() {
|
|
6456
|
-
|
|
6615
|
+
const residualRootKernel = getMihomoPids().some(isProcessRoot);
|
|
6616
|
+
if (!isDaemonEnabled() && !residualRootKernel) {
|
|
6457
6617
|
console.log("\u4FDD\u6D3B\u5DF2\u662F\u5173\u95ED\u72B6\u6001");
|
|
6458
6618
|
console.log("");
|
|
6459
6619
|
printDaemonStatus();
|
|
@@ -6645,6 +6805,23 @@ function withMirror(url, mirror) {
|
|
|
6645
6805
|
}
|
|
6646
6806
|
return url;
|
|
6647
6807
|
}
|
|
6808
|
+
var ALLOWED_ASSET_HOSTS = /* @__PURE__ */ new Set(["github.com", "api.github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"]);
|
|
6809
|
+
function assertTrustedAssetUrl(rawUrl) {
|
|
6810
|
+
let parsed;
|
|
6811
|
+
try {
|
|
6812
|
+
parsed = new URL(rawUrl);
|
|
6813
|
+
} catch {
|
|
6814
|
+
throw new Error(`\u5185\u6838\u4E0B\u8F7D\u5730\u5740\u65E0\u6CD5\u89E3\u6790: ${rawUrl}`);
|
|
6815
|
+
}
|
|
6816
|
+
if (parsed.protocol !== "https:") {
|
|
6817
|
+
throw new Error(`\u5185\u6838\u4E0B\u8F7D\u5730\u5740\u5FC5\u987B\u662F https: ${rawUrl}`);
|
|
6818
|
+
}
|
|
6819
|
+
const host = parsed.hostname.toLowerCase();
|
|
6820
|
+
if (!ALLOWED_ASSET_HOSTS.has(host)) {
|
|
6821
|
+
throw new Error(`\u5185\u6838\u4E0B\u8F7D\u5730\u5740\u7684\u4E3B\u673A\u4E0D\u5728\u767D\u540D\u5355\u5185: ${host}
|
|
6822
|
+
\u4EC5\u5141\u8BB8: ${[...ALLOWED_ASSET_HOSTS].join(", ")}`);
|
|
6823
|
+
}
|
|
6824
|
+
}
|
|
6648
6825
|
function getArch() {
|
|
6649
6826
|
const arch = process.arch;
|
|
6650
6827
|
if (arch === "arm64") return "arm64";
|
|
@@ -6706,12 +6883,13 @@ function findBinaryInDir(dir, maxDepth = 4) {
|
|
|
6706
6883
|
const files = fs8.readdirSync(dir);
|
|
6707
6884
|
for (const f of files) {
|
|
6708
6885
|
const fullPath = path7.join(dir, f);
|
|
6709
|
-
const stat = fs8.
|
|
6886
|
+
const stat = fs8.lstatSync(fullPath);
|
|
6710
6887
|
if (stat.isDirectory()) {
|
|
6711
6888
|
const found = findBinaryInDir(fullPath, maxDepth - 1);
|
|
6712
6889
|
if (found) return found;
|
|
6713
6890
|
continue;
|
|
6714
6891
|
}
|
|
6892
|
+
if (!stat.isFile()) continue;
|
|
6715
6893
|
if (f === "mihomo") return fullPath;
|
|
6716
6894
|
if (f.includes("mihomo") && !f.endsWith(".gz")) return fullPath;
|
|
6717
6895
|
}
|
|
@@ -6731,6 +6909,7 @@ async function downloadKernel(progressCallback, mirror, releaseInfo) {
|
|
|
6731
6909
|
throw new Error(`\u672A\u627E\u5230\u5339\u914D\u7684\u5185\u6838\u6587\u4EF6
|
|
6732
6910
|
\u5E73\u53F0: ${platform}, \u67B6\u6784: ${arch}${hint}`);
|
|
6733
6911
|
}
|
|
6912
|
+
assertTrustedAssetUrl(asset.browser_download_url);
|
|
6734
6913
|
const downloadUrl = withMirror(asset.browser_download_url, mirror);
|
|
6735
6914
|
const tempPath = path7.join(DIRS.kernel, path7.basename(asset.name));
|
|
6736
6915
|
const sizeMB = (asset.size / 1024 / 1024).toFixed(2);
|
|
@@ -6740,9 +6919,26 @@ async function downloadKernel(progressCallback, mirror, releaseInfo) {
|
|
|
6740
6919
|
if (progressCallback) {
|
|
6741
6920
|
progressCallback(`\u4E0B\u8F7D\u5185\u6838: ${asset.name} (${sizeMB} MB)`);
|
|
6742
6921
|
}
|
|
6922
|
+
const maxBytes = Number.isFinite(asset.size) && asset.size > 0 ? Math.floor(asset.size * 2 + 1024 * 1024) : 512 * 1024 * 1024;
|
|
6743
6923
|
const curlResult = spawnSync4(
|
|
6744
6924
|
"curl",
|
|
6745
|
-
[
|
|
6925
|
+
[
|
|
6926
|
+
"-L",
|
|
6927
|
+
"--proto",
|
|
6928
|
+
"=https",
|
|
6929
|
+
"--proto-redir",
|
|
6930
|
+
"=https",
|
|
6931
|
+
"--max-filesize",
|
|
6932
|
+
String(maxBytes),
|
|
6933
|
+
"--progress-bar",
|
|
6934
|
+
"--connect-timeout",
|
|
6935
|
+
"30",
|
|
6936
|
+
"--max-time",
|
|
6937
|
+
String(Math.floor(KERNEL_DOWNLOAD_TIMEOUT / 1e3)),
|
|
6938
|
+
"-o",
|
|
6939
|
+
tempPath,
|
|
6940
|
+
downloadUrl
|
|
6941
|
+
],
|
|
6746
6942
|
{ stdio: "inherit" }
|
|
6747
6943
|
);
|
|
6748
6944
|
if (curlResult.error) {
|
|
@@ -6761,6 +6957,19 @@ async function downloadKernel(progressCallback, mirror, releaseInfo) {
|
|
|
6761
6957
|
if (!fs8.existsSync(tempPath)) {
|
|
6762
6958
|
throw new Error("\u4E0B\u8F7D\u5931\u8D25: \u6587\u4EF6\u672A\u751F\u6210");
|
|
6763
6959
|
}
|
|
6960
|
+
if (Number.isFinite(asset.size) && asset.size > 0) {
|
|
6961
|
+
const actual = fs8.statSync(tempPath).size;
|
|
6962
|
+
if (actual !== asset.size) {
|
|
6963
|
+
try {
|
|
6964
|
+
fs8.unlinkSync(tempPath);
|
|
6965
|
+
} catch {
|
|
6966
|
+
}
|
|
6967
|
+
throw new Error(
|
|
6968
|
+
`\u4E0B\u8F7D\u7684\u6587\u4EF6\u5927\u5C0F\u4E0E release \u5143\u6570\u636E\u4E0D\u7B26\uFF08\u671F\u671B ${asset.size} \u5B57\u8282\uFF0C\u5B9E\u9645 ${actual} \u5B57\u8282\uFF09
|
|
6969
|
+
\u53EF\u80FD\u662F\u4E0B\u8F7D\u88AB\u622A\u65AD\u6216\u5185\u5BB9\u88AB\u66FF\u6362\uFF0C\u8BF7\u91CD\u8BD5\u6216\u6539\u7528 --no-mirror \u76F4\u8FDE`
|
|
6970
|
+
);
|
|
6971
|
+
}
|
|
6972
|
+
}
|
|
6764
6973
|
if (progressCallback) {
|
|
6765
6974
|
progressCallback("\u89E3\u538B\u5185\u6838...");
|
|
6766
6975
|
}
|
|
@@ -6771,13 +6980,24 @@ async function downloadKernel(progressCallback, mirror, releaseInfo) {
|
|
|
6771
6980
|
const listResult = spawnSync4("tar", ["-tzf", tempPath], { encoding: "utf8", timeout: 6e4 });
|
|
6772
6981
|
if (listResult.error) throw listResult.error;
|
|
6773
6982
|
if (listResult.status !== 0) throw new Error(`tar \u5217\u8868\u9000\u51FA\u7801 ${listResult.status}`);
|
|
6774
|
-
const
|
|
6775
|
-
for (const entry of entries) {
|
|
6983
|
+
for (const entry of (listResult.stdout || "").split("\n").filter(Boolean)) {
|
|
6776
6984
|
if (entry.startsWith("/") || entry.split("/").includes("..")) {
|
|
6777
6985
|
throw new Error(`\u5F52\u6863\u542B\u975E\u6CD5\u8DEF\u5F84\u6761\u76EE: ${entry}`);
|
|
6778
6986
|
}
|
|
6779
6987
|
}
|
|
6780
|
-
const
|
|
6988
|
+
const typeResult = spawnSync4("tar", ["-tvzf", tempPath], { encoding: "utf8", timeout: 6e4 });
|
|
6989
|
+
if (typeResult.error) throw typeResult.error;
|
|
6990
|
+
if (typeResult.status !== 0) throw new Error(`tar \u5217\u8868\u9000\u51FA\u7801 ${typeResult.status}`);
|
|
6991
|
+
for (const line of (typeResult.stdout || "").split("\n").filter(Boolean)) {
|
|
6992
|
+
const typeChar = line[0];
|
|
6993
|
+
if (typeChar !== "-" && typeChar !== "d") {
|
|
6994
|
+
throw new Error(`\u5F52\u6863\u542B\u975E\u666E\u901A\u6587\u4EF6\u6761\u76EE\uFF08\u7C7B\u578B "${typeChar}"\uFF09: ${line}`);
|
|
6995
|
+
}
|
|
6996
|
+
}
|
|
6997
|
+
const tarResult = spawnSync4("tar", ["--no-same-owner", "-xzf", tempPath, "-C", extractPath], {
|
|
6998
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
6999
|
+
timeout: 6e4
|
|
7000
|
+
});
|
|
6781
7001
|
if (tarResult.error) throw tarResult.error;
|
|
6782
7002
|
if (tarResult.status !== 0) throw new Error(`tar \u9000\u51FA\u7801 ${tarResult.status}`);
|
|
6783
7003
|
} else if (tempPath.endsWith(".gz")) {
|