@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.
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/bin/mihomo-dash.js +4 -0
- package/package.json +63 -4
- package/server/CHANGELOG.md +51 -0
- package/server/package.json +13 -0
- package/server/src/cli/index.js +75 -0
- package/server/src/cli/service.js +167 -0
- package/server/src/cli/status.js +245 -0
- package/server/src/http/config-files.js +148 -0
- package/server/src/http/config-order.js +186 -0
- package/server/src/http/config-sections.js +278 -0
- package/server/src/http/default-config.js +69 -0
- package/server/src/http/groups.js +386 -0
- package/server/src/http/index.js +260 -0
- package/server/src/http/kernel-admin.js +522 -0
- package/server/src/http/kernel-proxy.js +43 -0
- package/server/src/http/providers.js +251 -0
- package/server/src/http/rules.js +509 -0
- package/server/src/http/settings.js +409 -0
- package/server/src/http/system-proxy.js +86 -0
- package/server/src/index.js +26 -0
- package/server/src/kernel/archive.js +181 -0
- package/server/src/kernel/binary.js +33 -0
- package/server/src/kernel/client.js +138 -0
- package/server/src/kernel/install.js +150 -0
- package/server/src/kernel/lifecycle.js +339 -0
- package/server/src/kernel/run.js +11 -0
- package/server/src/shared/args.js +57 -0
- package/server/src/shared/atomic-file.js +41 -0
- package/server/src/shared/exec.js +44 -0
- package/server/src/shared/kernel-config.js +27 -0
- package/server/src/shared/log-file.js +92 -0
- package/server/src/shared/panel.js +58 -0
- package/server/src/shared/paths.js +72 -0
- package/server/src/shared/service.js +552 -0
- package/server/src/shared/system-proxy.js +375 -0
- package/server/src/shared/text.js +11 -0
- package/server/tests/cli-service.test.js +153 -0
- package/server/tests/config-dir.test.js +133 -0
- package/server/tests/config-order.test.js +148 -0
- package/server/tests/groups.test.js +558 -0
- package/server/tests/kernel-admin.test.js +647 -0
- package/server/tests/kernel.test.js +250 -0
- package/server/tests/log-file.test.js +110 -0
- package/server/tests/providers.test.js +259 -0
- package/server/tests/rules.test.js +463 -0
- package/server/tests/service.test.js +470 -0
- package/server/tests/settings.test.js +456 -0
- package/server/tests/status.test.js +269 -0
- package/server/tests/system-proxy.test.js +497 -0
- package/server/tsconfig.json +17 -0
- package/web/dist/assets/index-BpDzYhuI.css +1 -0
- package/web/dist/assets/index-WmujD2EM.js +515 -0
- package/web/dist/favicon.svg +5 -0
- package/web/dist/index.html +14 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 订阅(proxy-providers)增删改:以文本方式增删 config.yaml 中的条目,保留原有注释与排版,
|
|
3
|
+
* 校验「解析结果与预期一致」后写盘 → 热重载(复用 config-files)。
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import yaml from 'js-yaml';
|
|
8
|
+
import { detectMihomoDir } from '../shared/paths.js';
|
|
9
|
+
import { deleteFile, saveConfigWithReload } from './config-files.js';
|
|
10
|
+
import {
|
|
11
|
+
assertName,
|
|
12
|
+
findMapEntryLine,
|
|
13
|
+
findSection,
|
|
14
|
+
indentOf,
|
|
15
|
+
keyOf,
|
|
16
|
+
loadConfig,
|
|
17
|
+
mapEntryEnd,
|
|
18
|
+
mapSection,
|
|
19
|
+
normalize,
|
|
20
|
+
removeRange,
|
|
21
|
+
sectionEnd,
|
|
22
|
+
verifyRewrite,
|
|
23
|
+
} from './config-sections.js';
|
|
24
|
+
|
|
25
|
+
const SECTION = 'proxy-providers';
|
|
26
|
+
const DEFAULT_HEALTH_URL = 'https://www.gstatic.com/generate_204';
|
|
27
|
+
const DEFAULT_INTERVAL = 3600;
|
|
28
|
+
const DEFAULT_HEALTH_INTERVAL = 300;
|
|
29
|
+
|
|
30
|
+
/** proxy-providers 段(非映射时视作空) */
|
|
31
|
+
function providerMap(doc) {
|
|
32
|
+
return mapSection(doc, SECTION);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 「拉取用代理」取值:策略组名 / 节点名 / `DIRECT`,空串表示不设(= 内核直连拉取)。
|
|
37
|
+
* 名称里出现 `:` `#` 或换行时一律拒绝(文本级写行时会被当成结构)。
|
|
38
|
+
* @param {unknown} raw
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
function cleanProxy(raw) {
|
|
42
|
+
const v = String(raw ?? '').trim();
|
|
43
|
+
if (!v) return '';
|
|
44
|
+
if (v.length > 128) throw new Error('拉取用代理的名称过长(≤128 字符)');
|
|
45
|
+
if (/[\n\r:#]/.test(v)) throw new Error('拉取用代理的名称不能包含 : # 或换行');
|
|
46
|
+
return v;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 订阅缓存文件的相对路径:优先 config 中的 path,其次 providers/<name>.yaml;不安全则返回 null */
|
|
50
|
+
function cacheRelativePath(rawPath, name) {
|
|
51
|
+
const rel =
|
|
52
|
+
typeof rawPath === 'string' && rawPath.trim() ? rawPath.trim() : `./providers/${name}.yaml`;
|
|
53
|
+
const clean = rel.replace(/^\.\//, '');
|
|
54
|
+
if (path.isAbsolute(clean) || clean.split(/[\\/]/).includes('..')) return null;
|
|
55
|
+
if (/\.ya?ml$/i.test(clean) === false) return null;
|
|
56
|
+
if (path.basename(clean) === 'config.yaml') return null;
|
|
57
|
+
return clean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 读取 config.yaml 中全部订阅条目(原样返回)。
|
|
62
|
+
* 内核 `/providers/proxies` 不回传 interval / health-check 等配置项,面板展示时以此为准。
|
|
63
|
+
*/
|
|
64
|
+
export function listProviders() {
|
|
65
|
+
const { doc } = loadConfig();
|
|
66
|
+
return providerMap(doc);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 新增订阅:写入 `proxy-providers.<name>`(type: http)并热重载。
|
|
71
|
+
* `proxy` 不填就不写这个键(内核按直连拉取,墙内常因此拉不动 —— 面板表单默认给一个策略组)。
|
|
72
|
+
* @param {{ name: string, url: string, interval?: number, healthCheckUrl?: string,
|
|
73
|
+
* healthCheckInterval?: number, proxy?: string }} input
|
|
74
|
+
*/
|
|
75
|
+
export async function addProvider(input) {
|
|
76
|
+
const name = assertName(input?.name, '订阅名称');
|
|
77
|
+
const url = String(input?.url ?? '').trim();
|
|
78
|
+
if (!/^https?:\/\/\S+$/i.test(url)) throw new Error('订阅链接需为 http(s) 开头的 URL');
|
|
79
|
+
|
|
80
|
+
const { text, doc } = loadConfig();
|
|
81
|
+
const existing = providerMap(doc);
|
|
82
|
+
if (Object.prototype.hasOwnProperty.call(existing, name)) throw new Error(`订阅 ${name} 已存在`);
|
|
83
|
+
const dir = detectMihomoDir();
|
|
84
|
+
const relPath = `providers/${name}.yaml`;
|
|
85
|
+
if (fs.existsSync(path.join(dir, relPath))) {
|
|
86
|
+
throw new Error(`${relPath} 已存在,请换一个名称`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const proxy = cleanProxy(input?.proxy);
|
|
90
|
+
const entry = {
|
|
91
|
+
type: 'http',
|
|
92
|
+
url,
|
|
93
|
+
interval: Number(input?.interval) > 0 ? Number(input.interval) : DEFAULT_INTERVAL,
|
|
94
|
+
path: `./${relPath}`,
|
|
95
|
+
...(proxy ? { proxy } : {}),
|
|
96
|
+
'health-check': {
|
|
97
|
+
enable: true,
|
|
98
|
+
interval:
|
|
99
|
+
Number(input?.healthCheckInterval) > 0
|
|
100
|
+
? Number(input.healthCheckInterval)
|
|
101
|
+
: DEFAULT_HEALTH_INTERVAL,
|
|
102
|
+
url: String(input?.healthCheckUrl ?? '').trim() || DEFAULT_HEALTH_URL,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const block = yaml
|
|
107
|
+
.dump({ [name]: entry }, { indent: 2, lineWidth: 100, noRefs: true })
|
|
108
|
+
.trimEnd()
|
|
109
|
+
.split('\n')
|
|
110
|
+
.map((l) => (l ? ` ${l}` : l));
|
|
111
|
+
|
|
112
|
+
const lines = text.split('\n');
|
|
113
|
+
const at = findSection(lines, SECTION);
|
|
114
|
+
const out =
|
|
115
|
+
at >= 0
|
|
116
|
+
? [...lines.slice(0, at + 1), ...block, ...lines.slice(at + 1)]
|
|
117
|
+
: [...lines, `${SECTION}:`, ...block];
|
|
118
|
+
|
|
119
|
+
const next = normalize(out);
|
|
120
|
+
verifyRewrite(next, { ...doc, [SECTION]: { ...existing, [name]: entry } });
|
|
121
|
+
|
|
122
|
+
const reload = await saveConfigWithReload(next);
|
|
123
|
+
return { ok: true, name, path: relPath, proxy, ...reload };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 改订阅的「拉取用代理」(`proxy` 字段):走哪个策略组 / 节点去拉这份订阅。
|
|
128
|
+
* 空串 = 删掉该键(回退内核默认的直连拉取)。
|
|
129
|
+
* 文本级改写:只替换 / 插入 `proxy:` 这一行,条目里其它键、注释与排版都不动。
|
|
130
|
+
* @param {string} name
|
|
131
|
+
* @param {string} [rawProxy]
|
|
132
|
+
*/
|
|
133
|
+
export async function setProviderProxy(name, rawProxy) {
|
|
134
|
+
const providerName = assertName(name, '订阅名称');
|
|
135
|
+
const proxy = cleanProxy(rawProxy);
|
|
136
|
+
const { text, doc } = loadConfig();
|
|
137
|
+
const existing = providerMap(doc);
|
|
138
|
+
const entry = existing[providerName];
|
|
139
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
140
|
+
throw new Error(`config.yaml 中未找到订阅 ${providerName}`);
|
|
141
|
+
}
|
|
142
|
+
const current = typeof entry.proxy === 'string' ? entry.proxy.trim() : '';
|
|
143
|
+
if (current === proxy) {
|
|
144
|
+
return { ok: true, name: providerName, proxy, unchanged: true, reloaded: false };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const lines = text.split('\n');
|
|
148
|
+
const at = findSection(lines, SECTION);
|
|
149
|
+
if (at < 0) throw new Error(`config.yaml 中未找到 ${SECTION} 段`);
|
|
150
|
+
const start = findMapEntryLine(lines, at + 1, providerName);
|
|
151
|
+
if (start < 0) throw new Error(`config.yaml 中未找到订阅 ${providerName} 的条目`);
|
|
152
|
+
if (/^\s*[^:#]+:\s*\{/.test(lines[start])) {
|
|
153
|
+
throw new Error(`订阅 ${providerName} 是行内写法({…}),请先去「配置文件」页改成块状写法`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const end = mapEntryEnd(lines, start + 1);
|
|
157
|
+
/** 已有 `proxy:` 子键所在行(没有则 -1)与子键缩进(默认 4) */
|
|
158
|
+
let proxyLine = -1;
|
|
159
|
+
let childIndent = 0;
|
|
160
|
+
for (let i = start + 1; i < end; i++) {
|
|
161
|
+
const t = lines[i].trim();
|
|
162
|
+
if (!t || t.startsWith('#')) continue;
|
|
163
|
+
if (!childIndent) childIndent = indentOf(lines[i]);
|
|
164
|
+
if (keyOf(lines[i]) === 'proxy') proxyLine = i;
|
|
165
|
+
}
|
|
166
|
+
if (!childIndent) childIndent = 4;
|
|
167
|
+
|
|
168
|
+
const expectedEntry = { ...entry };
|
|
169
|
+
let out;
|
|
170
|
+
if (!proxy) {
|
|
171
|
+
delete expectedEntry.proxy;
|
|
172
|
+
out = proxyLine >= 0 ? removeRange(lines, proxyLine, proxyLine + 1, { blank: false }) : lines;
|
|
173
|
+
} else {
|
|
174
|
+
expectedEntry.proxy = proxy;
|
|
175
|
+
// 用 js-yaml 渲染这一行:名字里带空格 / 特殊字符时它会自己加引号
|
|
176
|
+
const line = ' '.repeat(childIndent) + yaml.dump({ proxy }, { lineWidth: 100 }).trimEnd();
|
|
177
|
+
if (proxyLine >= 0) {
|
|
178
|
+
out = [...lines.slice(0, proxyLine), line, ...lines.slice(proxyLine + 1)];
|
|
179
|
+
} else {
|
|
180
|
+
// 插在 `path:` 之后(没有 path 就紧跟条目首行),让键序与面板新增的条目一致
|
|
181
|
+
let after = start;
|
|
182
|
+
for (let i = start + 1; i < end; i++) {
|
|
183
|
+
if (keyOf(lines[i]) === 'path') {
|
|
184
|
+
after = i;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
out = [...lines.slice(0, after + 1), line, ...lines.slice(after + 1)];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const next = normalize(out);
|
|
193
|
+
verifyRewrite(next, { ...doc, [SECTION]: { ...existing, [providerName]: expectedEntry } });
|
|
194
|
+
|
|
195
|
+
const reload = await saveConfigWithReload(next);
|
|
196
|
+
return { ok: true, name: providerName, proxy, ...reload };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 删除订阅:从 config.yaml 移除条目并热重载,随后删除本地缓存文件。
|
|
201
|
+
* @param {string} name
|
|
202
|
+
* @param {{ deleteCache?: boolean }} [opts]
|
|
203
|
+
*/
|
|
204
|
+
export async function removeProvider(name, opts = {}) {
|
|
205
|
+
const providerName = assertName(name, '订阅名称');
|
|
206
|
+
const { text, doc } = loadConfig();
|
|
207
|
+
const existing = providerMap(doc);
|
|
208
|
+
if (!Object.prototype.hasOwnProperty.call(existing, providerName)) {
|
|
209
|
+
throw new Error(`config.yaml 中未找到订阅 ${providerName}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const entry = existing[providerName] ?? {};
|
|
213
|
+
const rest = { ...existing };
|
|
214
|
+
delete rest[providerName];
|
|
215
|
+
|
|
216
|
+
const lines = text.split('\n');
|
|
217
|
+
const at = findSection(lines, SECTION);
|
|
218
|
+
if (at < 0) throw new Error(`config.yaml 中未找到 ${SECTION} 段`);
|
|
219
|
+
|
|
220
|
+
let out;
|
|
221
|
+
let expected;
|
|
222
|
+
if (Object.keys(rest).length === 0) {
|
|
223
|
+
// 最后一个订阅:连同 proxy-providers 标题行与其下所有条目一起移除
|
|
224
|
+
out = removeRange(lines, at, sectionEnd(lines, at + 1));
|
|
225
|
+
expected = { ...doc };
|
|
226
|
+
delete expected[SECTION];
|
|
227
|
+
} else {
|
|
228
|
+
const start = findMapEntryLine(lines, at + 1, providerName);
|
|
229
|
+
if (start < 0) throw new Error(`config.yaml 中未找到订阅 ${providerName} 的条目`);
|
|
230
|
+
out = removeRange(lines, start, mapEntryEnd(lines, start + 1));
|
|
231
|
+
expected = { ...doc, [SECTION]: rest };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const next = normalize(out);
|
|
235
|
+
verifyRewrite(next, expected);
|
|
236
|
+
|
|
237
|
+
const reload = await saveConfigWithReload(next);
|
|
238
|
+
|
|
239
|
+
// 配置已生效后再清理本地缓存;删除失败不影响结果,仅回报
|
|
240
|
+
let cache = { removed: false, path: null };
|
|
241
|
+
const rel = cacheRelativePath(entry.path, providerName);
|
|
242
|
+
if (opts.deleteCache !== false && rel) {
|
|
243
|
+
try {
|
|
244
|
+
cache = { ...deleteFile(rel), path: rel };
|
|
245
|
+
} catch (e) {
|
|
246
|
+
cache = { removed: false, path: rel, error: String(e?.message || e) };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { ok: true, name: providerName, cache, ...reload };
|
|
251
|
+
}
|