@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,278 @@
1
+ /**
2
+ * config.yaml 文本级改写工具:定位顶层段落与条目、删除时压缩空行、写盘前校验解析结果。
3
+ * 由 providers.js(映射形式段落)、groups.js(列表形式段落)、rules.js(标记块)与
4
+ * settings.js(顶层标量键)共用,改写时保留原注释与排版。
5
+ */
6
+ import yaml from 'js-yaml';
7
+ import { readFile } from './config-files.js';
8
+
9
+ /** 名称合法性:非空、≤64 字符、不含换行 / 冒号 / 井号 */
10
+ export function assertName(name, label = '名称') {
11
+ const n = String(name ?? '').trim();
12
+ if (!n) throw new Error(`${label}不能为空`);
13
+ if (n.length > 64) throw new Error(`${label}过长(≤64 字符)`);
14
+ if (/[\n\r:#]/.test(n)) throw new Error(`${label}不能包含 : # 或换行`);
15
+ return n;
16
+ }
17
+
18
+ /** 读取 config.yaml 原文与解析结果 */
19
+ export function loadConfig() {
20
+ const text = readFile('config.yaml');
21
+ let doc;
22
+ try {
23
+ doc = yaml.load(text) ?? {};
24
+ } catch (e) {
25
+ throw new Error(`config.yaml 解析失败: ${e.message}`);
26
+ }
27
+ if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
28
+ throw new Error('config.yaml 结构异常(顶层应为映射)');
29
+ }
30
+ return { text, doc };
31
+ }
32
+
33
+ /** 映射形式段落(非映射时视作空),如 proxy-providers */
34
+ export function mapSection(doc, section) {
35
+ const v = doc[section];
36
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
37
+ }
38
+
39
+ /** 列表形式段落(非数组时视作空),如 proxy-groups */
40
+ export function listSection(doc, section) {
41
+ const v = doc[section];
42
+ return Array.isArray(v) ? v : [];
43
+ }
44
+
45
+ export const indentOf = (line) => line.match(/^\s*/)[0].length;
46
+
47
+ /** 取行首键名(兼容引号键;列表项与注释返回 null) */
48
+ export function keyOf(line) {
49
+ const t = line.trim();
50
+ if (!t || t.startsWith('#') || t.startsWith('- ')) return null;
51
+ const m = line.match(/^\s*(?:"([^"]*)"|'([^']*)'|([^:#]*?))\s*:/);
52
+ if (!m) return null;
53
+ return (m[1] ?? m[2] ?? m[3] ?? '').trim();
54
+ }
55
+
56
+ /** 取列表项 `- name: xxx` 的名称(非列表项或无名返回 null) */
57
+ export function itemNameOf(line) {
58
+ const m = line.match(/^\s*-\s*(?:"name"|'name'|name)\s*:\s*(.+?)\s*$/);
59
+ if (!m) return null;
60
+ return m[1].replace(/^["']|["']$/g, '').trim();
61
+ }
62
+
63
+ /**
64
+ * 取标量列表项的值(`- DOMAIN-SUFFIX,a.com,组`,去首尾引号)。
65
+ * 映射形式条目(`- name: xxx`)、注释、空行返回 null,供 rules 这类纯字符串列表使用。
66
+ */
67
+ export function scalarItemOf(line) {
68
+ const m = line.match(/^\s*-\s+(.*?)\s*$/);
69
+ if (!m) return null;
70
+ const v = m[1].trim();
71
+ if (!v || v.startsWith('#')) return null;
72
+ if (/^[^\s:]+:(\s|$)/.test(v)) return null; // 映射条目,交回 itemNameOf / keyOf
73
+ return v.replace(/^["']|["']$/g, '').trim();
74
+ }
75
+
76
+ /** 定位顶层段落所在行号,找不到返回 -1 */
77
+ export function findSection(lines, section) {
78
+ for (let i = 0; i < lines.length; i++) {
79
+ if (indentOf(lines[i]) === 0 && keyOf(lines[i]) === section) return i;
80
+ }
81
+ return -1;
82
+ }
83
+
84
+ /**
85
+ * 缩进 0 的块序列条目(`- xxx`)。YAML 允许序列与它的键**同缩进**:
86
+ *
87
+ * ```yaml
88
+ * rules:
89
+ * - MATCH,DIRECT
90
+ * ```
91
+ *
92
+ * 这与缩进 2 的写法等价(内核两种都认),所以段落扫描必须把它当成段落内容,
93
+ * 否则 `sectionEnd` 会在第一条 `- xxx` 就收尾,所有基于段落定位的编辑(规则 / 策略组 / 订阅)
94
+ * 都会在 verifyRewrite 那一步报「改写后的 config.yaml 解析失败」。
95
+ */
96
+ export const isSeqItem = (line) => /^-\s/.test(line);
97
+
98
+ /** 段落里第一条内容行的缩进(跳过空行与注释);第一条内容不是 `- ` 条目时返回 null */
99
+ function firstSeqIndent(lines, start) {
100
+ for (let i = start; i < lines.length; i++) {
101
+ const text = lines[i].trim();
102
+ if (!text || text.startsWith('#')) continue;
103
+ return isSeqItem(lines[i]) ? indentOf(lines[i]) : null;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * 顶层段落结束行:其后第一个「非空且缩进为 0」的行。
110
+ *
111
+ * 缩进 0 的 `- ` 条目不算结束(见 {@link isSeqItem})。另外,**当这个段落的列表条目就是
112
+ * 缩进 0 写法时**,缩进 0 的注释也算段落内容 —— 面板自己就会往 `rules` 段里写
113
+ * `# >>> mihomo-dash 自定义规则` 这种标记行(缩进跟着条目走,也是 0)。
114
+ * 缩进 2 的常规写法保持原行为:缩进 0 的注释仍然是段落结束(不能把别处的注释卷进来)。
115
+ */
116
+ export function sectionEnd(lines, start) {
117
+ const zeroIndentList = firstSeqIndent(lines, start) === 0;
118
+ let i = start;
119
+ while (i < lines.length) {
120
+ const line = lines[i];
121
+ if (!line.trim()) {
122
+ i++;
123
+ continue;
124
+ }
125
+ if (indentOf(line) === 0) {
126
+ if (isSeqItem(line)) {
127
+ i++;
128
+ continue;
129
+ }
130
+ if (zeroIndentList && line.trim().startsWith('#')) {
131
+ i++;
132
+ continue;
133
+ }
134
+ break;
135
+ }
136
+ i++;
137
+ }
138
+ return i;
139
+ }
140
+
141
+ /**
142
+ * 列表段落里条目的缩进(取第一条 `- ` 条目;段内没有条目时默认 2)。
143
+ * 新增条目按它写:缩进 0 的写法(`rules:` 换行 `- MATCH,DIRECT`)也支持。
144
+ */
145
+ export function listIndent(lines, sectionLine) {
146
+ const end = sectionEnd(lines, sectionLine + 1);
147
+ for (let i = sectionLine + 1; i < end; i++) {
148
+ if (isSeqItem(lines[i])) return indentOf(lines[i]);
149
+ }
150
+ return 2;
151
+ }
152
+
153
+ /** 段落写成 `section: []` 时改为 `section:`,便于追加块状条目;就地修改 lines 并返回是否改动 */
154
+ export function openEmptyFlowSection(lines, at, section) {
155
+ const re = new RegExp(`^(\\s*)(?:"${section}"|'${section}'|${section})\\s*:\\s*\\[\\s*\\]\\s*$`);
156
+ if (at < 0 || !re.test(lines[at])) return false;
157
+ lines[at] = lines[at].replace(/\[\s*\]\s*$/, '').replace(/\s+$/, '');
158
+ return true;
159
+ }
160
+
161
+ /** 删除 [start, end) 并压缩拼接处的连续空行;blank=false 时不额外插入分隔空行(列表条目用)。
162
+ * 纯函数(不改传入数组),与 Array.prototype.splice 同名不同义,故另起名字。 */
163
+ export function removeRange(lines, start, end, { blank = true } = {}) {
164
+ const head = lines.slice(0, start);
165
+ const tail = lines.slice(end);
166
+ while (head.length && !head[head.length - 1].trim()) head.pop();
167
+ while (tail.length && !tail[0].trim()) tail.shift();
168
+ if (!tail.length) return head;
169
+ return blank ? [...head, '', ...tail] : [...head, ...tail];
170
+ }
171
+
172
+ /** 在段落末尾(跳过尾部空行)插入若干行,返回新行数组 */
173
+ export function insertAtSectionEnd(lines, sectionStart, block) {
174
+ let at = sectionEnd(lines, sectionStart + 1);
175
+ while (at > sectionStart + 1 && !lines[at - 1].trim()) at--;
176
+ return [...lines.slice(0, at), ...block, ...lines.slice(at)];
177
+ }
178
+
179
+ /** 规范化换行并保证以单个换行结尾 */
180
+ export const normalize = (lines) => `${lines.join('\n').replace(/\n+$/, '')}\n`;
181
+
182
+ /** 递归排序键的稳定序列化,用于比对解析结果 */
183
+ export function stableStringify(v) {
184
+ if (Array.isArray(v)) return `[${v.map(stableStringify).join(',')}]`;
185
+ if (v && typeof v === 'object') {
186
+ return `{${Object.keys(v)
187
+ .sort()
188
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(v[k])}`)
189
+ .join(',')}}`;
190
+ }
191
+ return JSON.stringify(v) ?? 'null';
192
+ }
193
+
194
+ /** 写盘前的最后一道防线:改写后的文本必须能解析成与预期一致的结构 */
195
+ export function verifyRewrite(text, expected) {
196
+ let parsed;
197
+ try {
198
+ parsed = yaml.load(text);
199
+ } catch (e) {
200
+ throw new Error(`改写后的 config.yaml 解析失败(已放弃写入): ${e.message}`);
201
+ }
202
+ if (stableStringify(parsed) !== stableStringify(expected)) {
203
+ throw new Error('改写后的 config.yaml 与预期不一致(已放弃写入)');
204
+ }
205
+ }
206
+
207
+ /** 在映射形式段落内定位条目(缩进 2)所在行号,找不到返回 -1 */
208
+ export function findMapEntryLine(lines, from, name) {
209
+ for (let i = from; i < lines.length; i++) {
210
+ const line = lines[i];
211
+ if (!line.trim()) continue;
212
+ const indent = indentOf(line);
213
+ if (indent === 0) break; // 段落结束
214
+ if (indent === 2 && keyOf(line) === name) return i;
215
+ }
216
+ return -1;
217
+ }
218
+
219
+ /** 映射条目的结束行:其后第一个「非空且缩进 ≤2」的行 */
220
+ export function mapEntryEnd(lines, start) {
221
+ let i = start;
222
+ while (i < lines.length) {
223
+ if (!lines[i].trim()) {
224
+ i++;
225
+ continue;
226
+ }
227
+ if (indentOf(lines[i]) <= 2) break;
228
+ i++;
229
+ }
230
+ return i;
231
+ }
232
+
233
+ /** 在列表形式段落内定位条目行号,找不到返回 -1(缩进 0 / 2 的列表写法都支持) */
234
+ export function findListItemLine(lines, from, name) {
235
+ const end = sectionEnd(lines, from);
236
+ for (let i = from; i < end; i++) {
237
+ if (itemNameOf(lines[i]) === name) return i;
238
+ }
239
+ return -1;
240
+ }
241
+
242
+ /**
243
+ * 列表条目的结束行:其后第一个「段落结束」或「下一个条目」的行。
244
+ * 条目尾部的注释行(缩进 ≤2,如 ` # 组注释`)与空行不归该条目,
245
+ * 这样替换/删除条目时不会连带吞掉属于下一条目的注释。
246
+ */
247
+ export function listItemEnd(lines, start) {
248
+ let i = start + 1;
249
+ let end = start + 1; // 最后一条确属该条目的内容行之后
250
+ while (i < lines.length) {
251
+ const line = lines[i];
252
+ const text = line.trim();
253
+ if (!text) {
254
+ i++;
255
+ continue;
256
+ }
257
+ const indent = indentOf(line);
258
+ if (indent === 0) break; // 段落结束
259
+ if (indent <= 2 && /^\s*-\s/.test(line)) break; // 下一个条目
260
+ if (indent <= 2 && text.startsWith('#')) {
261
+ i++;
262
+ continue; // 条目之间的注释
263
+ }
264
+ end = i + 1;
265
+ i++;
266
+ }
267
+ return end;
268
+ }
269
+
270
+ /** 生成缩进到段落内的块状 YAML 行(列表条目:映射块状、内部数组流式,贴合手工排版) */
271
+ export function dumpListEntry(entry, indent = 2) {
272
+ const pad = ' '.repeat(indent);
273
+ return yaml
274
+ .dump([entry], { indent: 2, lineWidth: 100, noRefs: true, flowLevel: 2 })
275
+ .trimEnd()
276
+ .split('\n')
277
+ .map((l) => (l ? pad + l : l));
278
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * 面板生成的最小默认配置。
3
+ *
4
+ * 只有**首次安装内核**(落点原本没有二进制)时会写到配置目录;面板没有「让用户在这儿一键写默认配置」
5
+ * 的入口(2026-11 按用户要求去掉了),要自定义就在「配置文件」页自己写并保存。
6
+ * 单独一个文件是为了让 kernel-admin.js 与别处都能用,不成环。
7
+ */
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { writeFileAtomic } from '../shared/atomic-file.js';
11
+ import { KERNEL_CONFIG_DIR, resetMihomoDirCache } from '../shared/paths.js';
12
+ import { resetControllerCache } from '../kernel/client.js';
13
+
14
+ /**
15
+ * 装完还没有配置时生成的默认配置:只保证「内核能起来」,**全部直连**(没有节点,不冒充有代理可用)。
16
+ * external-controller 故意不写内核默认的 9090:那个端口最容易被别的 clash / mihomo 客户端占着,
17
+ * 这里用 8190 避开。**必须显式写出来**:内核只听配置文件里的,面板 `getController()` 读不到时才回退 9090。
18
+ * `profile.store-selected`(**2026-11 按用户要求不再生成,默认配置里没有这个键**):它决定节点选择以
19
+ * 配置还是以缓存为准 —— 写成 `false` 时「配置说了算」(面板切 select 组会把选中项写进该组的
20
+ * `default-selected`);不写就是内核默认的「开」,那时启动会拿 `cache.db` 里缓存的选择**覆盖**配置,
21
+ * 手改 `default-selected` 会看起来「没生效」。面板切组两边都会写(运行时 `PUT /proxies/<组>` + 落盘),
22
+ * 所以平时感觉不出差别,只有手改配置文件时会。要恢复这个行为:在默认配置里加回
23
+ * `profile: { store-selected: false }`(或用户在面板/配置文件页自己加)。
24
+ *
25
+ * GEO 那四项(geodata-mode / geo-auto-update / geo-update-interval / geox-url)默认值指向 jsDelivr 镜像:
26
+ * 内核自带的 GitHub 直链在国内网络基本拉不动(release 直链 302 到 `objects.githubusercontent.com`
27
+ * 之后卡住、raw 连不上),2026-11 按用户要求改成镜像。要下的四个文件落在配置目录里,名字分别是
28
+ * GeoSite.dat / GeoIP.dat / geoip.metadb / GeoLite2-ASN.mmdb。
29
+ *
30
+ * `rules`:**只留一条 `MATCH,DIRECT` 兜底**(2026-11 按用户要求去掉了原先预置的 `proxy-groups`
31
+ * (成员只有 DIRECT 的 select 组)与 6 条 `GEOSITE`(private / category-ads-all / cn / gfw /
32
+ * category-scholar-!cn / category-ai-!cn)—— 默认配置不该替用户预设分流策略)。
33
+ * 那几条规则并没有丢:规则页的「系统规则」卡片固定列着它们(`SYSTEM_RULES`),想用哪条点一下开关即可,
34
+ * 启用时会连着目标一起写进 `rules` 段;`MATCH` 是兜底规则,卡片上固定启用、不给开关。
35
+ *
36
+ * 生成出来的文件**只留首行那条官方文档链接**:2026-11 按用户要求把「生成说明」「键序说明」「GEO 说明」
37
+ * 这类解释性注释全去掉了(要留着看的知识都写在这段 JSDoc 里),文件里就是干净的键值。
38
+ */
39
+ export const DEFAULT_CONFIG = `# Document: https://wiki.metacubex.one/config/
40
+ mixed-port: 7890
41
+ ipv6: true
42
+ mode: rule
43
+ log-level: warning
44
+ external-controller: 127.0.0.1:8190
45
+ geodata-mode: true
46
+ geo-auto-update: true
47
+ geo-update-interval: 12
48
+ geox-url:
49
+ geosite: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geosite.dat"
50
+ geoip: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.dat"
51
+ mmdb: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.metadb"
52
+ asn: "https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/GeoLite2-ASN.mmdb"
53
+ rules:
54
+ - MATCH,DIRECT
55
+ `;
56
+ /**
57
+ * 写一份最小默认配置到指定目录(会建目录,**已有 config.yaml 直接覆盖**),并让两个缓存失效:
58
+ * 目录缓存(不然本进程会一直记得「刚才没找到」)与接口缓存(里面存的可能就是这份新的 external-controller)。
59
+ * @param {string} [dir]
60
+ * @returns {string} config.yaml 的绝对路径
61
+ */
62
+ export function writeDefaultConfig(dir = KERNEL_CONFIG_DIR) {
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ const file = path.join(dir, 'config.yaml');
65
+ writeFileAtomic(file, DEFAULT_CONFIG);
66
+ resetMihomoDirCache();
67
+ resetControllerCache();
68
+ return file;
69
+ }