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.
Files changed (3) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/index.js +287 -67
  3. 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
- return JSON.parse(content);
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
- const settings = readSettings();
3379
- const subs = [...getSubscriptions()];
3380
- if (subs.some((s) => s.name === name)) {
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
- if (settings.active_subscription === name) {
3399
- switchedTo = subs.length > 0 ? subs[0].name : null;
3400
- updates.active_subscription = switchedTo ?? void 0;
3401
- }
3402
- writeSettings(updates);
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
- let url = val;
3863
- if (!url.startsWith("http")) {
3864
- url = `https://${url}`;
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 (!url.endsWith("/")) {
3867
- url += "/";
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
- return url;
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 === 0) continue;
4058
- group.proxies = group.proxies.filter((name) => validNames.has(name));
4059
- warnings.push(`proxy-group "${group.name}": \u79FB\u9664\u4E86\u4E0D\u5B58\u5728\u7684\u5F15\u7528 ${invalid.map((n) => `"${n}"`).join(", ")}`);
4060
- const hasOtherSource = group.use || group["include-all"] || group["include-all-proxies"];
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
- if (!isDaemonEnabled()) return;
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
- const parts = header.split(";").map((p) => p.trim());
5185
- for (const part of parts) {
5186
- const [key, val] = part.split("=").map((s) => s.trim());
5187
- if (key && val !== void 0) {
5188
- const numVal = parseFloat(val);
5189
- info[key] = Number.isNaN(numVal) ? 0 : numVal;
5190
- }
5191
- }
5192
- return info;
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
- cacheData.upload = meta.userInfo.upload;
5219
- cacheData.download = meta.userInfo.download;
5220
- cacheData.total = meta.userInfo.total;
5221
- cacheData.expire = meta.userInfo.expire;
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
- const tunnels = [...getTunnels()];
5851
- if (tunnels.some((t) => t.name === config.name)) {
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
- const tunnels = [...getTunnels()];
5859
- const idx = tunnels.findIndex((t) => t.name === name);
5860
- if (idx < 0) return false;
5861
- tunnels.splice(idx, 1);
5862
- writeSettings({ tunnels });
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
- for (let i = 0; i < START_WAIT_ATTEMPTS; i++) {
6001
- if (!isProcessRunning(pid)) break;
6002
- if (await isPortListening(config.port)) {
6003
- return { alreadyRunning: false, pid };
6147
+ const unregisterCleanup = registerCleanup(() => {
6148
+ try {
6149
+ process.kill(pid, "SIGKILL");
6150
+ } catch {
6004
6151
  }
6005
- await new Promise((resolve) => setTimeout(resolve, START_WAIT_INTERVAL));
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
- if (!isDaemonEnabled()) {
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.statSync(fullPath);
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
- ["-L", "--progress-bar", "--connect-timeout", "30", "--max-time", String(Math.floor(KERNEL_DOWNLOAD_TIMEOUT / 1e3)), "-o", tempPath, downloadUrl],
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 entries = (listResult.stdout || "").split("\n").filter(Boolean);
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 tarResult = spawnSync4("tar", ["-xzf", tempPath, "-C", extractPath], { stdio: ["ignore", "ignore", "inherit"], timeout: 6e4 });
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")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mihomo-cli",
3
- "version": "3.8.0",
3
+ "version": "3.8.1",
4
4
  "type": "module",
5
5
  "description": "A terminal-based mihomo (Clash.Meta) client for macOS",
6
6
  "bin": {