@lancewu602/mihomo-dash 0.6.3 → 0.7.0

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/README.md CHANGED
@@ -46,16 +46,16 @@ macOS 只有用户级 LaunchAgent(`~/Library/LaunchAgents`,`gui/<uid>` 域
46
46
 
47
47
  ## 面板功能
48
48
 
49
- | 路由 | 页面 | 能力 |
50
- | -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- |
51
- | `/` | 概览 | 内核信息条、代理概览(订阅/策略组/节点/可用节点)、累计流量 / 活动连接 / 内存占用读数、上下行速率 60s 趋势 |
52
- | `/proxies` | 节点 | 策略组切换与增删改、节点测速、订阅增删/更新/健康检查、每条订阅的「拉取出口」(走哪个策略组 / 节点去拉,直连常被墙) |
53
- | `/connections` | 连接 | 连接表、单个/全部关闭、host 搜索 |
54
- | `/rules` | 规则 | 两张卡片:自定义规则(`DOMAIN-SUFFIX` 增删)+ 系统规则(固定 7 条 GEOSITE/MATCH 启停与选目标) |
55
- | `/logs` | 日志 | 实时日志流(关键字 / 级别过滤、暂停、自动滚动) |
56
- | `/config` | 配置 | 快捷设置(allow-lan / log-level / unified-delay / tcp-concurrent / find-process-mode)、GEO 数据库展示与配置、系统代理开关 |
57
- | `/config-file` | 配置文件 | CodeMirror 直接编辑 `config.yaml` 源文件 |
58
- | `/kernel` | 内核 | 面板自有内核:上传安装包(.gz / .zip)安装与升级、卸载、日志(进程启停 / 重启在侧栏底部) |
49
+ | 路由 | 页面 | 能力 |
50
+ | -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
51
+ | `/` | 概览 | 内核信息条、代理概览(订阅/策略组/节点/可用节点)、累计流量 / 活动连接 / 内存占用读数、上下行速率 60s 趋势 |
52
+ | `/proxies` | 节点 | 策略组切换与增删改、节点测速、订阅增删/更新/健康检查、每条订阅的「拉取出口」(走哪个策略组 / 节点去拉,直连常被墙) |
53
+ | `/connections` | 连接 | 连接表、单个/全部关闭、host 搜索 |
54
+ | `/rules` | 规则 | 两张卡片:自定义规则(`DOMAIN-SUFFIX` 增删)+ 系统规则(固定 7 条 GEOSITE/MATCH 启停与选目标) |
55
+ | `/logs` | 日志 | 实时日志流(关键字 / 级别过滤、暂停、自动滚动) |
56
+ | `/config` | 配置 | 快捷设置(allow-lan / log-level / unified-delay / tcp-concurrent / find-process-mode)、GEO 数据库展示与配置、系统代理开关(含绕过列表:可自己加域名 / 网段,面板只往系统里补、不动已有条目) |
57
+ | `/config-file` | 配置文件 | CodeMirror 直接编辑 `config.yaml` 源文件 |
58
+ | `/kernel` | 内核 | 面板自有内核:上传安装包(.gz / .zip)安装与升级、卸载、日志(进程启停 / 重启在侧栏底部) |
59
59
 
60
60
  设计要点:
61
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lancewu602/mihomo-dash",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "mihomo 管理工具:CLI + 守护进程 + Web 面板",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -5,6 +5,19 @@
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.0] - 2026-11-02
9
+
10
+ ### 新功能
11
+
12
+ - 系统代理的绕过列表可以自己加条目:`POST` / `DELETE /api/system-proxy/bypass`(域名、`*.域名` /
13
+ `+.域名` 通配、IPv4 / IPv6、CIDR 网段),存状态目录的 `system-proxy.json`;系统代理开着时增删
14
+ 即时写进系统列表(`networksetup -setproxybypassdomains` / GNOME `ignore-hosts`),关着时只记下、
15
+ 下次开启一并写入。写进系统前一律过 `normalizeBypassEntry()` 校验(字符白名单、拒绝前导 `-`、
16
+ 域名逐标签、IP 与网段前缀范围、不收端口),系统里其它条目一条不动
17
+
18
+ - `GET /api/system-proxy` 多带 `bypassCustom`(配置页加的自定义项),`PUT` 开启时把它们与固定项
19
+ 一起并入绕过列表;增删接口回 `written` 说明系统设置这次是否真的被改了
20
+
8
21
  ## [0.6.3] - 2026-10-31
9
22
 
10
23
  ### 工程
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mihomo-dash-server",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "mihomo-dash 后端源码(作为 mihomo-dash 的一部分发布,不单独发包)",
@@ -5,14 +5,25 @@
5
5
  * 内核的 mode 决定走不走节点,系统代理决定系统会不会把流量交给内核的端口,两层独立。
6
6
  *
7
7
  * 开启时除了写地址与端口,还会把 `BYPASS_DEFAULTS`(回环 / 私网 / link-local / 多播 / 本地域名的固定项)
8
- * **补进**绕过列表 —— 与已有条目求并集、不删手工加的;关闭时不动绕过列表。补了哪些会从 `bypassAdded`
9
- * 回给前端;GET 还会带上 `bypassDefaults`(值 + 说明),供页面把「固定项 / 自己加的 / 待补」分开展示。
8
+ * 与**配置页里用户自己加的自定义项**({@link readBypassCustom})**补进**绕过列表 —— 与已有条目求并集、
9
+ * 不删手工加的;关闭时不动绕过列表。补了哪些会从 `bypassAdded` 回给前端;GET 还会带上 `bypassDefaults`
10
+ * (值 + 说明)与 `bypassCustom`(自定义项),供页面把「固定项 / 自己加的 / 待补」分开展示。
11
+ *
12
+ * `/api/system-proxy/bypass` 增 / 删一条自定义项:存状态目录 → 系统代理**开着就即时写进系统绕过列表**
13
+ * (写失败则回滚,保证「页面上的列表 = 实际生效的列表」),关着就只记下来、下次开启时一并写入。
14
+ * 删除只移除**这一条**,系统里其它条目(包括用户手工加的)一概不碰。
10
15
  *
11
16
  * 面板无鉴权(仅监听 127.0.0.1),所以这里的写操作跟别的一样只接受 JSON body
12
17
  * (跨站表单发不出 application/json,等于顺手挡掉一类 CSRF)。
13
18
  */
14
19
  import { resolveKernelProxyTarget } from '../kernel/client.js';
15
- import { BYPASS_DEFAULTS, createSystemProxy } from '../shared/system-proxy.js';
20
+ import {
21
+ addBypassCustom,
22
+ dropBypassCustom,
23
+ readBypassCustom,
24
+ removeBypassCustom,
25
+ } from '../shared/bypass-list.js';
26
+ import { BYPASS_DEFAULTS, createSystemProxy, mergeBypass } from '../shared/system-proxy.js';
16
27
 
17
28
  /**
18
29
  * 只读探测系统代理:卸载内核这类「前置检查」用(**不动任何设置**)。
@@ -56,10 +67,34 @@ export async function disableSystemProxyIfOn(opts = {}) {
56
67
  export function registerSystemProxyRoutes(app, opts = {}) {
57
68
  const proxy = createSystemProxy(opts);
58
69
 
70
+ /** 读状态 + 内核端口 + 面板固定项 / 自定义项(GET 与两条 bypass 写接口共用同一份响形) */
71
+ async function snapshot() {
72
+ const [state, target] = await Promise.all([proxy.read(), resolveKernelProxyTarget()]);
73
+ return {
74
+ ...state,
75
+ target,
76
+ bypassDefaults: BYPASS_DEFAULTS,
77
+ bypassCustom: readBypassCustom(),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * 把系统绕过列表改成「现有 + 自定义 + 固定项」的并集(只增不减)。
83
+ * 只在系统代理开着时调用:关着时系统本身不走代理,提前写没意义,留着下次开启一并写。
84
+ * @param {string[]} custom 当前的自定义项
85
+ */
86
+ async function applyBypass(custom) {
87
+ const state = await proxy.read();
88
+ if (!state.supported || !state.enabled) return [];
89
+ const { list, added } = mergeBypass(state.bypass, custom);
90
+ if (!added.length) return [];
91
+ await proxy.setBypass(list);
92
+ return added;
93
+ }
94
+
59
95
  app.get('/api/system-proxy', async (_req, res) => {
60
96
  try {
61
- const [state, target] = await Promise.all([proxy.read(), resolveKernelProxyTarget()]);
62
- res.json({ ...state, target, bypassDefaults: BYPASS_DEFAULTS });
97
+ res.json(await snapshot());
63
98
  } catch (err) {
64
99
  res.status(500).json({ error: String(err?.message || err) });
65
100
  }
@@ -72,12 +107,66 @@ export function registerSystemProxyRoutes(app, opts = {}) {
72
107
  const state = await proxy.read();
73
108
  if (!state.supported) throw new Error(state.hint || '本平台不支持改系统代理');
74
109
  const target = await resolveKernelProxyTarget();
75
- const result = await proxy.setEnabled(enabled, target);
110
+ const result = await proxy.setEnabled(enabled, { ...target, bypass: readBypassCustom() });
76
111
  res.json({
77
112
  ...result.state,
78
113
  target,
79
114
  applied: result.applied,
80
115
  bypassAdded: result.bypassAdded,
116
+ bypassDefaults: BYPASS_DEFAULTS,
117
+ bypassCustom: readBypassCustom(),
118
+ });
119
+ } catch (err) {
120
+ res.status(400).json({ error: String(err?.message || err) });
121
+ }
122
+ });
123
+
124
+ /** 新增一条自定义绕过项(body `{ value }`):校验 → 存盘 → 系统代理开着就即时写入系统列表 */
125
+ app.post('/api/system-proxy/bypass', async (req, res) => {
126
+ let value = '';
127
+ try {
128
+ const added = addBypassCustom(req.body?.value);
129
+ value = added.value;
130
+ let applied;
131
+ try {
132
+ applied = await applyBypass(added.entries);
133
+ } catch (err) {
134
+ // 系统设置没写进去 → 把刚存的那条撤掉,别让页面显示一条其实没生效的项
135
+ dropBypassCustom(value);
136
+ throw err;
137
+ }
138
+ res.json({ ...(await snapshot()), applied, written: applied.length > 0, added: value });
139
+ } catch (err) {
140
+ res.status(400).json({ error: String(err?.message || err) });
141
+ }
142
+ });
143
+
144
+ /** 删掉一条自定义绕过项(`?value=`):系统里有这一条就一并移除,其它条目不动 */
145
+ app.delete('/api/system-proxy/bypass', async (req, res) => {
146
+ try {
147
+ const removed = removeBypassCustom(req.query?.value);
148
+ const applied = [];
149
+ try {
150
+ const state = await proxy.read();
151
+ const hit = (state.bypass ?? []).find(
152
+ (v) => String(v).toLowerCase() === removed.value.toLowerCase(),
153
+ );
154
+ // 系统里还剩至少一项时才写回(不能把列表清空);关着也删 —— 那是上次开着时写进去的残留
155
+ const list = (state.bypass ?? []).filter((v) => v !== hit);
156
+ if (state.supported && hit && list.length) {
157
+ await proxy.setBypass(list);
158
+ applied.push(`绕过列表 -${removed.value}`);
159
+ }
160
+ } catch (err) {
161
+ // 系统设置删不掉:把它放回自定义列表,保持「页面 = 实际」一致,再把错误往上抛
162
+ addBypassCustom(removed.value);
163
+ throw err;
164
+ }
165
+ res.json({
166
+ ...(await snapshot()),
167
+ applied,
168
+ written: applied.length > 0,
169
+ removed: removed.value,
81
170
  });
82
171
  } catch (err) {
83
172
  res.status(400).json({ error: String(err?.message || err) });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * 面板管理的**自定义绕过项**:配置页「系统代理」卡片里用户自己加的域名 / 通配 / 网段。
3
+ *
4
+ * 为什么单独存一份(`MIHOMO_DASH_DIR/system-proxy.json`,形如 `{ "custom": ["*.corp.example"] }`):
5
+ * - 系统绕过列表是**系统说了算**的(用户在系统设置里手工加的那些,面板不接管、也从来不删);
6
+ * 面板固定项(`BYPASS_DEFAULTS`)是「开启时补上就行了」,同样不需要记。
7
+ * - 只有「用户希望加进绕过列表」这件事是面板独有的状态,得跨重启记住 —— 存一份列表,
8
+ * 页面才能把「面板自定义的」和「系统里本来就有的」分开显示、并给每条一个删除按钮。
9
+ *
10
+ * `system-proxy.json` 不是配置契约:文件缺失 / 坏掉都当成「没有自定义项」(不抛错、不拦页面),
11
+ * 只有写入失败才往外抛 —— 那时用户的操作确实没生效。
12
+ */
13
+ import fs from 'node:fs';
14
+ import { writeFileAtomic } from './atomic-file.js';
15
+ import { ensureCliDir, SYSTEM_PROXY_FILE } from './paths.js';
16
+ import { normalizeBypassEntry } from './system-proxy.js';
17
+
18
+ /**
19
+ * 读自定义绕过项(文件不存在 / 解析失败 → 空数组;只保留非空字符串)。
20
+ * @returns {string[]}
21
+ */
22
+ export function readBypassCustom() {
23
+ let text;
24
+ try {
25
+ text = fs.readFileSync(SYSTEM_PROXY_FILE, 'utf8');
26
+ } catch {
27
+ return [];
28
+ }
29
+ try {
30
+ const data = JSON.parse(text);
31
+ const list = Array.isArray(data?.custom) ? data.custom : [];
32
+ return list.map((v) => String(v ?? '').trim()).filter(Boolean);
33
+ } catch {
34
+ return [];
35
+ }
36
+ }
37
+
38
+ /** 写回(原子替换:写到一半崩掉不会留下半份 JSON) */
39
+ function writeBypassCustom(list) {
40
+ ensureCliDir();
41
+ writeFileAtomic(SYSTEM_PROXY_FILE, `${JSON.stringify({ custom: list }, null, 2)}\n`);
42
+ return list;
43
+ }
44
+
45
+ /**
46
+ * 新增一条:校验 → 去重(大小写不敏感)→ 落盘。**写盘前一律过 {@link normalizeBypassEntry}**,
47
+ * 页面传什么上来都不直接进系统命令。
48
+ * @param {unknown} raw 用户输入
49
+ * @returns {{ entries: string[], value: string }} value 是规范化之后的写法
50
+ */
51
+ export function addBypassCustom(raw) {
52
+ const value = normalizeBypassEntry(raw);
53
+ const entries = readBypassCustom();
54
+ if (entries.some((v) => v.toLowerCase() === value.toLowerCase())) {
55
+ throw new Error(`已经在自定义绕过项里了:${value}`);
56
+ }
57
+ return { entries: writeBypassCustom([...entries, value]), value };
58
+ }
59
+
60
+ /**
61
+ * 删除一条(大小写不敏感匹配)。不在列表里就报错 —— 页面据此提示「已经被删掉了,刷新看看」。
62
+ * @param {unknown} raw
63
+ * @returns {{ entries: string[], value: string }} value 是实际被删掉的那条(原样返回)
64
+ */
65
+ export function removeBypassCustom(raw) {
66
+ const value = String(raw ?? '').trim();
67
+ if (!value) throw new Error('要删哪一条?value 不能为空');
68
+ const entries = readBypassCustom();
69
+ const hit = entries.find((v) => v.toLowerCase() === value.toLowerCase());
70
+ if (!hit) throw new Error(`自定义绕过项里没有这一条:${value}`);
71
+ return {
72
+ entries: writeBypassCustom(entries.filter((v) => v !== hit)),
73
+ value: hit,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * 撤销式回滚(新增后写系统设置失败时用):只在列表里还有这一条时移除,别的一律不动。
79
+ * @param {string} value 已经规范化过的写法
80
+ */
81
+ export function dropBypassCustom(value) {
82
+ const entries = readBypassCustom();
83
+ const rest = entries.filter((v) => v.toLowerCase() !== String(value).toLowerCase());
84
+ if (rest.length !== entries.length) writeBypassCustom(rest);
85
+ return rest;
86
+ }
@@ -36,6 +36,9 @@ export const KERNEL_LEDGER_FILE = path.join(CLI_DIR, 'kernels.json');
36
36
  export const KERNEL_META_FILE = path.join(CLI_DIR, 'kernel-run.json');
37
37
  export const KERNEL_PID_FILE = path.join(CLI_DIR, 'kernel.pid');
38
38
  export const KERNEL_LOG_FILE = path.join(CLI_DIR, 'kernel.log');
39
+ // 系统代理:面板只记自己在配置页加的那几条**自定义绕过项**(`{ custom: string[] }`)。
40
+ // 系统里那份绕过列表是系统说了算(面板只往里面补,不接管),所以不存这里。
41
+ export const SYSTEM_PROXY_FILE = path.join(CLI_DIR, 'system-proxy.json');
39
42
 
40
43
  export function ensureCliDir() {
41
44
  fs.mkdirSync(CLI_DIR, { recursive: true });
@@ -15,6 +15,7 @@
15
15
  * 即可写这些设置,不需要 sudo。
16
16
  * - `run` 可注入:测试用桩函数断言真实 argv,不会碰本机设置。
17
17
  */
18
+ import nodeNet from 'node:net';
18
19
  import { defaultRun } from './exec.js';
19
20
 
20
21
  export { defaultRun };
@@ -82,17 +83,118 @@ export const BYPASS_DEFAULTS = [
82
83
  const BYPASS_VALUES = BYPASS_DEFAULTS.map((d) => d.value);
83
84
 
84
85
  /**
85
- * 现有绕过列表 + {@link BYPASS_DEFAULTS}:顺序保留,缺失的追加在后面(大小写不敏感去重)。
86
- * @param {string[]} [current]
87
- * @returns {{ list: string[], added: string[] }}
86
+ * 现有绕过列表 + 自定义项 + {@link BYPASS_DEFAULTS}:顺序保留,缺失的追加在后面
87
+ * (大小写不敏感去重)。**只增不减**:现有条目一条都不删、不改大小写、不重排。
88
+ * @param {string[]} [current] 系统里已有的条目
89
+ * @param {string[]} [extra] 用户在面板里加的自定义项(排在固定项前面)
90
+ * @returns {{ list: string[], added: string[] }} added = 本次真正补进去的项
88
91
  */
89
- export function mergeBypass(current = []) {
92
+ export function mergeBypass(current = [], extra = []) {
90
93
  const list = current.map((s) => String(s));
91
94
  const have = new Set(list.map((s) => s.trim().toLowerCase()));
92
- const added = BYPASS_VALUES.filter((d) => !have.has(d.toLowerCase()));
95
+ const added = [];
96
+ for (const raw of extra) {
97
+ const v = String(raw).trim();
98
+ if (!v || have.has(v.toLowerCase())) continue;
99
+ have.add(v.toLowerCase());
100
+ added.push(v);
101
+ }
102
+ for (const d of BYPASS_VALUES) {
103
+ if (have.has(d.toLowerCase())) continue;
104
+ have.add(d.toLowerCase());
105
+ added.push(d);
106
+ }
93
107
  return { list: [...list, ...added], added };
94
108
  }
95
109
 
110
+ /** 自定义绕过项允许出现的字符(值最终作为 argv 交给 networksetup / gsettings) */
111
+ const BYPASS_CHARS = /^[A-Za-z0-9.*+_:/-]+$/;
112
+ /** 域名标签:不允许以 - 开头/结尾(_ 放行,内网里常见) */
113
+ const HOST_LABEL = /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/;
114
+ /** 通配前缀:`*.example.com`(Chrome / GNOME 写法)、`+.example.com`(Clash 写法)、`.example.com` */
115
+ const WILDCARD_PREFIX = /^(?:\*|\+)\./;
116
+
117
+ /** 逐个标签校验主机名(单标签也认,`localhost` / `myhost` 都在用) */
118
+ function checkHostname(name, raw) {
119
+ if (name.length > 253) throw new Error(`域名过长(≤253 字符):${raw}`);
120
+ const labels = name.split('.');
121
+ // 末级标签可以含数字,但不能全是数字(`1.2.3.4.5.6.7.8` 这种是 IP 打错了,不是域名)
122
+ if (labels.length > 1 && /^\d+$/.test(labels[labels.length - 1])) {
123
+ throw new Error(`IP 不合法:${raw}(四段 IPv4 每段 0–255;IPv6 直接写地址或网段)`);
124
+ }
125
+ for (const label of labels) {
126
+ if (!label) throw new Error(`域名不合法:${raw}(有空的标签 / 多余的 .)`);
127
+ if (!HOST_LABEL.test(label)) {
128
+ throw new Error(`域名不合法:${raw}(标签只能用小写字母 / 数字 / - / _,不能以 - 开头结尾)`);
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * 校验并规范化**用户手输**的一条绕过项(域名 / 通配域名 / IP / CIDR 网段)。
135
+ *
136
+ * 为什么要在写系统设置之前卡一道:这些值会作为**位置参数**进 `networksetup` / `gsettings`
137
+ * 的 argv(不经 shell,但以 `-` 开头的值会被系统命令当成自己的开关),而且随手打错一条
138
+ * 就意味着「以为绕过了、其实流量还在走代理」。所以这里既要挡字符,也要挡语义:
139
+ * - 域名:逐标签校验长度与字符,允许 `*.` / `+.` / `.` 通配前缀,通配只能出现一次且在最前面;
140
+ * - IP:交给 `net.isIP` 判(IPv4 / IPv6 都认);
141
+ * - 网段:`地址/前缀`,前缀范围按地址族(IPv4 0–32、IPv6 0–128);
142
+ * - 不收端口(系统绕过列表只认主机 / 域名 / 网段),也不收 `,` `#` `?` 这类分隔符。
143
+ *
144
+ * 规范化:去首尾空白、去 FQDN 的尾点、统一小写(IPv6 与域名都不区分大小写)。
145
+ * @param {unknown} raw
146
+ * @returns {string} 可以直接写进系统设置的写法
147
+ */
148
+ export function normalizeBypassEntry(raw) {
149
+ const text = String(raw ?? '').trim();
150
+ if (!text) throw new Error('绕过项不能为空');
151
+ if (text.length > 253) throw new Error(`绕过项过长(≤253 字符):${text.slice(0, 32)}…`);
152
+ if (/\s/.test(text)) throw new Error(`绕过项不能含空格:${text}`);
153
+ if (text.startsWith('-')) {
154
+ throw new Error(`绕过项不能以 - 开头(系统命令会把它当成开关):${text}`);
155
+ }
156
+ if (!BYPASS_CHARS.test(text)) {
157
+ throw new Error(
158
+ `绕过项只能由字母 / 数字 / . - _ * + : / 组成(域名、*.域名、IP、网段),收到:${text}`,
159
+ );
160
+ }
161
+ const v = text.replace(/\.$/, '').toLowerCase();
162
+ if (!v) throw new Error(`绕过项不能只有一个点:${text}`);
163
+ if (!/[a-z0-9]/.test(v)) throw new Error(`绕过项里要有域名或地址,不能只有符号:${text}`);
164
+
165
+ const slash = v.indexOf('/');
166
+ if (slash >= 0) {
167
+ const addr = v.slice(0, slash);
168
+ const prefix = v.slice(slash + 1);
169
+ if (!addr || !prefix) throw new Error(`网段写法是 地址/前缀:${text}(示例 10.0.0.0/8)`);
170
+ if (v.indexOf('/', slash + 1) >= 0) throw new Error(`网段里只能有一个 /:${text}`);
171
+ const family = nodeNet.isIP(addr);
172
+ if (!family) throw new Error(`网段里的地址不合法:${text}(示例 192.168.0.0/16、fe80::/10)`);
173
+ const max = family === 4 ? 32 : 128;
174
+ if (!/^\d{1,3}$/.test(prefix) || Number(prefix) > max) {
175
+ throw new Error(
176
+ `网段前缀不合法:${text}(${family === 4 ? 'IPv4 是 0–32' : 'IPv6 是 0–128'})`,
177
+ );
178
+ }
179
+ return v;
180
+ }
181
+
182
+ if (nodeNet.isIP(v)) return v;
183
+ if (v.includes(':')) {
184
+ throw new Error(`地址不合法:${text}(系统绕过列表不支持端口;IPv6 请写完整地址或网段)`);
185
+ }
186
+ // 长得就像 IPv4(四段纯数字)但过不了 isIP:错的是那个 IP 本身,别顺水推舟当成域名
187
+ if (/^\d+(\.\d+){3}$/.test(v)) {
188
+ throw new Error(`IP 不合法:${text}(每段 0–255,网段写成 192.168.1.0/24)`);
189
+ }
190
+
191
+ const name = v.replace(WILDCARD_PREFIX, '').replace(/^\./, '');
192
+ if (!name) throw new Error(`通配后面要跟域名:${text}(示例 *.corp.example)`);
193
+ if (/[*+]/.test(name)) throw new Error(`通配只能出现在最前面一个(* 或 +):${text}`);
194
+ checkHostname(name, text);
195
+ return v;
196
+ }
197
+
96
198
  /** 地址/端口白名单校验:只有这些字符能出现在 argv 里 */
97
199
  export function assertHostPort(host, port) {
98
200
  const h = String(host ?? '').trim();
@@ -238,8 +340,8 @@ function macOSProxy(run) {
238
340
  if (!r.ok) throw new Error(`设置 ${p.label} 代理失败:${r.stderr || r.status}`);
239
341
  applied.push(`${p.label} → ${host}:${port}`);
240
342
  }
241
- // 固定绕过项:补上缺的(已有的一个不动,所以只写回「并集」)
242
- const { list, added } = mergeBypass(before.bypass);
343
+ // 固定绕过项 + 自定义项:只补缺的(已有的一个不动,所以只写回「并集」)
344
+ const { list, added } = mergeBypass(before.bypass, target.bypass);
243
345
  if (added.length) {
244
346
  const r = await net(['-setproxybypassdomains', svc.name, ...list]);
245
347
  if (!r.ok) throw new Error(`设置绕过列表失败:${r.stderr || r.status}`);
@@ -250,7 +352,22 @@ function macOSProxy(run) {
250
352
  return { service: svc.name, applied, bypassAdded, state: await read() };
251
353
  }
252
354
 
253
- return { read, setEnabled };
355
+ /**
356
+ * 用给定的完整列表覆盖系统绕过列表(配置页增删一条自定义项时用)。
357
+ * 列表由调用方算好(现有 + 自定义 + 固定项的并集),这里不再做合并 / 去重。
358
+ * @param {string[]} entries
359
+ */
360
+ async function setBypass(entries) {
361
+ const svc = await activeService();
362
+ if (!svc.name) throw new Error('没找到可用的网络服务(networksetup 没列出任何服务?)');
363
+ const list = (entries || []).map((s) => String(s).trim()).filter(Boolean);
364
+ if (!list.length) throw new Error('绕过列表不能是空的:系统会把它当成「没有绕过项」');
365
+ const r = await net(['-setproxybypassdomains', svc.name, ...list]);
366
+ if (!r.ok) throw new Error(`设置绕过列表失败:${r.stderr || r.status}`);
367
+ return { service: svc.name, list };
368
+ }
369
+
370
+ return { read, setEnabled, setBypass };
254
371
  }
255
372
 
256
373
  /** GNOME 实现:gsettings(用户级,不需要 root) */
@@ -320,8 +437,8 @@ function linuxProxy(run) {
320
437
  await gs(['set', `${GNOME_PROXY}.${key}`, 'host', host]);
321
438
  await gs(['set', `${GNOME_PROXY}.${key}`, 'port', String(port)]);
322
439
  }
323
- // 固定绕过项(ignore-hosts):补上缺的,已有的不动
324
- const { list, added } = mergeBypass(state.bypass);
440
+ // 固定绕过项 + 自定义项(ignore-hosts):补上缺的,已有的不动
441
+ const { list, added } = mergeBypass(state.bypass, target.bypass);
325
442
  if (added.length) {
326
443
  const r = await gs(['set', GNOME_PROXY, 'ignore-hosts', gvariantList(list)]);
327
444
  if (!r.ok) throw new Error(`设置绕过列表失败:${r.stderr || r.status}`);
@@ -335,7 +452,20 @@ function linuxProxy(run) {
335
452
  return { service: state.service, applied, bypassAdded, state: await read() };
336
453
  }
337
454
 
338
- return { read, setEnabled };
455
+ /**
456
+ * 覆盖 ignore-hosts(配置页增删一条自定义项时用):列表由调用方算好(并集),这里不再合并。
457
+ * @param {string[]} entries
458
+ */
459
+ async function setBypass(entries) {
460
+ const state = await read();
461
+ if (!state.supported) throw new Error(state.hint);
462
+ const list = (entries || []).map((s) => String(s).trim()).filter(Boolean);
463
+ const r = await gs(['set', GNOME_PROXY, 'ignore-hosts', gvariantList(list)]);
464
+ if (!r.ok) throw new Error(`设置绕过列表失败:${r.stderr || r.status}`);
465
+ return { service: state.service, list };
466
+ }
467
+
468
+ return { read, setEnabled, setBypass };
339
469
  }
340
470
 
341
471
  /** 不支持的平台:读返回提示,写直接拒 */
@@ -351,6 +481,9 @@ function unsupportedProxy(platform) {
351
481
  async setEnabled() {
352
482
  throw new Error(hint);
353
483
  },
484
+ async setBypass() {
485
+ throw new Error(hint);
486
+ },
354
487
  };
355
488
  }
356
489
 
@@ -364,7 +497,11 @@ function gvariantList(items) {
364
497
 
365
498
  /**
366
499
  * @param {{ run?: typeof defaultRun, platform?: string }} [options]
367
- * @returns {{ read: () => Promise<Record<string, any>>, setEnabled: (enabled: boolean, target?: { host?: string, port?: number }) => Promise<{ service: string|null, applied: string[], bypassAdded: string[], state: Record<string, any> }> }}
500
+ * @returns {{
501
+ * read: () => Promise<Record<string, any>>,
502
+ * setEnabled: (enabled: boolean, target?: { host?: string, port?: number, bypass?: string[] }) => Promise<{ service: string|null, applied: string[], bypassAdded: string[], state: Record<string, any> }>,
503
+ * setBypass: (entries: string[]) => Promise<{ service: string|null, list: string[] }>,
504
+ * }}
368
505
  */
369
506
  export function createSystemProxy(options = {}) {
370
507
  const run = options.run || defaultRun;