@lancewu602/mihomo-dash 0.0.0-stage → 0.6.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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -2
  3. package/bin/mihomo-dash.js +4 -0
  4. package/package.json +63 -4
  5. package/server/CHANGELOG.md +44 -0
  6. package/server/package.json +13 -0
  7. package/server/src/cli/index.js +75 -0
  8. package/server/src/cli/service.js +166 -0
  9. package/server/src/cli/status.js +243 -0
  10. package/server/src/http/config-files.js +148 -0
  11. package/server/src/http/config-order.js +186 -0
  12. package/server/src/http/config-sections.js +278 -0
  13. package/server/src/http/default-config.js +69 -0
  14. package/server/src/http/groups.js +386 -0
  15. package/server/src/http/index.js +260 -0
  16. package/server/src/http/kernel-admin.js +522 -0
  17. package/server/src/http/kernel-proxy.js +43 -0
  18. package/server/src/http/providers.js +251 -0
  19. package/server/src/http/rules.js +509 -0
  20. package/server/src/http/settings.js +409 -0
  21. package/server/src/http/system-proxy.js +86 -0
  22. package/server/src/index.js +26 -0
  23. package/server/src/kernel/archive.js +181 -0
  24. package/server/src/kernel/binary.js +33 -0
  25. package/server/src/kernel/client.js +138 -0
  26. package/server/src/kernel/install.js +150 -0
  27. package/server/src/kernel/lifecycle.js +339 -0
  28. package/server/src/kernel/run.js +11 -0
  29. package/server/src/shared/args.js +57 -0
  30. package/server/src/shared/atomic-file.js +41 -0
  31. package/server/src/shared/exec.js +44 -0
  32. package/server/src/shared/kernel-config.js +27 -0
  33. package/server/src/shared/log-file.js +92 -0
  34. package/server/src/shared/panel.js +58 -0
  35. package/server/src/shared/paths.js +72 -0
  36. package/server/src/shared/service.js +552 -0
  37. package/server/src/shared/system-proxy.js +375 -0
  38. package/server/src/shared/text.js +11 -0
  39. package/server/tests/cli-service.test.js +153 -0
  40. package/server/tests/config-dir.test.js +133 -0
  41. package/server/tests/config-order.test.js +148 -0
  42. package/server/tests/groups.test.js +558 -0
  43. package/server/tests/kernel-admin.test.js +647 -0
  44. package/server/tests/kernel.test.js +250 -0
  45. package/server/tests/log-file.test.js +110 -0
  46. package/server/tests/providers.test.js +259 -0
  47. package/server/tests/rules.test.js +463 -0
  48. package/server/tests/service.test.js +470 -0
  49. package/server/tests/settings.test.js +456 -0
  50. package/server/tests/status.test.js +266 -0
  51. package/server/tests/system-proxy.test.js +497 -0
  52. package/server/tsconfig.json +17 -0
  53. package/web/dist/assets/index-BpDzYhuI.css +1 -0
  54. package/web/dist/assets/index-WmujD2EM.js +515 -0
  55. package/web/dist/favicon.svg +5 -0
  56. package/web/dist/index.html +14 -0
@@ -0,0 +1,409 @@
1
+ /**
2
+ * 面板「快捷设置」的持久化:文本级改写 config.yaml 的顶层键(保留注释与排版),
3
+ * 校验「改写后解析结果与预期一致」后写盘 → 应用。
4
+ *
5
+ * 两种应用方式(由键本身决定):
6
+ * - 内核 PATCH /configs 支持的几项(mode / mixed-port / allow-lan / log-level / tcp-concurrent /
7
+ * find-process-mode):写完 PATCH 对齐运行时,不整包重载,因此不会重置「规则临时禁用」
8
+ * 「节点固定选择」等仅运行时状态;
9
+ * - 内核 PATCH 的 schema 里没有的项(GEO 那组 geodata-mode / geo-auto-update / geo-update-interval / geox-url,
10
+ * 以及 unified-delay —— 2026-11 实测:PATCH 带上它内核回 204 但值不变,静默忽略):
11
+ * 只能整包热重载(PUT /configs?force=true)才生效;
12
+ * geox-url 的子键写法以 config.yaml 为准(geosite / geoip / mmdb / asn)。
13
+ */
14
+ import { writeFileAtomic } from '../shared/atomic-file.js';
15
+ import { KERNEL_DOWN_WARNING, orderedConfigText, saveConfigWithReload } from './config-files.js';
16
+ import { configPath } from '../shared/paths.js';
17
+ import { kernelFetch } from '../kernel/client.js';
18
+ import {
19
+ indentOf,
20
+ insertAtSectionEnd,
21
+ keyOf,
22
+ loadConfig,
23
+ normalize,
24
+ removeRange,
25
+ sectionEnd,
26
+ verifyRewrite,
27
+ } from './config-sections.js';
28
+
29
+ /** 内核支持的运行模式(config.yaml 的 mode 取值) */
30
+ const MODES = ['rule', 'global', 'direct'];
31
+ /** 内核支持的日志级别(config.yaml 的 log-level 取值) */
32
+ const LOG_LEVELS = ['silent', 'error', 'warning', 'info', 'debug'];
33
+ /** 进程匹配模式(find-process-mode):always = 强制匹配所有连接,strict = 交给内核判断,off = 不匹配 */
34
+ const FIND_PROCESS_MODES = ['always', 'strict', 'off'];
35
+
36
+ /**
37
+ * 只能整包热重载生效的键(内核 PATCH /configs 的 schema 里没有,或实测被忽略)。
38
+ * `unified-delay` 实测(v1.19.32):PATCH 回 204 但 `GET /configs` 里值不变 —— 静默忽略,所以归到这里。
39
+ */
40
+ const RELOAD_KEYS = [
41
+ 'geodata-mode',
42
+ 'geo-auto-update',
43
+ 'geo-update-interval',
44
+ 'geox-url',
45
+ 'unified-delay',
46
+ ];
47
+
48
+ /**
49
+ * geox-url 支持的子键(**写法以 config.yaml 为准**:`geosite` / `geoip` / `mmdb` / `asn`)。
50
+ * 注意内核控制接口 `GET /configs` 用的是 JSON 字段名 `geo-site` / `geo-ip`(见 config.GeoXUrl),
51
+ * 两者不可混用:写错名字内核会静默忽略,面板与内核取值时需自行对应。
52
+ * 空字符串 = 从配置里删掉该子键,回退内核默认地址。
53
+ */
54
+ const GEOX_KEYS = ['geosite', 'geoip', 'mmdb', 'asn'];
55
+
56
+ /** 布尔键的解析器(工厂):非法时报错并带上键名 */
57
+ const booleanSpec = (key) => (v) => {
58
+ if (typeof v !== 'boolean') throw new Error(`${key} 非法:${v}(需为 true / false)`);
59
+ return v;
60
+ };
61
+
62
+ /** 可持久化的顶层标量键:解析 + 校验 + 归一化 */
63
+ const SCALAR_SPECS = {
64
+ mode: (v) => {
65
+ const s = String(v ?? '')
66
+ .trim()
67
+ .toLowerCase();
68
+ if (!MODES.includes(s))
69
+ throw new Error(`运行模式非法:${s || '(空)'}(可选 ${MODES.join(' / ')})`);
70
+ return s;
71
+ },
72
+ 'mixed-port': (v) => {
73
+ const n = typeof v === 'number' ? v : Number(String(v ?? '').trim());
74
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
75
+ throw new Error(`mixed-port 非法:${v}(需为 1-65535 的整数)`);
76
+ }
77
+ return n;
78
+ },
79
+ 'allow-lan': booleanSpec('allow-lan'),
80
+ // 延迟测试是否统一口径(只算 RTT):改动需整包热重载(见 RELOAD_KEYS)
81
+ 'unified-delay': booleanSpec('unified-delay'),
82
+ // 并发拨号:对 DNS 解析出的所有 IP 同时建连、取最先成功者;内核 PATCH 支持,改完不用重载
83
+ 'tcp-concurrent': booleanSpec('tcp-concurrent'),
84
+ 'log-level': (v) => {
85
+ const s = String(v ?? '')
86
+ .trim()
87
+ .toLowerCase();
88
+ if (!LOG_LEVELS.includes(s))
89
+ throw new Error(`log-level 非法:${s || '(空)'}(可选 ${LOG_LEVELS.join(' / ')})`);
90
+ return s;
91
+ },
92
+ // GEO:geodata-mode=true 用 geoip.dat(geodata loader),false 用 mmdb
93
+ 'geodata-mode': booleanSpec('geodata-mode'),
94
+ // GEO 自动更新:内核只在启动时注册更新任务,改动需重启内核才真正生效
95
+ 'geo-auto-update': booleanSpec('geo-auto-update'),
96
+ 'geo-update-interval': (v) => {
97
+ const n = typeof v === 'number' ? v : Number(String(v ?? '').trim());
98
+ if (!Number.isInteger(n) || n < 1 || n > 720) {
99
+ throw new Error(`geo-update-interval 非法:${v}(需为 1-720 的整数,单位:小时)`);
100
+ }
101
+ return n;
102
+ },
103
+ // 进程匹配:面板概览的「流量分布 · 按进程」靠它(strict 时内核可能一个都匹配不到,实测如此)
104
+ // 注意:这个键 **在 PATCH /configs 的 schema 里**(2026-11 用独立实例实测:PATCH 回 204、
105
+ // GET /configs 立刻变),所以不进 RELOAD_KEYS,改完即时生效、不用重启内核。
106
+ 'find-process-mode': (v) => {
107
+ const s = String(v ?? '')
108
+ .trim()
109
+ .toLowerCase();
110
+ if (!FIND_PROCESS_MODES.includes(s)) {
111
+ throw new Error(
112
+ `find-process-mode 非法:${s || '(空)'}(可选 ${FIND_PROCESS_MODES.join(' / ')})`,
113
+ );
114
+ }
115
+ return s;
116
+ },
117
+ };
118
+
119
+ /** 可持久化的键名列表(用于报错提示) */
120
+ const SETTING_KEYS = [...Object.keys(SCALAR_SPECS), 'geox-url'];
121
+
122
+ /**
123
+ * 解析 geox-url 子键补丁:值必须是 http(s) 链接,空串表示「删除该子键」(回退内核默认)。
124
+ * @param {Record<string, unknown>} v
125
+ * @returns {Record<string, string | null>}
126
+ */
127
+ function parseGeoxUrl(v) {
128
+ if (!v || typeof v !== 'object' || Array.isArray(v)) {
129
+ throw new Error(`geox-url 非法:需为 { "${GEOX_KEYS[0]}": "https://..." } 形式的对象`);
130
+ }
131
+ /** @type {Record<string, string | null>} */
132
+ const out = {};
133
+ for (const [key, raw] of Object.entries(v)) {
134
+ if (!GEOX_KEYS.includes(key)) {
135
+ throw new Error(
136
+ `geox-url 不支持的子键:${key}(config.yaml 里可选 ${GEOX_KEYS.join(' / ')};` +
137
+ '内核接口里的 geo-site / geo-ip 对应配置文件里的 geosite / geoip)',
138
+ );
139
+ }
140
+ const url = String(raw ?? '').trim();
141
+ if (!url) {
142
+ out[key] = null;
143
+ continue;
144
+ }
145
+ if (!/^https?:\/\/\S+$/i.test(url)) {
146
+ throw new Error(`geox-url.${key} 非法:${url}(需为 http(s) 链接)`);
147
+ }
148
+ out[key] = url;
149
+ }
150
+ if (!Object.keys(out).length)
151
+ throw new Error(`geox-url 未指定任何子键(可选 ${GEOX_KEYS.join(' / ')})`);
152
+ return out;
153
+ }
154
+
155
+ /** `key: value` 行:兼容引号键与引号值,只替换值部分,保留行尾注释 */
156
+ const linePattern = (key) =>
157
+ new RegExp(`^(\\s*(?:"${key}"|'${key}'|${key})\\s*:\\s*)(?:"[^"]*"|'[^']*'|[^\\s#]+)`);
158
+
159
+ /** 顶层键所在行号,找不到返回 -1 */
160
+ function findLine(lines, key) {
161
+ return lines.findIndex((l) => indentOf(l) === 0 && keyOf(l) === key);
162
+ }
163
+
164
+ /** 缺失键的插入位置(下标):文件头部注释块之后 */
165
+ function headerInsertPos(lines) {
166
+ const at = lines.findIndex((l) => l.trim() && !l.trim().startsWith('#'));
167
+ return at < 0 ? lines.length : at;
168
+ }
169
+
170
+ /** 写回文件时的字面量:数字 / 布尔直接转字符串;含 : # 或空白的字符串加双引号 */
171
+ function scalar(v) {
172
+ if (typeof v !== 'string') return `${v}`;
173
+ return /[:#\s]/.test(v) ? `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : v;
174
+ }
175
+
176
+ /** geox-url 段落头是行内写法时改为块状;带真实值时报错(避免误删数据) */
177
+ function ensureBlockHeader(lines, at, section) {
178
+ const inline = lines[at]
179
+ .replace(new RegExp(`^\\s*(?:"${section}"|'${section}'|${section})\\s*:`), '')
180
+ .trim();
181
+ if (!inline || /^[{[]\s*[}\]]\s*$/.test(inline)) {
182
+ lines[at] = lines[at].replace(/:\s*.*$/, ':');
183
+ return;
184
+ }
185
+ throw new Error(`${section} 目前是行内写法(${inline}),请先在配置页改为块状写法再保存`);
186
+ }
187
+
188
+ /** 段落内仍然存在的子键所在行号(段落的「有效内容」) */
189
+ function sectionKeyLines(lines, at) {
190
+ const end = sectionEnd(lines, at + 1);
191
+ const found = [];
192
+ for (let i = at + 1; i < end; i++) {
193
+ if (indentOf(lines[i]) >= 2 && keyOf(lines[i])) found.push(i);
194
+ }
195
+ return found;
196
+ }
197
+
198
+ /**
199
+ * 改写 `geox-url` 内的子键:已存在的行就地替换(保留行尾注释),缺失的补行,值为 null 的删行;
200
+ * 子键被删空时连同段落头一起移除(回退内核默认地址)。
201
+ * @param {string[]} lines
202
+ * @param {Record<string, string | null>} geoxPatch
203
+ * @returns {string[]} 改写后的行数组(删除整个段落返回新数组)
204
+ */
205
+ function rewriteGeoxUrl(lines, geoxPatch) {
206
+ /** @type {[string, string][]} 段落内没有对应行、需要补的子键 */
207
+ const adds = [];
208
+ for (const [key, value] of Object.entries(geoxPatch)) {
209
+ // 每轮重新定位:删行会移动后续行号
210
+ const at = findLine(lines, 'geox-url');
211
+ const found =
212
+ at < 0 ? -1 : (sectionKeyLines(lines, at).find((i) => keyOf(lines[i]) === key) ?? -1);
213
+ if (found >= 0) {
214
+ if (value === null) {
215
+ lines.splice(found, 1); // 删掉该子键,回退内核默认
216
+ continue;
217
+ }
218
+ const re = linePattern(key);
219
+ lines[found] = re.test(lines[found])
220
+ ? lines[found].replace(re, `$1${scalar(value)}`)
221
+ : ` ${key}: ${scalar(value)}`;
222
+ continue;
223
+ }
224
+ if (value !== null) adds.push([key, value]); // 段落或子键不存在:待补
225
+ }
226
+
227
+ if (adds.length) {
228
+ let at = findLine(lines, 'geox-url');
229
+ if (at < 0) {
230
+ lines.splice(headerInsertPos(lines), 0, 'geox-url:');
231
+ at = findLine(lines, 'geox-url');
232
+ } else {
233
+ ensureBlockHeader(lines, at, 'geox-url');
234
+ }
235
+ // 新子键追加到段落末尾(不插到第一个子键前面,改动 diff 更干净)
236
+ lines = insertAtSectionEnd(
237
+ lines,
238
+ at,
239
+ adds.map(([k, v]) => ` ${k}: ${scalar(v)}`),
240
+ );
241
+ }
242
+
243
+ // 子键被删空:连同标题行移除(留着 `geox-url:` 空段落会解析成 null,且无实际意义)
244
+ const at = findLine(lines, 'geox-url');
245
+ if (at >= 0 && !sectionKeyLines(lines, at).length) {
246
+ return removeRange(lines, at, sectionEnd(lines, at + 1));
247
+ }
248
+ return lines;
249
+ }
250
+
251
+ /** 按 changed 生成「改写后应有的解析结果」,供 verifyRewrite 比对 */
252
+ function expectedDoc(doc, changed) {
253
+ const expected = { ...doc };
254
+ for (const [key, value] of Object.entries(changed)) {
255
+ if (key !== 'geox-url') {
256
+ expected[key] = value;
257
+ continue;
258
+ }
259
+ const current = doc['geox-url'];
260
+ if (
261
+ current !== undefined &&
262
+ (typeof current !== 'object' || current === null || Array.isArray(current))
263
+ ) {
264
+ throw new Error('geox-url 目前不是映射写法,请先在配置页改为块状写法再保存');
265
+ }
266
+ const merged = { ...(current ?? {}) };
267
+ for (const [sub, url] of Object.entries(value)) {
268
+ if (url === null) delete merged[sub];
269
+ else merged[sub] = url;
270
+ }
271
+ // 子键被删空时整段移除(与 rewriteGeoxUrl 的行为一致)
272
+ if (Object.keys(merged).length) expected[key] = merged;
273
+ else delete expected[key];
274
+ }
275
+ return expected;
276
+ }
277
+
278
+ /**
279
+ * 持久化一组快捷设置:落盘 + 生效(标量键 PATCH,GEO 键整包热重载)。
280
+ * @param {Record<string, unknown>} input 形如 { mode: 'global', 'geodata-mode': true, 'geox-url': { geoip: 'https://...' } }
281
+ * @returns {Promise<{ applied: string[], previous: Record<string, unknown>, persisted: boolean, reloaded: boolean }>}
282
+ */
283
+ export async function setSettings(input) {
284
+ const keys = Object.keys(input ?? {});
285
+ if (!keys.length) {
286
+ throw new Error(`未指定要修改的配置项(可选 ${SETTING_KEYS.join(' / ')})`);
287
+ }
288
+ /** @type {Record<string, unknown>} */
289
+ const patch = {};
290
+ for (const key of keys) {
291
+ if (key === 'geox-url') {
292
+ patch[key] = parseGeoxUrl(input[key]);
293
+ continue;
294
+ }
295
+ const parse = SCALAR_SPECS[key];
296
+ if (!parse) {
297
+ throw new Error(
298
+ `不支持的配置项:${key}(可选 ${SETTING_KEYS.join(' / ')})` +
299
+ '—— 若面板刚更新过代码,重启面板进程(服务管理器 stop/start,或 launchctl kickstart -k)后新配置项才会被认识',
300
+ );
301
+ }
302
+ patch[key] = parse(input[key]);
303
+ }
304
+
305
+ const { text, doc } = loadConfig();
306
+ /** @type {Record<string, unknown>} */
307
+ const previous = {};
308
+ /** @type {Record<string, unknown>} 只含真正变化的键(geox-url 为其子键补丁) */
309
+ const changed = {};
310
+ for (const [key, value] of Object.entries(patch)) {
311
+ previous[key] = doc[key] ?? null;
312
+ if (key !== 'geox-url') {
313
+ if (doc[key] !== value) changed[key] = value;
314
+ continue;
315
+ }
316
+ const current =
317
+ doc['geox-url'] && typeof doc['geox-url'] === 'object' && !Array.isArray(doc['geox-url'])
318
+ ? doc['geox-url']
319
+ : {};
320
+ const sub = Object.fromEntries(
321
+ Object.entries(value).filter(([k, v]) => (current[k] ?? null) !== v),
322
+ );
323
+ if (Object.keys(sub).length) changed[key] = sub;
324
+ }
325
+
326
+ const reload = Object.keys(changed).some((k) => RELOAD_KEYS.includes(k));
327
+
328
+ // 配置文件里已是目标值:GEO 键本就无需处理,标量键仍把运行时状态对齐一次
329
+ if (!Object.keys(changed).length) {
330
+ const scalars = Object.fromEntries(
331
+ Object.entries(patch).filter(([k]) => !RELOAD_KEYS.includes(k)),
332
+ );
333
+ const warning = Object.keys(scalars).length
334
+ ? await applyRuntimeQuiet(scalars, { written: false })
335
+ : null;
336
+ return {
337
+ applied: [],
338
+ previous,
339
+ persisted: false,
340
+ reloaded: false,
341
+ ...(warning && { warning }),
342
+ };
343
+ }
344
+
345
+ let lines = text.split('\n');
346
+ const inserts = [];
347
+ for (const [key, value] of Object.entries(changed)) {
348
+ if (key === 'geox-url') {
349
+ lines = rewriteGeoxUrl(lines, value);
350
+ continue;
351
+ }
352
+ const at = findLine(lines, key);
353
+ if (at < 0) {
354
+ inserts.push(`${key}: ${scalar(value)}`);
355
+ continue;
356
+ }
357
+ const re = linePattern(key);
358
+ lines[at] = re.test(lines[at])
359
+ ? lines[at].replace(re, `$1${scalar(value)}`)
360
+ : // 值是块状 / 空值等无法就地替换时整行重写(嵌套残留会被 verifyRewrite 拦下)
361
+ `${lines[at].match(/^\s*/)[0]}${key}: ${scalar(value)}`;
362
+ }
363
+ if (inserts.length) lines.splice(headerInsertPos(lines), 0, ...inserts);
364
+
365
+ // 顶层键按固定顺序排一遍(见 config-order.js),再校验「解析结果与预期一致」
366
+ const next = orderedConfigText(normalize(lines));
367
+ verifyRewrite(next, expectedDoc(doc, changed));
368
+
369
+ const applied = Object.keys(changed);
370
+
371
+ // GEO 键:PATCH 无效,走「校验 → 写盘 → 整包热重载」
372
+ if (reload) {
373
+ const result = await saveConfigWithReload(next);
374
+ return { applied, previous, persisted: true, ...result };
375
+ }
376
+
377
+ const p = configPath();
378
+ writeFileAtomic(p, next);
379
+ const warning = await applyRuntimeQuiet(patch);
380
+ if (warning) return { applied, previous, persisted: true, reloaded: false, warning };
381
+
382
+ return { applied, previous, persisted: true, reloaded: false };
383
+ }
384
+
385
+ /** 运行时生效:PATCH /configs 只改这几个键,不整包重载 */
386
+ async function applyRuntime(patch) {
387
+ await kernelFetch('/configs', { method: 'PATCH', body: patch });
388
+ }
389
+
390
+ /**
391
+ * 运行时应用,但**把「内核没在跑」降级成 warning**:那时文件已经是目标值,等内核起来自然生效,
392
+ * 报错只会让页面弹「保存失败」、用户以为没写上。内核真回了错(配置不合法)仍然抛。
393
+ * @param {Record<string, unknown>} patch
394
+ * @param {{ written?: boolean }} [opts] written = 这次调用前是否已经写盘(决定报错文案)
395
+ * @returns {Promise<string|null>} warning 文案(正常返回 null)
396
+ */
397
+ async function applyRuntimeQuiet(patch, { written = true } = {}) {
398
+ try {
399
+ await applyRuntime(patch);
400
+ return null;
401
+ } catch (e) {
402
+ if (!e?.status) return KERNEL_DOWN_WARNING;
403
+ throw new Error(
404
+ written
405
+ ? `内核应用失败(config.yaml 已写入,未回滚): ${e.message}`
406
+ : `内核应用失败: ${e.message}`,
407
+ );
408
+ }
409
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * 系统代理接口:GET 读本机系统代理状态,PUT 开关。
3
+ *
4
+ * 只动**本机系统设置**(macOS networksetup / Linux gsettings),不碰内核:
5
+ * 内核的 mode 决定走不走节点,系统代理决定系统会不会把流量交给内核的端口,两层独立。
6
+ *
7
+ * 开启时除了写地址与端口,还会把 `BYPASS_DEFAULTS`(回环 / 私网 / link-local / 多播 / 本地域名的固定项)
8
+ * **补进**绕过列表 —— 与已有条目求并集、不删手工加的;关闭时不动绕过列表。补了哪些会从 `bypassAdded`
9
+ * 回给前端;GET 还会带上 `bypassDefaults`(值 + 说明),供页面把「固定项 / 自己加的 / 待补」分开展示。
10
+ *
11
+ * 面板无鉴权(仅监听 127.0.0.1),所以这里的写操作跟别的一样只接受 JSON body
12
+ * (跨站表单发不出 application/json,等于顺手挡掉一类 CSRF)。
13
+ */
14
+ import { resolveKernelProxyTarget } from '../kernel/client.js';
15
+ import { BYPASS_DEFAULTS, createSystemProxy } from '../shared/system-proxy.js';
16
+
17
+ /**
18
+ * 只读探测系统代理:卸载内核这类「前置检查」用(**不动任何设置**)。
19
+ * 读不到 / 平台不支持时回 `{ supported: false, enabled: false, hint }`,调用方据此决定放不放行。
20
+ * @param {{ run?: Function, platform?: string }} [opts] 测试可注入假执行器
21
+ * @returns {Promise<{ supported: boolean, enabled: boolean, hint?: string, [k: string]: unknown }>}
22
+ */
23
+ export async function readSystemProxyState(opts = {}) {
24
+ const state = await createSystemProxy(opts).read();
25
+ return { enabled: false, ...state };
26
+ }
27
+
28
+ /**
29
+ * 关掉系统代理(如果它开着)。
30
+ *
31
+ * 停内核 / 卸载内核前调它:系统代理指向的就是内核的 mixed-port,内核一停,系统还会把流量
32
+ * 交给那个没人监听的端口 —— 用户看到的就是「突然上不了网」。所以先把系统代理关掉(= 系统直连),
33
+ * 再停内核。本平台不支持读系统代理、或读/写失败时一律**不阻止**停内核,只把原因回给调用方。
34
+ * @param {{ run?: Function, platform?: string }} [opts] 测试可注入假执行器,不碰真实系统设置
35
+ * @returns {Promise<{ disabled: boolean, reason?: string }>} disabled=true 表示真的关了一个开着的系统代理
36
+ */
37
+ export async function disableSystemProxyIfOn(opts = {}) {
38
+ try {
39
+ const proxy = createSystemProxy(opts);
40
+ const state = await proxy.read();
41
+ if (!state.supported) return { disabled: false, reason: 'unsupported' };
42
+ if (!state.enabled) return { disabled: false, reason: 'already-off' };
43
+ const target = await resolveKernelProxyTarget();
44
+ await proxy.setEnabled(false, target);
45
+ return { disabled: true };
46
+ } catch (err) {
47
+ return { disabled: false, reason: String(err?.message || err) };
48
+ }
49
+ }
50
+
51
+ /**
52
+ * 注册 `/api/system-proxy` 两个端点。
53
+ * @param {import('express').Express} app
54
+ * @param {{ run?: Function, platform?: string }} [opts] 测试可注入假执行器,不碰真实系统设置
55
+ */
56
+ export function registerSystemProxyRoutes(app, opts = {}) {
57
+ const proxy = createSystemProxy(opts);
58
+
59
+ app.get('/api/system-proxy', async (_req, res) => {
60
+ try {
61
+ const [state, target] = await Promise.all([proxy.read(), resolveKernelProxyTarget()]);
62
+ res.json({ ...state, target, bypassDefaults: BYPASS_DEFAULTS });
63
+ } catch (err) {
64
+ res.status(500).json({ error: String(err?.message || err) });
65
+ }
66
+ });
67
+
68
+ app.put('/api/system-proxy', async (req, res) => {
69
+ try {
70
+ const enabled = req.body?.enabled;
71
+ if (typeof enabled !== 'boolean') throw new Error('enabled 必须是 true / false');
72
+ const state = await proxy.read();
73
+ if (!state.supported) throw new Error(state.hint || '本平台不支持改系统代理');
74
+ const target = await resolveKernelProxyTarget();
75
+ const result = await proxy.setEnabled(enabled, target);
76
+ res.json({
77
+ ...result.state,
78
+ target,
79
+ applied: result.applied,
80
+ bypassAdded: result.bypassAdded,
81
+ });
82
+ } catch (err) {
83
+ res.status(400).json({ error: String(err?.message || err) });
84
+ }
85
+ });
86
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 后端入口:前台起面板服务(`npm --prefix server start`)。
3
+ *
4
+ * 服务管理器拉起的入口是 `bin/mihomo-dash-runner.js panel`,它复用这里的 `runPanel()`,
5
+ * 所以「手动起」与「被 launchd / systemd 拉起」走同一条代码路径。
6
+ */
7
+ import { startServer } from './http/index.js';
8
+ import { parseArgs, parsePort } from './shared/args.js';
9
+ import { DEFAULT_HOST, DEFAULT_PORT } from './shared/paths.js';
10
+
11
+ /**
12
+ * @param {Record<string, string|boolean>} [flags] `--port` / `--host` / `--dev`
13
+ */
14
+ export async function runPanel(flags = {}) {
15
+ await startServer({
16
+ port: parsePort(flags.port, DEFAULT_PORT),
17
+ host: typeof flags.host === 'string' ? flags.host : DEFAULT_HOST,
18
+ dev: !!flags.dev,
19
+ });
20
+ }
21
+
22
+ // 直接被执行时(不是被 runner import)才起服务
23
+ if (import.meta.url === `file://${process.argv[1]}`) {
24
+ const { flags } = parseArgs(process.argv.slice(2));
25
+ await runPanel(flags);
26
+ }