@lancewu602/mihomo-dash 0.0.0-stage → 0.6.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 (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 +51 -0
  6. package/server/package.json +13 -0
  7. package/server/src/cli/index.js +75 -0
  8. package/server/src/cli/service.js +167 -0
  9. package/server/src/cli/status.js +245 -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 +269 -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,386 @@
1
+ /**
2
+ * 策略组(proxy-groups)增删:文本级增删列表条目,保留原有注释与排版,
3
+ * 校验「解析结果与预期一致」后写盘 → 热重载;
4
+ * 删除前检查规则 / 其它组的引用,被引用则拒绝(配置不动)。
5
+ */
6
+ import { writeFileAtomic } from '../shared/atomic-file.js';
7
+ import { saveConfigWithReload } from './config-files.js';
8
+ import { configPath } from '../shared/paths.js';
9
+ import {
10
+ assertName,
11
+ dumpListEntry,
12
+ findListItemLine,
13
+ findSection,
14
+ indentOf,
15
+ insertAtSectionEnd,
16
+ keyOf,
17
+ listIndent,
18
+ listItemEnd,
19
+ listSection,
20
+ loadConfig,
21
+ normalize,
22
+ openEmptyFlowSection,
23
+ removeRange,
24
+ sectionEnd,
25
+ verifyRewrite,
26
+ } from './config-sections.js';
27
+
28
+ const SECTION = 'proxy-groups';
29
+
30
+ /** 当前支持的组类型(值 = 内核 type,键 = 展示名) */
31
+ export const GROUP_TYPES = { select: '手动选择', 'url-test': '自动选择' };
32
+
33
+ /** 内核保留名称,不能用作策略组名 */
34
+ const RESERVED = ['DIRECT', 'REJECT', 'REJECT-DROP', 'PASS', 'COMPATIBLE', 'GLOBAL'];
35
+
36
+ const DEFAULT_TEST_URL = 'https://www.gstatic.com/generate_204';
37
+ const DEFAULT_INTERVAL = 300;
38
+ const DEFAULT_TOLERANCE = 50;
39
+
40
+ /** 成员名称列表:去空、去重,名称规则与订阅一致 */
41
+ function memberList(raw, label) {
42
+ const list = Array.isArray(raw)
43
+ ? raw
44
+ : raw === undefined || raw === null || raw === ''
45
+ ? []
46
+ : [raw];
47
+ const out = [];
48
+ for (const item of list) {
49
+ const n = assertName(item, label);
50
+ if (!out.includes(n)) out.push(n);
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /** 引用检查:返回引用该策略组的位置描述(rules / sub-rules / 其它组的 proxies) */
56
+ export function groupRefs(doc, name) {
57
+ const refs = [];
58
+
59
+ for (const g of listSection(doc, SECTION)) {
60
+ if (!g || g.name === name) continue;
61
+ const proxies = Array.isArray(g.proxies) ? g.proxies : [];
62
+ if (proxies.includes(name)) refs.push(`策略组「${g.name}」的 proxies`);
63
+ }
64
+
65
+ const scanRules = (rules, label) => {
66
+ for (const raw of Array.isArray(rules) ? rules : []) {
67
+ const text = String(raw ?? '').trim();
68
+ if (!text) continue;
69
+ if (
70
+ text
71
+ .split(',')
72
+ .map((s) => s.trim())
73
+ .includes(name)
74
+ )
75
+ refs.push(`${label}: ${text}`);
76
+ }
77
+ };
78
+ scanRules(doc.rules, 'rules');
79
+ const sub = doc['sub-rules'];
80
+ if (sub && typeof sub === 'object' && !Array.isArray(sub)) {
81
+ for (const [k, v] of Object.entries(sub)) scanRules(v, `sub-rules「${k}」`);
82
+ }
83
+
84
+ return refs;
85
+ }
86
+
87
+ /** 校验收到的表单并构造策略组条目(新增与编辑共用,name 由调用方决定) */
88
+ function buildEntry(name, input) {
89
+ const type = String(input?.type ?? '').trim();
90
+ if (!Object.prototype.hasOwnProperty.call(GROUP_TYPES, type)) {
91
+ throw new Error('策略组类型仅支持 select(手动选择)或 url-test(自动选择)');
92
+ }
93
+
94
+ const use = memberList(input?.use, '订阅名称');
95
+ const proxies = memberList(input?.proxies, '节点/策略组名称');
96
+ if (proxies.includes(name)) throw new Error('策略组不能引用自己');
97
+ if (use.length === 0 && proxies.length === 0) {
98
+ throw new Error('至少选择一个订阅或节点/策略组');
99
+ }
100
+
101
+ const entry = { name, type };
102
+ if (proxies.length) entry.proxies = proxies;
103
+ if (use.length) entry.use = use;
104
+ if (type === 'url-test') {
105
+ const url = String(input?.url ?? '').trim() || DEFAULT_TEST_URL;
106
+ if (!/^https?:\/\/\S+$/i.test(url)) throw new Error('测速地址需为 http(s) 开头的 URL');
107
+ const interval = Number(input?.interval);
108
+ const tolerance = Number(input?.tolerance);
109
+ entry.url = url;
110
+ entry.interval = Number.isFinite(interval) && interval > 0 ? interval : DEFAULT_INTERVAL;
111
+ entry.tolerance = Number.isFinite(tolerance) && tolerance >= 0 ? tolerance : DEFAULT_TOLERANCE;
112
+ }
113
+ return entry;
114
+ }
115
+
116
+ /**
117
+ * 新增策略组:写入 `proxy-groups` 列表末尾并热重载。
118
+ * @param {{ name: string, type: 'select'|'url-test', use?: string[], proxies?: string[],
119
+ * url?: string, interval?: number, tolerance?: number }} input
120
+ */
121
+ export async function addGroup(input) {
122
+ const name = assertName(input?.name, '策略组名称');
123
+ if (RESERVED.includes(name.toUpperCase())) {
124
+ throw new Error(`${name} 是内核保留名称,不能作为策略组名`);
125
+ }
126
+
127
+ const entry = buildEntry(name, input);
128
+
129
+ const { text, doc } = loadConfig();
130
+ const list = listSection(doc, SECTION);
131
+ if (list.some((g) => g && g.name === name)) throw new Error(`策略组 ${name} 已存在`);
132
+
133
+ const lines = text.split('\n');
134
+ const at = findSection(lines, SECTION);
135
+ let out;
136
+ if (at >= 0) {
137
+ // 兼容 `proxy-groups: []` 这种空流式写法
138
+ openEmptyFlowSection(lines, at, SECTION);
139
+ // 缩进跟段内现有条目一致(0 / 2 两种列表写法都保留原样)
140
+ out = insertAtSectionEnd(lines, at, dumpListEntry(entry, listIndent(lines, at)));
141
+ } else {
142
+ out = [...lines, `${SECTION}:`, ...dumpListEntry(entry)];
143
+ }
144
+
145
+ const next = normalize(out);
146
+ verifyRewrite(next, { ...doc, [SECTION]: [...list, entry] });
147
+
148
+ const reload = await saveConfigWithReload(next);
149
+ return { ok: true, name, ...reload };
150
+ }
151
+
152
+ /**
153
+ * 删除策略组:先检查引用(被引用则拒绝,不做任何写入),否则移除条目并热重载。
154
+ * @param {string} name
155
+ */
156
+ export async function removeGroup(name) {
157
+ const groupName = assertName(name, '策略组名称');
158
+ const { text, doc } = loadConfig();
159
+ const list = listSection(doc, SECTION);
160
+ if (!list.some((g) => g && g.name === groupName)) {
161
+ throw new Error(`config.yaml 中未找到策略组 ${groupName}`);
162
+ }
163
+
164
+ const refs = groupRefs(doc, groupName);
165
+ if (refs.length) {
166
+ const err = new Error(
167
+ `策略组 ${groupName} 仍被引用,请先修改以下引用再删除:\n${refs
168
+ .map((r) => `· ${r}`)
169
+ .join('\n')}`,
170
+ );
171
+ err.refs = refs;
172
+ throw err;
173
+ }
174
+
175
+ const rest = list.filter((g) => !g || g.name !== groupName);
176
+ const lines = text.split('\n');
177
+ const at = findSection(lines, SECTION);
178
+ if (at < 0) throw new Error(`config.yaml 中未找到 ${SECTION} 段`);
179
+
180
+ let out;
181
+ let expected;
182
+ if (rest.length === 0) {
183
+ // 最后一个策略组:连同 proxy-groups 标题行一起移除
184
+ out = removeRange(lines, at, sectionEnd(lines, at + 1));
185
+ expected = { ...doc };
186
+ delete expected[SECTION];
187
+ } else {
188
+ const start = findListItemLine(lines, at + 1, groupName);
189
+ if (start < 0) throw new Error(`config.yaml 中未找到策略组 ${groupName} 的条目`);
190
+ // 列表条目:删除后不额外插入空行,与相邻条目直接接上
191
+ out = removeRange(lines, start, listItemEnd(lines, start), { blank: false });
192
+ expected = { ...doc, [SECTION]: rest };
193
+ }
194
+
195
+ const next = normalize(out);
196
+ verifyRewrite(next, expected);
197
+
198
+ const reload = await saveConfigWithReload(next);
199
+ return { ok: true, name: groupName, ...reload };
200
+ }
201
+
202
+ /**
203
+ * 读取 config.yaml 中全部策略组条目(原样返回)。
204
+ * 内核 /proxies 不回传 use / interval / tolerance 等配置项,面板展示时以此为准。
205
+ */
206
+ export function listGroups() {
207
+ const { doc } = loadConfig();
208
+ return listSection(doc, SECTION);
209
+ }
210
+
211
+ /** 读取某策略组在 config.yaml 中的原始条目(内核 /proxies 不回传 use,编辑时以此为准) */
212
+ export function getGroup(name) {
213
+ const groupName = assertName(name, '策略组名称');
214
+ const { doc } = loadConfig();
215
+ const found = listSection(doc, SECTION).find((g) => g && g.name === groupName);
216
+ if (!found) throw new Error(`config.yaml 中未找到策略组 ${groupName}`);
217
+ return found;
218
+ }
219
+
220
+ /**
221
+ * 面板表单管理的字段。其余键(`icon` / `disable-udp` / `hidden` / `lazy` / `expected-status` /
222
+ * `max-failed-times` 等)面板没有输入项,必须在编辑时**原样保留**。
223
+ */
224
+ const MANAGED_KEYS = ['name', 'type', 'proxies', 'use', 'url', 'interval', 'tolerance'];
225
+
226
+ /**
227
+ * 用表单构造的条目覆盖原条目:只动 {@link MANAGED_KEYS},其余键与它们的值一律保留。
228
+ * (以前是整条替换,编辑一次就把 `icon` / `disable-udp` / `hidden` 这些丢了。)
229
+ * @param {Record<string, any>} original config.yaml 里的原条目
230
+ * @param {Record<string, any>} built 表单构造出来的条目
231
+ */
232
+ function mergeEntry(original, built) {
233
+ const merged = { ...original };
234
+ for (const key of MANAGED_KEYS) {
235
+ if (key in built) merged[key] = built[key];
236
+ else delete merged[key];
237
+ }
238
+ return merged;
239
+ }
240
+
241
+ /**
242
+ * 编辑策略组:就地替换 `proxy-groups` 中的条目(名称不可改,避免影响 rules 等处的引用)。
243
+ *
244
+ * 只改面板建模的那几个字段(见 {@link mergeEntry});`icon` / `disable-udp` / `hidden` 这类
245
+ * 面板没有输入项的键原样留着。类型不是 select / url-test 的组(fallback / load-balance …)
246
+ * 面板没有对应表单,直接拒 —— 以前报的是「类型仅支持 select 或 url-test」,看不出该怎么办。
247
+ * @param {string} name 现有策略组名
248
+ * @param {object} input 同 addGroup 的表单字段
249
+ */
250
+ export async function updateGroup(name, input) {
251
+ const groupName = assertName(name, '策略组名称');
252
+ const { text, doc } = loadConfig();
253
+ const list = listSection(doc, SECTION);
254
+ const index = list.findIndex((g) => g && g.name === groupName);
255
+ if (index < 0) throw new Error(`config.yaml 中未找到策略组 ${groupName}`);
256
+
257
+ const currentType = String(list[index].type ?? '').toLowerCase();
258
+ if (!Object.prototype.hasOwnProperty.call(GROUP_TYPES, currentType)) {
259
+ throw new Error(
260
+ `策略组 ${groupName} 的类型是 ${list[index].type}:面板只提供手动选择 / 自动选择的编辑表单,` +
261
+ '要改这个组请去「配置文件」页(直接编辑 config.yaml)',
262
+ );
263
+ }
264
+
265
+ const entry = mergeEntry(list[index], buildEntry(groupName, input));
266
+
267
+ const lines = text.split('\n');
268
+ const at = findSection(lines, SECTION);
269
+ if (at < 0) throw new Error(`config.yaml 中未找到 ${SECTION} 段`);
270
+ const start = findListItemLine(lines, at + 1, groupName);
271
+ if (start < 0) throw new Error(`config.yaml 中未找到策略组 ${groupName} 的条目`);
272
+ const end = listItemEnd(lines, start);
273
+
274
+ // 缩进沿用原条目(0 / 2 两种列表写法都保留原样)
275
+ const out = [
276
+ ...lines.slice(0, start),
277
+ ...dumpListEntry(entry, indentOf(lines[start])),
278
+ ...lines.slice(end),
279
+ ];
280
+ const next = normalize(out);
281
+ verifyRewrite(next, { ...doc, [SECTION]: list.map((g, i) => (i === index ? entry : g)) });
282
+
283
+ const reload = await saveConfigWithReload(next);
284
+ return { ok: true, name: groupName, ...reload };
285
+ }
286
+
287
+ /** 节点名合法性:非空、≤ 128 字符、无换行(允许空格与括号,如「日本 中继-1(1.5x)」) */
288
+ function assertNodeName(name) {
289
+ const n = String(name ?? '').trim();
290
+ if (!n) throw new Error('节点名称不能为空');
291
+ if (n.length > 128) throw new Error('节点名称过长(≤128 字符)');
292
+ if (/[\n\r]/.test(n)) throw new Error('节点名称不能包含换行');
293
+ return n;
294
+ }
295
+
296
+ /** 写回 YAML 的字面量:一律加双引号,避免空格 / 括号 / 井号等破坏解析 */
297
+ const quote = (v) => `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
298
+
299
+ /** 组内子键的缩进:条目缩进 + 2(缩进 0 的列表写法,子键就是 2) */
300
+ const childIndentOf = (lines, start) => indentOf(lines[start]) + 2;
301
+
302
+ /** 组内 `default-selected` 行的行号,找不到返回 -1 */
303
+ function findSelectedLine(lines, start, end) {
304
+ const indent = childIndentOf(lines, start);
305
+ for (let i = start + 1; i < end; i++) {
306
+ if (indentOf(lines[i]) === indent && keyOf(lines[i]) === 'default-selected') return i;
307
+ }
308
+ return -1;
309
+ }
310
+
311
+ /**
312
+ * 落盘 select 组的「当前节点」:写入 / 覆盖 / 删除 config.yaml 中该组的 `default-selected`。
313
+ *
314
+ * 只「校验 → 写盘」,**不热重载**:运行时选择已由面板的 `PUT /proxies/<组>` 生效,
315
+ * `default-selected` 只影响下次加载;若这里再走一次整包重载,会顺带清掉「规则临时禁用」
316
+ * 「url-test 固定选择」等仅运行时状态。
317
+ * url-test / fallback / load-balance 这几种组的配置结构里没有对应字段,无法落盘,直接拒绝。
318
+ * @param {Record<string, string | null>} selections 组名 → 节点名(null = 删除该项)
319
+ * @returns {Promise<{ applied: string[], cleared: string[] }>}
320
+ */
321
+ export async function setDefaultSelected(selections) {
322
+ const entries = Object.entries(selections ?? {});
323
+ if (!entries.length) throw new Error('未指定要落盘的策略组选择');
324
+
325
+ const { text, doc } = loadConfig();
326
+ const list = listSection(doc, SECTION);
327
+ /** @type {Map<string, string | null>} */
328
+ const changed = new Map();
329
+ for (const [rawName, rawNode] of entries) {
330
+ const groupName = assertName(rawName, '策略组名称');
331
+ const index = list.findIndex((g) => g && g.name === groupName);
332
+ if (index < 0) throw new Error(`config.yaml 中未找到策略组 ${groupName}`);
333
+ const type = String(list[index].type ?? '').toLowerCase();
334
+ if (type !== 'select') {
335
+ throw new Error(
336
+ `策略组 ${groupName} 的类型是 ${list[index].type},只有 select 组能落盘当前节点(该类型内核配置里没有对应字段)`,
337
+ );
338
+ }
339
+ const node = rawNode === null ? null : assertNodeName(rawNode);
340
+ if ((list[index]['default-selected'] ?? null) === node) continue; // 无变化不写盘
341
+ changed.set(groupName, node);
342
+ }
343
+ if (!changed.size) return { applied: [], cleared: [] };
344
+
345
+ const lines = text.split('\n');
346
+ const at = findSection(lines, SECTION);
347
+ if (at < 0) throw new Error(`config.yaml 中未找到 ${SECTION} 段`);
348
+ for (const [groupName, node] of changed) {
349
+ // 逐个组重新定位(插入 / 删除会改变后续行号)
350
+ const start = findListItemLine(lines, at + 1, groupName);
351
+ if (start < 0) throw new Error(`config.yaml 中未找到策略组 ${groupName} 的条目`);
352
+ const end = listItemEnd(lines, start);
353
+ const keyLine = findSelectedLine(lines, start, end);
354
+ const indent = ' '.repeat(childIndentOf(lines, start));
355
+ if (node === null) {
356
+ if (keyLine >= 0) lines.splice(keyLine, 1);
357
+ continue;
358
+ }
359
+ const re = /^(\s*default-selected\s*:\s*)(?:"[^"]*"|'[^']*'|[^\s#]+)/;
360
+ if (keyLine >= 0 && re.test(lines[keyLine])) {
361
+ lines[keyLine] = lines[keyLine].replace(re, `$1${quote(node)}`);
362
+ } else if (keyLine >= 0) {
363
+ lines[keyLine] = `${indent}default-selected: ${quote(node)}`;
364
+ } else {
365
+ lines.splice(end, 0, `${indent}default-selected: ${quote(node)}`);
366
+ }
367
+ }
368
+
369
+ const next = normalize(lines);
370
+ const expectedList = list.map((g) => {
371
+ if (!g || !changed.has(g.name)) return g;
372
+ const copy = { ...g };
373
+ const node = changed.get(g.name);
374
+ if (node === null) delete copy['default-selected'];
375
+ else copy['default-selected'] = node;
376
+ return copy;
377
+ });
378
+ verifyRewrite(next, { ...doc, [SECTION]: expectedList });
379
+
380
+ const p = configPath();
381
+ writeFileAtomic(p, next);
382
+ return {
383
+ applied: [...changed].filter(([, n]) => n !== null).map(([g]) => g),
384
+ cleared: [...changed].filter(([, n]) => n === null).map(([g]) => g),
385
+ };
386
+ }
@@ -0,0 +1,260 @@
1
+ /** 面板服务:REST 转发 + 静态托管 web/dist + 配置文件读写接口。 */
2
+ import express from 'express';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { createRequire } from 'node:module';
7
+ import { kernelProxy } from './kernel-proxy.js';
8
+ import { listFiles, readFileInfo, saveConfigWithReload, saveFile } from './config-files.js';
9
+ import { addProvider, listProviders, removeProvider, setProviderProxy } from './providers.js';
10
+ import {
11
+ addGroup,
12
+ getGroup,
13
+ listGroups,
14
+ removeGroup,
15
+ setDefaultSelected,
16
+ updateGroup,
17
+ } from './groups.js';
18
+ import { setSettings } from './settings.js';
19
+ import { addRule, listCustomRules, listSystemRules, removeRule, setSystemRule } from './rules.js';
20
+ import { registerSystemProxyRoutes } from './system-proxy.js';
21
+ import { registerKernelAdminRoutes } from './kernel-admin.js';
22
+ import { getController } from '../kernel/client.js';
23
+ import { detectMihomoDir } from '../shared/paths.js';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const { version: PKG_VERSION } = require('../../../package.json');
27
+ const DIST_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../web/dist');
28
+
29
+ /**
30
+ * @param {{ port: number, host: string, dev?: boolean }} opts
31
+ */
32
+ export async function startServer(opts) {
33
+ const startedAt = Date.now();
34
+ const app = express();
35
+ app.use(express.json({ limit: '2mb' }));
36
+
37
+ // 探活用:只报进程事实,不碰内核 / 配置,所以任何正常状态下都该立刻返回
38
+ app.get('/api/health', (_req, res) => {
39
+ res.json({
40
+ ok: true,
41
+ name: 'mihomo-dash',
42
+ version: PKG_VERSION,
43
+ pid: process.pid,
44
+ uptime: process.uptime(),
45
+ startedAt,
46
+ dev: !!opts.dev,
47
+ });
48
+ });
49
+
50
+ app.get('/api/meta', (_req, res) => {
51
+ res.json({
52
+ name: 'mihomo-dash',
53
+ version: PKG_VERSION,
54
+ uptime: process.uptime(),
55
+ startedAt,
56
+ dev: !!opts.dev,
57
+ kernel: getController().url,
58
+ mihomoDir: detectMihomoDir(),
59
+ });
60
+ });
61
+
62
+ // REST:透明转发内核控制接口
63
+ app.use('/api/kernel', kernelProxy());
64
+
65
+ // 配置文件读写(校验 → 写盘 → 热重载)
66
+ app.get('/api/config', (_req, res) => {
67
+ try {
68
+ res.json(readFileInfo('config.yaml'));
69
+ } catch (err) {
70
+ res.status(500).json({ error: String(err?.message || err) });
71
+ }
72
+ });
73
+ app.put('/api/config', async (req, res) => {
74
+ try {
75
+ // reloaded:false + warning = 内核没在跑,文件已写入(不是失败,页面提示橙色)
76
+ res.json({ ok: true, ...(await saveConfigWithReload(String(req.body?.content ?? ''))) });
77
+ } catch (err) {
78
+ res.status(400).json({ error: String(err?.message || err) });
79
+ }
80
+ });
81
+ app.get('/api/files', (_req, res) => {
82
+ res.json({ files: listFiles() });
83
+ });
84
+ // 不存在不算错:带上 exists / path,前端据此显示「还没有配置,保存一次就会创建」
85
+ app.get('/api/file', (req, res) => {
86
+ try {
87
+ res.json(readFileInfo(String(req.query.name || '')));
88
+ } catch (err) {
89
+ res.status(400).json({ error: String(err?.message || err) });
90
+ }
91
+ });
92
+
93
+ app.put('/api/file', (req, res) => {
94
+ try {
95
+ saveFile(String(req.body?.name ?? ''), String(req.body?.content ?? ''));
96
+ res.json({ ok: true });
97
+ } catch (err) {
98
+ res.status(400).json({ error: String(err?.message || err) });
99
+ }
100
+ });
101
+
102
+ // 订阅(proxy-providers):GET 读配置条目(interval / health-check 等仅存在于配置文件),
103
+ // POST/DELETE 走文本级改写 + 热重载
104
+ app.get('/api/providers', (_req, res) => {
105
+ try {
106
+ res.json({ providers: listProviders() });
107
+ } catch (err) {
108
+ res.status(400).json({ error: String(err?.message || err) });
109
+ }
110
+ });
111
+ app.post('/api/providers', async (req, res) => {
112
+ try {
113
+ res.json(await addProvider(req.body ?? {}));
114
+ } catch (err) {
115
+ res.status(400).json({ error: String(err?.message || err) });
116
+ }
117
+ });
118
+ // PUT:改这条订阅的「拉取用代理」(proxy 字段,空串 = 删掉该键回退直连)
119
+ app.put('/api/providers', async (req, res) => {
120
+ try {
121
+ res.json(await setProviderProxy(String(req.query.name ?? ''), req.body?.proxy));
122
+ } catch (err) {
123
+ res.status(400).json({ error: String(err?.message || err) });
124
+ }
125
+ });
126
+ app.delete('/api/providers', async (req, res) => {
127
+ try {
128
+ const keepCache = String(req.query.keepCache ?? '') === '1';
129
+ res.json(await removeProvider(String(req.query.name ?? ''), { deleteCache: !keepCache }));
130
+ } catch (err) {
131
+ res.status(400).json({ error: String(err?.message || err) });
132
+ }
133
+ });
134
+
135
+ // 策略组(proxy-groups)增删:被规则/其它组引用时拒绝删除(refs 一并返回)
136
+ app.post('/api/groups', async (req, res) => {
137
+ try {
138
+ res.json(await addGroup(req.body ?? {}));
139
+ } catch (err) {
140
+ res.status(400).json({ error: String(err?.message || err), refs: err?.refs });
141
+ }
142
+ });
143
+ app.delete('/api/groups', async (req, res) => {
144
+ try {
145
+ res.json(await removeGroup(String(req.query.name ?? '')));
146
+ } catch (err) {
147
+ res.status(400).json({ error: String(err?.message || err), refs: err?.refs });
148
+ }
149
+ });
150
+ // 读取策略组:带 name 返回单条(编辑回填),不带 name 返回全部条目(展示 interval / tolerance)
151
+ app.get('/api/groups', (req, res) => {
152
+ try {
153
+ const name = String(req.query.name ?? '').trim();
154
+ res.json(name ? { group: getGroup(name) } : { groups: listGroups() });
155
+ } catch (err) {
156
+ res.status(400).json({ error: String(err?.message || err) });
157
+ }
158
+ });
159
+ app.put('/api/groups', async (req, res) => {
160
+ try {
161
+ const body = req.body ?? {};
162
+ res.json(await updateGroup(String(body.name ?? ''), body));
163
+ } catch (err) {
164
+ res.status(400).json({ error: String(err?.message || err) });
165
+ }
166
+ });
167
+ // 落盘 select 组的当前节点(default-selected):只写文件,运行时由前端 PUT /proxies 完成
168
+ app.put('/api/groups/selected', async (req, res) => {
169
+ try {
170
+ res.json(await setDefaultSelected(req.body?.selections ?? {}));
171
+ } catch (err) {
172
+ res.status(400).json({ error: String(err?.message || err) });
173
+ }
174
+ });
175
+
176
+ // 面板管理的规则(rules 段标记块内的条目):GET 读文件里的块,POST 新增 / PUT 重排 / DELETE 删除
177
+ // 都是文本级改写 + 热重载;块外的手工规则与注释不动
178
+ app.get('/api/rules', (_req, res) => {
179
+ try {
180
+ res.json(listCustomRules());
181
+ } catch (err) {
182
+ res.status(400).json({ error: String(err?.message || err) });
183
+ }
184
+ });
185
+ app.post('/api/rules', async (req, res) => {
186
+ try {
187
+ res.json(await addRule(req.body ?? {}));
188
+ } catch (err) {
189
+ res.status(400).json({ error: String(err?.message || err) });
190
+ }
191
+ });
192
+ // 系统规则:固定那几条 GEOSITE + MATCH,逐条启 / 禁(禁用 = 注释掉那行)与改目标
193
+ app.get('/api/system-rules', (_req, res) => {
194
+ try {
195
+ res.json(listSystemRules());
196
+ } catch (err) {
197
+ res.status(500).json({ error: String(err?.message || err) });
198
+ }
199
+ });
200
+ app.put('/api/system-rules', async (req, res) => {
201
+ try {
202
+ res.json(await setSystemRule(req.body ?? {}));
203
+ } catch (err) {
204
+ res.status(400).json({ error: String(err?.message || err) });
205
+ }
206
+ });
207
+ app.delete('/api/rules', async (req, res) => {
208
+ try {
209
+ res.json(await removeRule({ raw: req.query.raw }));
210
+ } catch (err) {
211
+ res.status(400).json({ error: String(err?.message || err) });
212
+ }
213
+ });
214
+
215
+ // 快捷设置:改写 config.yaml 顶层键(含 GEO 键)→ 写盘 → 应用
216
+ // (2026-11 去掉了只读的 GET /api/settings:面板不再标记「自定义 / 内核默认」)
217
+ app.put('/api/settings', async (req, res) => {
218
+ try {
219
+ res.json(await setSettings(req.body ?? {}));
220
+ } catch (err) {
221
+ res.status(400).json({ error: String(err?.message || err) });
222
+ }
223
+ });
224
+
225
+ // 系统代理:读本机系统代理状态 / 开关(只动系统设置,不碰内核)
226
+ registerSystemProxyRoutes(app);
227
+
228
+ // 内核管理:装 / 升 / 卸 / 启停重启 / 日志(面板不再是「只读内核」,见 kernel-admin.js 文件头)
229
+ registerKernelAdminRoutes(app);
230
+
231
+ // 静态:生产托管 web/dist(SPA 回退);dev 模式由 vite 提供页面
232
+ const indexPath = path.join(DIST_DIR, 'index.html');
233
+ if (opts.dev) {
234
+ app.get('/', (_req, res) => {
235
+ res.send(
236
+ '<p>mihomo-dash serve (dev):请访问 vite 开发服务器(默认 http://127.0.0.1:5173)</p>',
237
+ );
238
+ });
239
+ } else if (fs.existsSync(indexPath)) {
240
+ app.use(express.static(DIST_DIR));
241
+ app.use((req, res, next) => {
242
+ if (req.method !== 'GET' || req.path.startsWith('/api')) return next();
243
+ res.sendFile(indexPath);
244
+ });
245
+ } else {
246
+ app.get('/', (_req, res) => {
247
+ res
248
+ .status(503)
249
+ .send('<p>前端未构建:项目根目录执行 <code>npm run build</code> 后重启面板</p>');
250
+ });
251
+ }
252
+
253
+ await new Promise((resolve, reject) => {
254
+ const server = app.listen(opts.port, opts.host, resolve);
255
+ server.on('error', reject);
256
+ });
257
+
258
+ console.log(`mihomo-dash 面板已启动: http://${opts.host}:${opts.port}`);
259
+ console.log(`内核接口: ${getController().url} 配置目录: ${detectMihomoDir()}`);
260
+ }