@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,250 @@
1
+ /**
2
+ * 内核安装 / 升级 / 卸载(server/src/shared/kernel-*.js 与 CLI 入口)测试。
3
+ *
4
+ * 不碰真实内核、不碰网络:安装包是测试自己用 gzip 包出来的「假 mihomo」——一个 `-v` 会打印
5
+ * `Mihomo Meta <版本> ...` 的可执行脚本。这样能把「解包 → sha256 校验 → 试跑 → 备份替换 → 台账
6
+ * → 卸载」整条链路走通。面板不下载二进制(2026-11 起),所以这里没有桩 HTTP 服务。
7
+ * 运行:npm test
8
+ */
9
+ // 单测要快:把内核起停的等待间隔调到 20ms(默认 300 / 200,生产行为不变)
10
+ process.env.MIHOMO_DASH_READY_INTERVAL_MS ??= '20';
11
+ process.env.MIHOMO_DASH_STOP_INTERVAL_MS ??= '20';
12
+
13
+ import assert from 'node:assert/strict';
14
+ import { after, before, test } from 'node:test';
15
+ import { spawnSync } from 'node:child_process';
16
+ import { randomBytes } from 'node:crypto';
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { gzipSync } from 'node:zlib';
21
+
22
+ const BIN = path.resolve(import.meta.dirname, '../../bin/mihomo-dash.js');
23
+ /**
24
+ * 假内核:只有 `-v` 会输出一行版本,其余什么都不做(shell 脚本够用,Windows 上会跳过相关断言)。
25
+ * 中间塞几行随机十六进制注释:面板会拒「小于 1 KB」的安装包(像被截断的文件),
26
+ * 而这么短的脚本 gzip 完只有几十字节,靠随机内容把包撑过那条线。
27
+ */
28
+ const fakeMihomo = (version) =>
29
+ `#!/bin/sh\n${Array.from({ length: 8 }, () => `# ${randomBytes(128).toString('hex')}`).join('\n')}\n` +
30
+ `if [ "$1" = "-v" ]; then echo "Mihomo Meta ${version} lin fake"; fi\n`;
31
+
32
+ /** @type {string} */ let tmpDir;
33
+ /** @type {string} */ let cliDir;
34
+ /** @type {string} */ let cfgDir;
35
+ /** @type {string} */ let workDir;
36
+ /** @type {Record<string, any>} */ let mods = {};
37
+ const posix = process.platform !== 'win32';
38
+
39
+ before(async () => {
40
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mihomo-dash-kernel-'));
41
+ cliDir = path.join(tmpDir, 'cli');
42
+ cfgDir = path.join(tmpDir, 'mihomo');
43
+ workDir = path.join(tmpDir, 'work');
44
+ for (const d of [cliDir, cfgDir, workDir]) fs.mkdirSync(d, { recursive: true });
45
+ fs.writeFileSync(
46
+ path.join(cfgDir, 'config.yaml'),
47
+ 'mixed-port: 17890\nexternal-controller: 127.0.0.1:18980\n',
48
+ );
49
+
50
+ // 这几个模块在加载时就读 CLI_DIR,必须先设好环境变量再 import
51
+ process.env.MIHOMO_DASH_DIR = cliDir;
52
+ process.env.MIHOMO_DIR = cfgDir;
53
+ mods = {
54
+ binary: await import('../src/kernel/binary.js'),
55
+ archive: await import('../src/kernel/archive.js'),
56
+ install: await import('../src/kernel/install.js'),
57
+ };
58
+ });
59
+
60
+ after(() => {
61
+ fs.rmSync(tmpDir, { recursive: true, force: true });
62
+ });
63
+
64
+ /** 造一个真·gzip 安装包(内容是一个假内核),返回文件路径 */
65
+ function writePkg(name, version) {
66
+ const file = path.join(workDir, name);
67
+ fs.writeFileSync(file, gzipSync(Buffer.from(fakeMihomo(version))));
68
+ return file;
69
+ }
70
+
71
+ test('assetTarget:按平台/架构给出去 release 页面该拿哪个资产(纯字符串,不联网)', () => {
72
+ const { assetTarget, archiveExt } = mods.archive;
73
+ const at = (platform, arch) => assetTarget(platform, arch);
74
+ assert.equal(at('darwin', 'arm64'), 'darwin-arm64');
75
+ assert.equal(at('darwin', 'x64'), 'darwin-amd64-compatible');
76
+ assert.equal(at('linux', 'x64'), 'linux-amd64');
77
+ assert.equal(at('linux', 'arm64'), 'linux-arm64');
78
+ assert.equal(at('linux', 'arm'), 'linux-armv7');
79
+ assert.equal(at('win32', 'x64'), 'windows-amd64');
80
+ assert.equal(at('win32', 'arm64'), 'windows-arm64');
81
+ // 没有官方构建的平台/架构 → null(页面退回通用文案)
82
+ assert.equal(at('linux', 'mips'), null);
83
+ assert.equal(at('freebsd', 'x64'), null);
84
+ // 扩展名:只有 Windows 是 zip
85
+ assert.equal(archiveExt('darwin'), 'gz');
86
+ assert.equal(archiveExt('linux'), 'gz');
87
+ assert.equal(archiveExt('win32'), 'zip');
88
+ });
89
+
90
+ test('assertBinaryArchive:发行版安装包 / 其它格式一律拒,二进制压缩包才放行', () => {
91
+ const { assertBinaryArchive } = mods.archive;
92
+ for (const name of [
93
+ 'mihomo-linux-amd64-v1.19.32.gz',
94
+ 'mihomo-darwin-arm64-v1.19.32.gz',
95
+ 'mihomo-windows-amd64-v1.19.32.zip',
96
+ ]) {
97
+ assert.equal(assertBinaryArchive(name), name);
98
+ }
99
+ for (const name of [
100
+ 'mihomo-linux-amd64-v1.19.32.deb',
101
+ 'mihomo-linux-amd64-v1.19.32.rpm',
102
+ 'mihomo-linux-amd64-v1.19.32.pkg.tar.zst',
103
+ 'mihomo-linux-arm64-v1.19.32.apk',
104
+ 'mihomo-darwin-arm64-v1.19.32',
105
+ ]) {
106
+ assert.throws(() => assertBinaryArchive(name), /只支持二进制压缩包/);
107
+ }
108
+ });
109
+
110
+ test('bareVersion:去掉 v 前缀', () => {
111
+ const { bareVersion } = mods.binary;
112
+ assert.equal(bareVersion('v1.19.32'), '1.19.32');
113
+ assert.equal(bareVersion('1.19.32'), '1.19.32');
114
+ assert.equal(bareVersion(''), '');
115
+ });
116
+
117
+ test('安装:解包 → 试跑 → 落盘 + 台账;再装一版会留备份', async (t) => {
118
+ if (!posix) return t.skip('假内核是 shell 脚本,Windows 上跑不了');
119
+ const { performInstall, readLedger, currentInstall } = mods.install;
120
+ const { readKernelVersion } = mods.binary;
121
+ const target = path.join(workDir, 'bin', 'mihomo');
122
+
123
+ const first = await performInstall({
124
+ localFile: writePkg('pkg-1.19.32.gz', '1.19.32'),
125
+ ext: 'gz',
126
+ asset: 'mihomo-fake-v1.19.32.gz',
127
+ target,
128
+ });
129
+ assert.equal(first.version, '1.19.32');
130
+ assert.equal(first.sha256.length, 64);
131
+ assert.equal(first.source, 'upload');
132
+ assert.ok(fs.existsSync(target));
133
+ assert.equal(fs.statSync(target).mode & 0o111, 0o111, '得是可执行的');
134
+ assert.equal(await readKernelVersion(target), '1.19.32');
135
+ assert.equal(currentInstall(target).version, '1.19.32');
136
+ assert.equal(currentInstall(target).asset, 'mihomo-fake-v1.19.32.gz');
137
+
138
+ const second = await performInstall({
139
+ localFile: writePkg('pkg-1.19.33.gz', '1.19.33'),
140
+ ext: 'gz',
141
+ asset: 'mihomo-fake-v1.19.33.gz',
142
+ target,
143
+ });
144
+ assert.equal(second.version, '1.19.33');
145
+ assert.ok(second.backup.endsWith('mihomo.bak-1.19.32'), `备份名:${second.backup}`);
146
+ assert.ok(fs.existsSync(second.backup));
147
+ assert.equal(readLedger().installed.length, 2);
148
+ });
149
+
150
+ test('sha256 对不上就报错,且现有文件与临时文件都不受影响', async (t) => {
151
+ if (!posix) return t.skip('假内核是 shell 脚本,Windows 上跑不了');
152
+ const { performInstall } = mods.install;
153
+ const { readKernelVersion } = mods.binary;
154
+ const dir = path.join(workDir, 'bin-sha');
155
+ const target = path.join(dir, 'mihomo');
156
+
157
+ await performInstall({
158
+ localFile: writePkg('sha-1.19.32.gz', '1.19.32'),
159
+ ext: 'gz',
160
+ asset: 'mihomo-fake-v1.19.32.gz',
161
+ target,
162
+ });
163
+ await assert.rejects(
164
+ () =>
165
+ performInstall({
166
+ localFile: writePkg('sha-1.19.33.gz', '1.19.33'),
167
+ ext: 'gz',
168
+ asset: 'mihomo-fake-v1.19.33.gz',
169
+ target,
170
+ sha256: 'deadbeef',
171
+ }),
172
+ /sha256 不匹配/,
173
+ );
174
+ assert.equal(await readKernelVersion(target), '1.19.32');
175
+ assert.deepEqual(
176
+ fs.readdirSync(dir).filter((f) => f.startsWith('.')),
177
+ [],
178
+ '解包/暂存的临时文件要清干净',
179
+ );
180
+ });
181
+
182
+ test('安装包里不是 mihomo(`-v` 没输出):拒绝替换,落点保持原样', async (t) => {
183
+ if (!posix) return t.skip('假内核是 shell 脚本,Windows 上跑不了');
184
+ const { performInstall } = mods.install;
185
+ const target = path.join(workDir, 'bin-junk', 'mihomo');
186
+ const junk = path.join(workDir, 'junk.gz');
187
+ fs.writeFileSync(junk, gzipSync(Buffer.from('#!/bin/sh\necho 我不是 mihomo\n')));
188
+
189
+ await assert.rejects(
190
+ () => performInstall({ localFile: junk, ext: 'gz', asset: 'junk.gz', target }),
191
+ /跑不起来/,
192
+ );
193
+ assert.equal(fs.existsSync(target), false);
194
+ assert.equal(mods.install.currentInstall(target), null);
195
+ });
196
+
197
+ test('卸载:只删面板装的文件,别人的东西留着', async (t) => {
198
+ if (!posix) return t.skip('假内核是 shell 脚本,Windows 上跑不了');
199
+ const { performInstall, removeInstalled, readLedger } = mods.install;
200
+ const dir = path.join(workDir, 'bin-uninstall');
201
+ const target = path.join(dir, 'mihomo');
202
+
203
+ for (const version of ['1.19.32', '1.19.33']) {
204
+ await performInstall({
205
+ localFile: writePkg(`uninstall-${version}.gz`, version),
206
+ ext: 'gz',
207
+ asset: `mihomo-fake-v${version}.gz`,
208
+ target,
209
+ });
210
+ }
211
+ const keep = path.join(dir, '别人的文件');
212
+ fs.writeFileSync(keep, 'keep');
213
+
214
+ const removed = removeInstalled(target);
215
+ assert.ok(removed.includes(target));
216
+ assert.ok(removed.some((f) => f.includes('.bak-1.19.32')));
217
+ assert.ok(!fs.existsSync(target));
218
+ assert.ok(fs.existsSync(keep), '同目录下别的文件不能删');
219
+ assert.equal(readLedger().installed.length, 0);
220
+ });
221
+
222
+ test('CLI:kernel 子命令已下线(指向面板),proxy 只是普通未知命令,顶层 --version 仍是查包版本', (t) => {
223
+ if (!posix) return t.skip('假内核是 shell 脚本,Windows 上跑不了');
224
+ const env = { ...process.env, MIHOMO_DASH_DIR: cliDir, MIHOMO_DIR: cfgDir };
225
+ const gone = spawnSync(process.execPath, [BIN, 'kernel', 'info'], { encoding: 'utf8', env });
226
+ assert.equal(gone.status, 2);
227
+ assert.match(gone.stderr, /内核的装 \/ 升 \/ 卸改到面板的「内核」页,进程启停改到侧栏底部/);
228
+
229
+ // proxy 已删除,但不再特殊提示:与其它拼错的子命令一样报「未知命令」
230
+ const proxyGone = spawnSync(process.execPath, [BIN, 'proxy', 'status'], {
231
+ encoding: 'utf8',
232
+ env,
233
+ });
234
+ assert.equal(proxyGone.status, 2);
235
+ assert.match(proxyGone.stderr, /未知命令: proxy/);
236
+ assert.doesNotMatch(proxyGone.stderr, /子命令已移除/);
237
+
238
+ // 帮助里不再提 kernel / proxy 子命令,而是指到面板
239
+ const help = spawnSync(process.execPath, [BIN, '--help'], { encoding: 'utf8', env });
240
+ assert.equal(help.status, 0);
241
+ assert.doesNotMatch(help.stdout, /mihomo-dash kernel/);
242
+ assert.doesNotMatch(help.stdout, /proxy <子命令>/);
243
+ assert.match(help.stdout, /面板的「内核」页/);
244
+ assert.match(help.stdout, /系统代理开关也不在 CLI/);
245
+
246
+ // 顶层 --version 仍然是「查包版本」
247
+ const pkg = spawnSync(process.execPath, [BIN, '--version'], { encoding: 'utf8', env });
248
+ assert.equal(pkg.status, 0);
249
+ assert.match(pkg.stdout, /^mihomo-dash \d+\.\d+\.\d+/);
250
+ });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * 日志尾部读取 / 轮转(server/src/shared/log-file.js)与原子写(server/src/shared/atomic-file.js)测试。
3
+ *
4
+ * 两件事都跟「长期跑的面板不把事件循环按住 / 不把用户配置写坏」有关:
5
+ * 日志按块从文件末尾读(而不是 readFileSync 整份),配置走 tmp → rename(而不是直接覆盖)。
6
+ * 运行:npm test
7
+ */
8
+ import assert from 'node:assert/strict';
9
+ import { after, before, test } from 'node:test';
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { readTailLines, rotateLogFile } from '../src/shared/log-file.js';
14
+ import { writeFileAtomic } from '../src/shared/atomic-file.js';
15
+
16
+ /** @type {string} */ let dir;
17
+ before(() => {
18
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mihomo-dash-logfile-'));
19
+ });
20
+ after(() => fs.rmSync(dir, { recursive: true, force: true }));
21
+
22
+ const write = (name, text) => {
23
+ const p = path.join(dir, name);
24
+ fs.writeFileSync(p, text);
25
+ return p;
26
+ };
27
+
28
+ test('尾读:文件比要的行数少 → 全给;结尾有换行也不能多出空行', () => {
29
+ const f = write('small.log', 'a\nb\nc\n');
30
+ assert.deepEqual(readTailLines(f, { lines: 10 }), {
31
+ lines: ['a', 'b', 'c'],
32
+ missing: false,
33
+ truncated: false,
34
+ });
35
+ });
36
+
37
+ test('尾读:按行切,不因为缺结尾换行就吞掉最后一行', () => {
38
+ const f = write('no-nl.log', 'x1\nx2\nx3');
39
+ assert.deepEqual(readTailLines(f, { lines: 2 }).lines, ['x2', 'x3']);
40
+ });
41
+
42
+ test('尾读:大文件只读尾部,结果与「整份读再切」一致(含多字节字符跨块边界)', () => {
43
+ const all = Array.from({ length: 200000 }, (_, i) => `${i} 中文行 abcdefghijklmnopqrstuvwxyz`);
44
+ const f = write('big.log', `${all.join('\n')}\n`);
45
+ const got = readTailLines(f, { lines: 400 });
46
+ assert.equal(got.lines.length, 400);
47
+ assert.deepEqual(got.lines, all.slice(-400));
48
+ assert.equal(got.truncated, true, '没读到文件头就该标成 truncated');
49
+
50
+ // 行数要求超过 maxBytes 允许的范围:最多给 maxBytes 里的完整行,不能越界读
51
+ const capped = readTailLines(f, { lines: 100000, maxBytes: 64 * 1024 });
52
+ assert.ok(capped.lines.length > 0 && capped.lines.length < 100000);
53
+ assert.deepEqual(capped.lines, all.slice(-capped.lines.length));
54
+ });
55
+
56
+ test('尾读:文件不存在 → missing;空文件 → 空数组', () => {
57
+ assert.deepEqual(readTailLines(path.join(dir, 'nope.log')), {
58
+ lines: [],
59
+ missing: true,
60
+ truncated: false,
61
+ });
62
+ assert.deepEqual(readTailLines(write('empty.log', ''), { lines: 5 }), {
63
+ lines: [],
64
+ missing: false,
65
+ truncated: false,
66
+ });
67
+ });
68
+
69
+ test('轮转:超过上限改名成 <file>.1(覆盖旧的那份);没超过 / 不存在都不动', () => {
70
+ const f = write('rotate.log', 'x'.repeat(2 * 1024 * 1024));
71
+ assert.equal(rotateLogFile(f, 1024 * 1024), `${f}.1`);
72
+ assert.equal(fs.existsSync(f), false, '原文件已被移走');
73
+ assert.equal(fs.statSync(`${f}.1`).size, 2 * 1024 * 1024);
74
+
75
+ // 再来一次:旧的 .1 被覆盖,不会堆成 .2 / .3
76
+ write('rotate.log', 'y'.repeat(2 * 1024 * 1024));
77
+ assert.equal(rotateLogFile(f, 1024 * 1024), `${f}.1`);
78
+ assert.deepEqual(
79
+ fs.readdirSync(dir).filter((n) => n.startsWith('rotate.log')),
80
+ ['rotate.log.1'],
81
+ );
82
+
83
+ write('keep.log', 'small');
84
+ assert.equal(rotateLogFile(path.join(dir, 'keep.log'), 1024 * 1024), null);
85
+ assert.equal(fs.readFileSync(path.join(dir, 'keep.log'), 'utf8'), 'small');
86
+ assert.equal(rotateLogFile(path.join(dir, 'missing.log')), null);
87
+ });
88
+
89
+ test('原子写:内容整份替换、权限沿用原文件、不留临时文件', () => {
90
+ const f = path.join(dir, 'config.yaml');
91
+ writeFileAtomic(f, 'a: 1\n');
92
+ fs.chmodSync(f, 0o600);
93
+ writeFileAtomic(f, 'a: 2\nb: 3\n');
94
+ assert.equal(fs.readFileSync(f, 'utf8'), 'a: 2\nb: 3\n');
95
+ assert.equal(fs.statSync(f).mode & 0o777, 0o600, '原文件权限要保留');
96
+ assert.deepEqual(
97
+ fs.readdirSync(dir).filter((n) => n.startsWith('.')),
98
+ [],
99
+ '临时文件不能留在目录里',
100
+ );
101
+ });
102
+
103
+ test('原子写:写不进去时抛错,且不留下临时文件', () => {
104
+ const target = path.join(dir, 'no-such-dir', 'config.yaml');
105
+ assert.throws(() => writeFileAtomic(target, 'a: 1\n'), /ENOENT/);
106
+ assert.deepEqual(
107
+ fs.readdirSync(dir).filter((n) => n.startsWith('.')),
108
+ [],
109
+ );
110
+ });
@@ -0,0 +1,259 @@
1
+ /**
2
+ * 订阅增删(server/src/http/providers.js)集成测试。
3
+ * 用桩 HTTP 服务替代内核(只响应 PUT /configs),验证:文本改写保留注释、
4
+ * 内核重载失败时报错、删除订阅时清理本地缓存文件。运行:npm test
5
+ */
6
+ import assert from 'node:assert/strict';
7
+ import { after, before, beforeEach, test } from 'node:test';
8
+ import fs from 'node:fs';
9
+ import http from 'node:http';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import yaml from 'js-yaml';
13
+
14
+ const BASE_CONFIG = `# Document: https://wiki.metacubex.one/config/
15
+ mixed-port: 7890
16
+ mode: rule
17
+
18
+ proxy-providers:
19
+ airport:
20
+ type: http
21
+ url: "https://example.com/link/aaa"
22
+ interval: 3600
23
+ path: ./providers/airport.yaml
24
+
25
+ proxy-groups:
26
+ - name: 节点选择
27
+ type: select
28
+ proxies: [自动选择]
29
+ use: [airport]
30
+ # 组注释
31
+
32
+ rules:
33
+ - MATCH,节点选择
34
+ `;
35
+
36
+ /** @type {string} */ let tmpDir;
37
+ /** @type {string} */ let cfgDir;
38
+ /** @type {string} */ let cliDir;
39
+ /** @type {import('node:http').Server} */ let server;
40
+ /** @type {boolean} */ let kernelDown = false;
41
+ /** @type {typeof import('../src/http/providers.js')} */ let providers;
42
+
43
+ const configPath = () => path.join(cfgDir, 'config.yaml');
44
+ const readConfig = () => fs.readFileSync(configPath(), 'utf8');
45
+ /** 代码只认 config.yaml 的 external-controller(环境变量覆盖已去掉),桩内核地址必须写进配置 */
46
+ let stubPort = 0;
47
+ const withKernel = (text) =>
48
+ `${text}${text.endsWith('\n') ? '' : '\n'}external-controller: 127.0.0.1:${stubPort}\n`;
49
+ const writeConfig = (text) => fs.writeFileSync(configPath(), withKernel(text));
50
+ const parseConfig = () => yaml.load(readConfig());
51
+ const cacheFile = (name) => path.join(cfgDir, 'providers', `${name}.yaml`);
52
+
53
+ before(async () => {
54
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mihomo-dash-'));
55
+ cfgDir = path.join(tmpDir, 'mihomo');
56
+ cliDir = path.join(tmpDir, 'cli');
57
+ fs.mkdirSync(path.join(cfgDir, 'providers'), { recursive: true });
58
+
59
+ server = http.createServer((_req, res) => {
60
+ res.writeHead(kernelDown ? 500 : 200, { 'content-type': 'application/json' });
61
+ res.end(kernelDown ? JSON.stringify({ message: 'mock reload failure' }) : '{}');
62
+ });
63
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
64
+
65
+ // 必须在 import 之前注入环境变量:paths.js / kernel.js 在模块加载时读取
66
+ process.env.MIHOMO_DIR = cfgDir;
67
+ process.env.MIHOMO_DASH_DIR = cliDir;
68
+ stubPort = server.address().port;
69
+ providers = await import('../src/http/providers.js');
70
+ });
71
+
72
+ after(() => {
73
+ server?.close();
74
+ fs.rmSync(tmpDir, { recursive: true, force: true });
75
+ });
76
+
77
+ beforeEach(() => {
78
+ kernelDown = false;
79
+ writeConfig(BASE_CONFIG);
80
+ for (const f of fs.readdirSync(path.join(cfgDir, 'providers'))) {
81
+ fs.rmSync(path.join(cfgDir, 'providers', f), { force: true });
82
+ }
83
+ fs.writeFileSync(cacheFile('airport'), 'proxies: []\n');
84
+ });
85
+
86
+ test('新增订阅:写入条目、保留注释与其余段落', async () => {
87
+ const r = await providers.addProvider({
88
+ name: 'airport2',
89
+ url: 'https://example.com/link/bbb',
90
+ });
91
+
92
+ assert.equal(r.name, 'airport2');
93
+ assert.equal(r.path, 'providers/airport2.yaml');
94
+
95
+ const text = readConfig();
96
+ assert.match(text, /# Document: https:\/\/wiki\.metacubex\.one/);
97
+ assert.match(text, /# 组注释/);
98
+
99
+ const doc = parseConfig();
100
+ assert.deepEqual(
101
+ doc['proxy-providers'].airport,
102
+ yaml.load(BASE_CONFIG)['proxy-providers'].airport,
103
+ );
104
+ assert.deepEqual(doc['proxy-providers'].airport2, {
105
+ type: 'http',
106
+ url: 'https://example.com/link/bbb',
107
+ interval: 3600,
108
+ path: './providers/airport2.yaml',
109
+ 'health-check': {
110
+ enable: true,
111
+ interval: 300,
112
+ url: 'https://www.gstatic.com/generate_204',
113
+ },
114
+ });
115
+ assert.deepEqual(doc['proxy-groups'], yaml.load(BASE_CONFIG)['proxy-groups']);
116
+ assert.deepEqual(doc.rules, yaml.load(BASE_CONFIG).rules);
117
+ });
118
+
119
+ test('新增订阅:无 proxy-providers 段时自动创建', async () => {
120
+ writeConfig(`mixed-port: 7891\nproxy-groups:\n - name: 直连\n`);
121
+ await providers.addProvider({ name: 'sub', url: 'https://example.com/link/ccc', interval: 1800 });
122
+
123
+ const doc = parseConfig();
124
+ assert.equal(doc['proxy-providers'].sub.interval, 1800);
125
+ assert.equal(doc['mixed-port'], 7891);
126
+ });
127
+
128
+ test('新增订阅:重名 / 非法链接 / 缓存文件已存在时拒绝', async () => {
129
+ await assert.rejects(
130
+ () => providers.addProvider({ name: 'airport', url: 'https://a.com/x' }),
131
+ /已存在/,
132
+ );
133
+ await assert.rejects(
134
+ () => providers.addProvider({ name: 'x', url: 'ftp://a.com/x' }),
135
+ /http\(s\)/,
136
+ );
137
+ await assert.rejects(
138
+ () => providers.addProvider({ name: '', url: 'https://a.com/x' }),
139
+ /不能为空/,
140
+ );
141
+ await assert.rejects(
142
+ () => providers.addProvider({ name: 'a:b', url: 'https://a.com/x' }),
143
+ /不能包含/,
144
+ );
145
+
146
+ fs.writeFileSync(cacheFile('ghost'), 'proxies: []\n');
147
+ await assert.rejects(
148
+ () => providers.addProvider({ name: 'ghost', url: 'https://a.com/x' }),
149
+ /已存在/,
150
+ );
151
+ });
152
+
153
+ test('新增订阅:内核热重载失败时报错,config.yaml 停在新内容', async () => {
154
+ kernelDown = true;
155
+ await assert.rejects(
156
+ () => providers.addProvider({ name: 'broken', url: 'https://example.com/link/ddd' }),
157
+ /热重载失败/,
158
+ );
159
+ kernelDown = false;
160
+ assert.match(readConfig(), /broken/); // 写盘已完成,失败只发生在内核重载这一步
161
+ });
162
+
163
+ test('新增订阅:带「拉取用代理」时写入 proxy 字段(键序在 path 之后)', async () => {
164
+ await providers.addProvider({
165
+ name: 'proxied',
166
+ url: 'https://example.com/link/fff',
167
+ proxy: '自动选择',
168
+ });
169
+
170
+ const doc = parseConfig();
171
+ assert.equal(doc['proxy-providers'].proxied.proxy, '自动选择');
172
+ // 键序:path 紧跟 proxy,便于肉眼对照
173
+ const lines = readConfig().split('\n');
174
+ const pathAt = lines.findIndex((l) => l.trim().startsWith('path: ./providers/proxied.yaml'));
175
+ assert.match(lines[pathAt + 1], /^\s+proxy: 自动选择$/);
176
+
177
+ // 不填 proxy 时那个键压根不写(内核按直连拉取)
178
+ await providers.addProvider({ name: 'direct', url: 'https://example.com/link/ggg' });
179
+ assert.equal('proxy' in parseConfig()['proxy-providers'].direct, false);
180
+ });
181
+
182
+ test('改拉取用代理:插入 / 替换 / 删除都只动那一行,保留注释', async () => {
183
+ const set = providers.setProviderProxy;
184
+
185
+ await set('airport', '自动选择');
186
+ let doc = parseConfig();
187
+ assert.equal(doc['proxy-providers'].airport.proxy, '自动选择');
188
+ assert.equal(doc['proxy-providers'].airport.url, 'https://example.com/link/aaa');
189
+ assert.match(readConfig(), /# 组注释/);
190
+
191
+ // 再改一次:原地替换,不堆出第二行 proxy
192
+ await set('airport', 'DIRECT');
193
+ assert.equal(parseConfig()['proxy-providers'].airport.proxy, 'DIRECT');
194
+ assert.equal(readConfig().match(/^\s+proxy:/gm).length, 1);
195
+
196
+ // 空串 = 删掉该键,回退直连;条目其余字段不变
197
+ const r = await set('airport', '');
198
+ assert.equal(r.proxy, '');
199
+ doc = parseConfig();
200
+ assert.deepEqual(
201
+ doc['proxy-providers'].airport,
202
+ yaml.load(BASE_CONFIG)['proxy-providers'].airport,
203
+ );
204
+ assert.equal(readConfig().includes('proxy:'), false);
205
+ });
206
+
207
+ test('改拉取用代理:值没变就不写盘、不热重载', async () => {
208
+ const r = await providers.setProviderProxy('airport', '');
209
+ assert.equal(r.unchanged, true);
210
+ assert.equal(r.reloaded, false);
211
+ });
212
+
213
+ test('改拉取用代理:未知订阅 / 含冒号的名称报错', async () => {
214
+ await assert.rejects(() => providers.setProviderProxy('nope', '自动选择'), /未找到订阅/);
215
+ await assert.rejects(() => providers.setProviderProxy('airport', 'a:b'), /不能包含/);
216
+ });
217
+
218
+ test('删除订阅:移除条目与缓存文件、保留其他段落与注释', async () => {
219
+ const r = await providers.removeProvider('airport');
220
+
221
+ assert.equal(r.cache.removed, true);
222
+ assert.equal(r.cache.path, 'providers/airport.yaml');
223
+ assert.equal(fs.existsSync(cacheFile('airport')), false);
224
+
225
+ const doc = parseConfig();
226
+ assert.equal(doc['proxy-providers'], undefined); // 最后一个订阅被删,段头一并移除
227
+ assert.equal(readConfig().includes('proxy-providers'), false);
228
+ assert.match(readConfig(), /# 组注释/);
229
+ assert.deepEqual(doc['proxy-groups'], yaml.load(BASE_CONFIG)['proxy-groups']);
230
+ });
231
+
232
+ test('删除订阅:多订阅时仅删目标条目', async () => {
233
+ await providers.addProvider({ name: 'airport2', url: 'https://example.com/link/eee' });
234
+ fs.writeFileSync(cacheFile('airport2'), 'proxies: []\n');
235
+
236
+ await providers.removeProvider('airport2');
237
+
238
+ const doc = parseConfig();
239
+ assert.deepEqual(Object.keys(doc['proxy-providers']), ['airport']);
240
+ assert.match(readConfig(), /^proxy-providers:$/m);
241
+ assert.equal(fs.existsSync(cacheFile('airport2')), false);
242
+ });
243
+
244
+ test('删除订阅:keepCache 时保留缓存文件;未知名称报错', async () => {
245
+ await providers.removeProvider('airport', { deleteCache: false });
246
+ assert.equal(fs.existsSync(cacheFile('airport')), true);
247
+ await assert.rejects(() => providers.removeProvider('nope'), /未找到订阅/);
248
+ });
249
+
250
+ test('读取订阅列表:原样返回 config.yaml 中的 proxy-providers 条目', () => {
251
+ const list = providers.listProviders();
252
+ assert.deepEqual(list, yaml.load(BASE_CONFIG)['proxy-providers']);
253
+ assert.equal(list.airport.interval, 3600);
254
+ });
255
+
256
+ test('读取订阅列表:无 proxy-providers 段时返回空映射', () => {
257
+ writeConfig('mixed-port: 7891\n');
258
+ assert.deepEqual(providers.listProviders(), {});
259
+ });