@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.
- 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 +44 -0
- package/server/package.json +13 -0
- package/server/src/cli/index.js +75 -0
- package/server/src/cli/service.js +166 -0
- package/server/src/cli/status.js +243 -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 +266 -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,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mihomo-dash status:一眼看清这台机器上的几件事(默认命令,无参数时也走它):
|
|
3
|
+
*
|
|
4
|
+
* 内核(版本 / 模式 / 控制接口 / 密钥)· 配置文件规模 · 面板进程 · 系统代理 · 开机自启
|
|
5
|
+
*
|
|
6
|
+
* 每一块单独兜底:探不到就写一行原因,不让别的块跟着消失(面板没起、平台不支持都不算错)。
|
|
7
|
+
* **退出码沿用老行为:内核不通 → 1**(方便脚本当健康检查),其它块不参与判定。
|
|
8
|
+
*
|
|
9
|
+
* 探测函数(proxy / service)可注入,测试用桩,不碰本机系统设置与 launchd / systemd。
|
|
10
|
+
*/
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { bareVersion } from '../kernel/binary.js';
|
|
13
|
+
import { getController, kernelFetch } from '../kernel/client.js';
|
|
14
|
+
import { detectMihomoDir } from '../shared/paths.js';
|
|
15
|
+
import { probePanel } from '../shared/panel.js';
|
|
16
|
+
import { createSystemProxy } from '../shared/system-proxy.js';
|
|
17
|
+
import { createServiceManager } from '../shared/service.js';
|
|
18
|
+
import { pad } from '../shared/text.js';
|
|
19
|
+
import { loadKernelConfig } from '../shared/kernel-config.js';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const { version: PKG_VERSION } = require('../../../package.json');
|
|
23
|
+
|
|
24
|
+
/** 顶层标签的显示宽度(中文各占 2 列) */
|
|
25
|
+
const LABEL_WIDTH = 10;
|
|
26
|
+
/** 子行的缩进 */
|
|
27
|
+
const SUB = ' ';
|
|
28
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
29
|
+
const map = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
30
|
+
|
|
31
|
+
/** 内核:控制接口通不通、版本 / 模式 / 密钥 */
|
|
32
|
+
async function kernelPart() {
|
|
33
|
+
const { url, secret } = getController();
|
|
34
|
+
try {
|
|
35
|
+
const [version, configs] = await Promise.all([
|
|
36
|
+
kernelFetch('/version'),
|
|
37
|
+
kernelFetch('/configs'),
|
|
38
|
+
]);
|
|
39
|
+
return {
|
|
40
|
+
up: true,
|
|
41
|
+
api: url,
|
|
42
|
+
secret: !!secret,
|
|
43
|
+
version: `${bareVersion(version.version)}${version.meta ? ' (Meta)' : ''}`,
|
|
44
|
+
mode: configs?.mode ?? '-',
|
|
45
|
+
};
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return { up: false, api: url, secret: !!secret, error: String(err?.message || err) };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 配置文件:在哪、能不能解析、里面有多少东西(不存在的目录不算错,交给 doctor 去报) */
|
|
52
|
+
function configPart() {
|
|
53
|
+
const { path, config, error } = loadKernelConfig();
|
|
54
|
+
if (!config) return { path, dir: detectMihomoDir(), exists: false, error };
|
|
55
|
+
return {
|
|
56
|
+
path,
|
|
57
|
+
dir: detectMihomoDir(),
|
|
58
|
+
exists: true,
|
|
59
|
+
providers: Object.keys(map(config['proxy-providers'])).length,
|
|
60
|
+
groups: list(config['proxy-groups']).length,
|
|
61
|
+
rules: list(config.rules).length,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 面板进程:打 `/api/health` 探活(不再看 pid 文件)。
|
|
67
|
+
* 拿到 version / uptime 就能看出是不是旧版本在跑。
|
|
68
|
+
* @param {{ host?: string, port?: number }} [target]
|
|
69
|
+
*/
|
|
70
|
+
async function panelPart(target = {}) {
|
|
71
|
+
const info = await probePanel(target);
|
|
72
|
+
return {
|
|
73
|
+
running: info.running,
|
|
74
|
+
url: info.url,
|
|
75
|
+
port: info.port,
|
|
76
|
+
pid: info.pid ?? 0,
|
|
77
|
+
version: info.version,
|
|
78
|
+
uptime: info.uptime,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 系统代理:开没开、指向哪、绕过几条、动的是哪个网络服务 */
|
|
83
|
+
async function proxyPart(proxy) {
|
|
84
|
+
const raw = await proxy.read();
|
|
85
|
+
const enabled = ['http', 'https', 'socks'].filter((k) => raw.protocols?.[k]?.enabled);
|
|
86
|
+
const one = enabled.length ? raw.protocols[enabled[0]] : null;
|
|
87
|
+
return {
|
|
88
|
+
supported: !!raw.supported,
|
|
89
|
+
hint: raw.hint ?? null,
|
|
90
|
+
enabled: !!raw.enabled,
|
|
91
|
+
protocols: enabled,
|
|
92
|
+
pointing: one ? `${one.host}:${one.port}` : null,
|
|
93
|
+
bypass: list(raw.bypass).length,
|
|
94
|
+
service: raw.service ?? null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 开机自启:两个服务定义装没装、什么时候会起(登录后 / 开机即起) */
|
|
99
|
+
async function autostartPart(service) {
|
|
100
|
+
const st = await service.status();
|
|
101
|
+
return {
|
|
102
|
+
supported: !!st.supported,
|
|
103
|
+
hint: st.hint ?? null,
|
|
104
|
+
platform: st.platform ?? process.platform,
|
|
105
|
+
linger: st.linger ?? null,
|
|
106
|
+
installed: list(st.jobs).filter((j) => j.installed).length,
|
|
107
|
+
total: list(st.jobs).length || 2,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 汇总所有块。任何一块抛错都只记下原因。
|
|
113
|
+
* @param {{ proxy?: { read: Function }, service?: { status: Function }, panel?: { host?: string, port?: number } }} [opts]
|
|
114
|
+
* 测试可注入桩与面板端口
|
|
115
|
+
*/
|
|
116
|
+
export async function gatherStatus(opts = {}) {
|
|
117
|
+
const proxy = opts.proxy || createSystemProxy();
|
|
118
|
+
const service = opts.service || createServiceManager();
|
|
119
|
+
const safe = async (fn, fallback) => {
|
|
120
|
+
try {
|
|
121
|
+
return await fn();
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return { ...fallback, error: String(err?.message || err) };
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const [kernel, panel, proxyState, autostart] = await Promise.all([
|
|
128
|
+
safe(kernelPart, { up: false }),
|
|
129
|
+
safe(() => panelPart(opts.panel), { running: false }),
|
|
130
|
+
safe(() => proxyPart(proxy), { supported: false }),
|
|
131
|
+
safe(() => autostartPart(service), { supported: false }),
|
|
132
|
+
]);
|
|
133
|
+
return { kernel, panel, config: configPart(), proxy: proxyState, autostart };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const line = (label, text) => `${pad(label, LABEL_WIDTH)}${text}`;
|
|
137
|
+
const sub = (label, text) => `${SUB}${pad(label, LABEL_WIDTH - SUB.length)} ${text}`;
|
|
138
|
+
const indent = (text) => `${SUB}${text}`;
|
|
139
|
+
|
|
140
|
+
const whenText = (platform, linger) => {
|
|
141
|
+
if (platform === 'darwin') return '登录后启动';
|
|
142
|
+
if (linger === true) return '开机即起(linger 已开)';
|
|
143
|
+
if (linger === false) return '登录后启动(无 linger)';
|
|
144
|
+
return '登录后启动';
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const duration = (sec) => {
|
|
148
|
+
const s = Math.max(0, Math.floor(sec));
|
|
149
|
+
if (s < 60) return `${s} 秒`;
|
|
150
|
+
if (s < 3600) return `${Math.floor(s / 60)} 分钟`;
|
|
151
|
+
return `${Math.floor(s / 3600)} 小时 ${Math.floor((s % 3600) / 60)} 分钟`;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 把汇总结果渲染成给人看的几行。
|
|
156
|
+
* @param {Awaited<ReturnType<typeof gatherStatus>>} d
|
|
157
|
+
* @returns {string}
|
|
158
|
+
*/
|
|
159
|
+
export function formatStatus(d) {
|
|
160
|
+
const { kernel, panel, config, proxy, autostart } = d;
|
|
161
|
+
const out = [];
|
|
162
|
+
|
|
163
|
+
// 内核
|
|
164
|
+
if (kernel.up) {
|
|
165
|
+
out.push(line('内核', `运行中 · v${kernel.version} · ${kernel.mode}`));
|
|
166
|
+
out.push(
|
|
167
|
+
sub(
|
|
168
|
+
'控制接口',
|
|
169
|
+
`${kernel.api} · ${kernel.secret ? '密钥已配置' : '密钥未配置(无鉴权,仅适合本机使用)'}`,
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
} else {
|
|
173
|
+
const why = kernel.error ? `不通:${kernel.error}` : '不通';
|
|
174
|
+
out.push(line('内核', `未运行(控制接口 ${kernel.api} ${why})`));
|
|
175
|
+
out.push(indent('启动:用侧栏底部的「启动内核」,或面板「内核」页安装后启动'));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 配置文件
|
|
179
|
+
if (config.exists) {
|
|
180
|
+
out.push(
|
|
181
|
+
line(
|
|
182
|
+
'配置文件',
|
|
183
|
+
`${config.path}(订阅 ${config.providers} / 策略组 ${config.groups} / 规则 ${config.rules})`,
|
|
184
|
+
),
|
|
185
|
+
);
|
|
186
|
+
} else {
|
|
187
|
+
out.push(
|
|
188
|
+
line('配置文件', `还没有 config.yaml(${config.dir}):在面板「配置文件」页写好保存就会创建`),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 面板
|
|
193
|
+
if (panel.running) {
|
|
194
|
+
const bits = [`pid ${panel.pid}`];
|
|
195
|
+
if (panel.version) bits.push(`v${panel.version}`);
|
|
196
|
+
if (panel.uptime !== null && panel.uptime !== undefined)
|
|
197
|
+
bits.push(`已运行 ${duration(panel.uptime)}`);
|
|
198
|
+
out.push(line('面板', `运行中 · ${panel.url} · ${bits.join(' · ')}`));
|
|
199
|
+
} else {
|
|
200
|
+
out.push(line('面板', `未运行(端口 ${panel.port};在面板「自启」页装成系统服务)`));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 系统代理
|
|
204
|
+
if (!proxy.supported) {
|
|
205
|
+
out.push(line('系统代理', proxy.hint ? `不支持(${proxy.hint})` : '不支持'));
|
|
206
|
+
} else if (!proxy.enabled) {
|
|
207
|
+
out.push(line('系统代理', '关(系统直连,内核照跑)'));
|
|
208
|
+
} else {
|
|
209
|
+
const bits = [
|
|
210
|
+
proxy.pointing
|
|
211
|
+
? `${proxy.protocols.map((p) => p.toUpperCase()).join('/')} → ${proxy.pointing}`
|
|
212
|
+
: proxy.protocols.map((p) => p.toUpperCase()).join('/') || '未启用协议',
|
|
213
|
+
];
|
|
214
|
+
bits.push(`绕过 ${proxy.bypass} 条`);
|
|
215
|
+
if (proxy.service) bits.push(`服务 ${proxy.service}`);
|
|
216
|
+
out.push(line('系统代理', `开 · ${bits.join(' · ')}`));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 开机自启
|
|
220
|
+
if (!autostart.supported) {
|
|
221
|
+
out.push(line('开机自启', autostart.hint ? `不支持(${autostart.hint})` : '不支持'));
|
|
222
|
+
} else if (!autostart.installed) {
|
|
223
|
+
out.push(line('开机自启', '未安装(面板「自启」页打开开关即可)'));
|
|
224
|
+
} else {
|
|
225
|
+
const where = autostart.platform === 'darwin' ? 'macOS LaunchAgent' : 'systemd --user';
|
|
226
|
+
out.push(
|
|
227
|
+
line(
|
|
228
|
+
'开机自启',
|
|
229
|
+
`已安装 ${autostart.installed}/${autostart.total} · ${where} · ${whenText(autostart.platform, autostart.linger)}`,
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return out.join('\n');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function cmdStatus(panel) {
|
|
238
|
+
console.log(`mihomo-dash ${PKG_VERSION}\n`);
|
|
239
|
+
const data = await gatherStatus({ panel });
|
|
240
|
+
console.log(formatStatus(data));
|
|
241
|
+
// 内核不通当作失败:老行为,方便脚本拿它当健康检查
|
|
242
|
+
if (!data.kernel.up) process.exit(1);
|
|
243
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/** 配置文件读写:YAML 校验 → 写盘 → 热重载。 */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
import { writeFileAtomic } from '../shared/atomic-file.js';
|
|
6
|
+
import { configPath, detectMihomoDir, ensureConfigDir } from '../shared/paths.js';
|
|
7
|
+
import { kernelFetch } from '../kernel/client.js';
|
|
8
|
+
import { orderTopLevel } from './config-order.js';
|
|
9
|
+
|
|
10
|
+
/** 内核没在运行时写盘的提示文案(写盘已经成功,只是没生效;前端拿它显示黄色 toast) */
|
|
11
|
+
export const KERNEL_DOWN_WARNING = '内核没在运行:配置已写入,启动内核后生效';
|
|
12
|
+
|
|
13
|
+
/** name → 内核目录内的绝对路径;限制 .yaml/.yml 且防路径穿越 */
|
|
14
|
+
function safeResolve(name) {
|
|
15
|
+
const root = path.resolve(detectMihomoDir());
|
|
16
|
+
const p = path.resolve(root, name);
|
|
17
|
+
if (p !== root && !p.startsWith(root + path.sep)) throw new Error('非法路径');
|
|
18
|
+
if (!/\.ya?ml$/i.test(p)) throw new Error('仅支持 YAML 文件');
|
|
19
|
+
return p;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function validateYaml(content) {
|
|
23
|
+
try {
|
|
24
|
+
return yaml.load(content);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
throw new Error(`YAML 解析失败: ${e.message}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 顶层键排序 + 安全网:排完必须解析出**完全一样**的配置(只是键的顺序变了)。
|
|
32
|
+
* 排序只是排版,万一它搬丢 / 搬重了内容,宁可放弃排序也不让写盘出错 ——
|
|
33
|
+
* 所以这里比一遍解析结果,不一致就原样返回(并留一行日志)。
|
|
34
|
+
* @param {string} content
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function orderedConfigText(content) {
|
|
38
|
+
let next;
|
|
39
|
+
try {
|
|
40
|
+
next = orderTopLevel(content);
|
|
41
|
+
if (next === content) return content;
|
|
42
|
+
if (!sameDoc(yaml.load(next), yaml.load(content))) {
|
|
43
|
+
console.warn('[mihomo-dash] 顶层键排序被跳过:排序后解析结果与原文不一致');
|
|
44
|
+
return content;
|
|
45
|
+
}
|
|
46
|
+
return next;
|
|
47
|
+
} catch (e) {
|
|
48
|
+
console.warn(`[mihomo-dash] 顶层键排序被跳过:${e.message}`);
|
|
49
|
+
return content;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 两份解析结果是否完全一致(键序无关;对象 / 数组逐层比) */
|
|
54
|
+
function sameDoc(a, b) {
|
|
55
|
+
if (a === b) return true;
|
|
56
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
57
|
+
return (
|
|
58
|
+
Array.isArray(a) &&
|
|
59
|
+
Array.isArray(b) &&
|
|
60
|
+
a.length === b.length &&
|
|
61
|
+
a.every((v, i) => sameDoc(v, b[i]))
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (a && b && typeof a === 'object' && typeof b === 'object') {
|
|
65
|
+
const ka = Object.keys(a);
|
|
66
|
+
const kb = Object.keys(b);
|
|
67
|
+
return ka.length === kb.length && ka.every((k) => k in b && sameDoc(a[k], b[k]));
|
|
68
|
+
}
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 可编辑文件清单:config.yaml + providers/*.yaml */
|
|
73
|
+
import { resetControllerCache } from '../kernel/client.js';
|
|
74
|
+
|
|
75
|
+
export function listFiles() {
|
|
76
|
+
const dir = detectMihomoDir();
|
|
77
|
+
const files = ['config.yaml'];
|
|
78
|
+
const providerDir = path.join(dir, 'providers');
|
|
79
|
+
if (fs.existsSync(providerDir)) {
|
|
80
|
+
for (const f of fs.readdirSync(providerDir).sort()) {
|
|
81
|
+
if (/\.ya?ml$/i.test(f)) files.push(`providers/${f}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return files.filter((f) => fs.existsSync(path.join(dir, f)));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function readFile(name) {
|
|
88
|
+
const p = safeResolve(name);
|
|
89
|
+
if (!fs.existsSync(p)) throw new Error(`还没有这个文件:${p}(保存一次就会创建)`);
|
|
90
|
+
return fs.readFileSync(p, 'utf8');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 读文件但「不存在」不算错:给「配置文件」页判断该不该显示「还没有配置」 */
|
|
94
|
+
export function readFileInfo(name) {
|
|
95
|
+
const p = safeResolve(name);
|
|
96
|
+
const exists = fs.existsSync(p);
|
|
97
|
+
return { name, path: p, exists, content: exists ? fs.readFileSync(p, 'utf8') : '' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 保存任意编辑文件(不触发内核重载);config.yaml 顺手把顶层键按固定顺序排好 */
|
|
101
|
+
export function saveFile(name, content) {
|
|
102
|
+
validateYaml(content);
|
|
103
|
+
const text = name === 'config.yaml' ? orderedConfigText(content) : content;
|
|
104
|
+
const p = safeResolve(name);
|
|
105
|
+
fs.mkdirSync(path.dirname(p), { recursive: true }); // providers/*.yaml 这类子目录也要建出来
|
|
106
|
+
ensureConfigDir();
|
|
107
|
+
writeFileAtomic(p, text);
|
|
108
|
+
// 改的可能就是面板在用的 config.yaml(external-controller / secret):写盘后让接口缓存失效
|
|
109
|
+
resetControllerCache();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 删除文件;文件不存在时返回 removed: false */
|
|
113
|
+
export function deleteFile(name) {
|
|
114
|
+
const p = safeResolve(name);
|
|
115
|
+
if (!fs.existsSync(p)) return { removed: false };
|
|
116
|
+
fs.unlinkSync(p);
|
|
117
|
+
return { removed: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 保存 config.yaml 并热重载内核。
|
|
122
|
+
*
|
|
123
|
+
* 两种「写盘成功但未生效」不当成错误:
|
|
124
|
+
* - **内核没在跑 / 连不上**(没有 HTTP 状态码):文件确实已经写好,返回 `reloaded: false` +
|
|
125
|
+
* 一句 warning,让页面提示「已写入,启动内核后生效」;以前这里直接抛错,前端弹「保存失败」,
|
|
126
|
+
* 用户以为没存上、反复重存,其实文件已经变了。
|
|
127
|
+
* - 内核**回了错**(配置不合法、重载被拒)仍然抛错:那是真问题,只是文件已经写进去了(不回滚)。
|
|
128
|
+
* @param {string} content
|
|
129
|
+
* @returns {Promise<{ reloaded: boolean, warning?: string }>}
|
|
130
|
+
*/
|
|
131
|
+
export async function saveConfigWithReload(content) {
|
|
132
|
+
const p = configPath();
|
|
133
|
+
const text = orderedConfigText(content);
|
|
134
|
+
validateYaml(text);
|
|
135
|
+
ensureConfigDir();
|
|
136
|
+
writeFileAtomic(p, text);
|
|
137
|
+
resetControllerCache();
|
|
138
|
+
try {
|
|
139
|
+
await kernelFetch('/configs?force=true', {
|
|
140
|
+
method: 'PUT',
|
|
141
|
+
body: { path: p, payload: '' },
|
|
142
|
+
});
|
|
143
|
+
} catch (e) {
|
|
144
|
+
if (!e?.status) return { reloaded: false, warning: KERNEL_DOWN_WARNING };
|
|
145
|
+
throw new Error(`热重载失败(config.yaml 已写入,未回滚): ${e.message}`);
|
|
146
|
+
}
|
|
147
|
+
return { reloaded: true };
|
|
148
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.yaml 顶层键的固定顺序(2026-11 定的规范)。
|
|
3
|
+
*
|
|
4
|
+
* 面板**每次写盘**都会调 `orderTopLevel()` 把顶层键按下面的顺序排一遍:写配置的人不用自己挑
|
|
5
|
+
* 位置(以前缺的键统一插到文件最顶上,于是 GEO / 快捷设置那几个键总是飘在 mixed-port 上面),
|
|
6
|
+
* 手写的文件保存一次也就归位了。
|
|
7
|
+
*
|
|
8
|
+
* 只搬「整块」的位置,不改内容:
|
|
9
|
+
* - 键下面缩进的内容、行尾注释、键上方紧贴的说明注释都跟着键一起走,缩进与字面量一个字不改;
|
|
10
|
+
* - 面板不认识的顶层键不算乱丢:它们保持**原相对顺序**,统一排在「规则」组(rules / sub-rules /
|
|
11
|
+
* script)前面(见下面的 GEO_TAIL 说明);
|
|
12
|
+
* - 顶层键**挨着写,块与块之间不留空行**(文件头注释与第一个键之间也一样):配置项之间空一行
|
|
13
|
+
* 只是好看,写多了反而难扫;想分组就用注释行(注释会跟着它下面那个键走)。块内部的空行、
|
|
14
|
+
* 缩进、注释、排版一律不动。
|
|
15
|
+
*
|
|
16
|
+
* 这个模块刻意不 import 任何东西(只做「文本进、文本出」),好让 config-files.js 直接用而不绕成环。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 顶层键的固定顺序。分组只为读起来清楚,同组内按数组先后排。
|
|
21
|
+
*/
|
|
22
|
+
export const TOP_LEVEL_ORDER = [
|
|
23
|
+
// ① 基础运行:流量从哪进来、内核怎么跑
|
|
24
|
+
'mixed-port',
|
|
25
|
+
'port',
|
|
26
|
+
'socks-port',
|
|
27
|
+
'redir-port',
|
|
28
|
+
'tproxy-port',
|
|
29
|
+
'tun',
|
|
30
|
+
'allow-lan',
|
|
31
|
+
'bind-address',
|
|
32
|
+
'ipv6',
|
|
33
|
+
'mode',
|
|
34
|
+
'log-level',
|
|
35
|
+
'unified-delay',
|
|
36
|
+
'tcp-concurrent',
|
|
37
|
+
'find-process-mode',
|
|
38
|
+
'global-client-fingerprint',
|
|
39
|
+
'interface-name',
|
|
40
|
+
'routing-mark',
|
|
41
|
+
'keep-alive-interval',
|
|
42
|
+
'keep-alive-idle',
|
|
43
|
+
'disable-keep-alive',
|
|
44
|
+
'authentication',
|
|
45
|
+
'skip-auth-prefixes',
|
|
46
|
+
// ② 控制接口:面板 / 别的客户端连哪个端口
|
|
47
|
+
'external-controller',
|
|
48
|
+
'external-controller-tls',
|
|
49
|
+
'external-controller-unix',
|
|
50
|
+
'external-controller-pipe',
|
|
51
|
+
'secret',
|
|
52
|
+
'external-ui',
|
|
53
|
+
'external-ui-name',
|
|
54
|
+
'external-ui-url',
|
|
55
|
+
// ③ GEO 数据
|
|
56
|
+
'geodata-mode',
|
|
57
|
+
'geodata-loader',
|
|
58
|
+
'geo-auto-update',
|
|
59
|
+
'geo-update-interval',
|
|
60
|
+
'geox-url',
|
|
61
|
+
// ④ 内核行为 / 数据面
|
|
62
|
+
'profile',
|
|
63
|
+
'dns',
|
|
64
|
+
'hosts',
|
|
65
|
+
'sniffer',
|
|
66
|
+
'tls',
|
|
67
|
+
'experimental',
|
|
68
|
+
'ntp',
|
|
69
|
+
'listeners',
|
|
70
|
+
'tunnels',
|
|
71
|
+
// ⑤ 代理来源与分组
|
|
72
|
+
'proxy-providers',
|
|
73
|
+
'rule-providers',
|
|
74
|
+
'proxies',
|
|
75
|
+
'proxy-groups',
|
|
76
|
+
// ⑥ 规则:惯例放最后
|
|
77
|
+
'rules',
|
|
78
|
+
'sub-rules',
|
|
79
|
+
'script',
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** 面板不认识的键排在这组之前(即紧挨着 rules 上面),保持它们之间的相对顺序 */
|
|
83
|
+
const UNKNOWN_RANK = TOP_LEVEL_ORDER.indexOf('rules');
|
|
84
|
+
|
|
85
|
+
/** 行首缩进宽度 */
|
|
86
|
+
export const indentOf = (line) => line.match(/^\s*/)[0].length;
|
|
87
|
+
|
|
88
|
+
/** 取行首键名(兼容引号键;列表项与注释返回 null) */
|
|
89
|
+
export function keyOf(line) {
|
|
90
|
+
const t = line.trim();
|
|
91
|
+
if (!t || t.startsWith('#') || t.startsWith('- ')) return null;
|
|
92
|
+
const m = line.match(/^\s*(?:"([^"]*)"|'([^']*)'|([^:#]*?))\s*:/);
|
|
93
|
+
if (!m) return null;
|
|
94
|
+
return (m[1] ?? m[2] ?? m[3] ?? '').trim();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 顶层段落(键行 + 其下缩进内容,含中间空行)结束的行号。
|
|
99
|
+
* 空行不算结束 —— 段落后面跟的空行视作段落的一部分,下一行顶格内容才算下一段。
|
|
100
|
+
*/
|
|
101
|
+
export function sectionEnd(lines, start) {
|
|
102
|
+
let i = start;
|
|
103
|
+
while (i < lines.length) {
|
|
104
|
+
if (!lines[i].trim()) {
|
|
105
|
+
i++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (indentOf(lines[i]) === 0) break;
|
|
109
|
+
i++;
|
|
110
|
+
}
|
|
111
|
+
return i;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const isBlank = (line) => !line.trim();
|
|
115
|
+
/** 顶层注释行(缩进 0 的 `# ...`) */
|
|
116
|
+
const isTopComment = (line) => indentOf(line) === 0 && line.trim().startsWith('#');
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 某个顶层键所在块的起始行(含上方属于它的注释行)。
|
|
120
|
+
*
|
|
121
|
+
* 注释归属规则:
|
|
122
|
+
* - **文件最上面的注释块是文件头**(不跟任何键走):第一个顶层键上面的注释一律留在原位;
|
|
123
|
+
* - 紧贴键上方、中间没有空行的注释 → 跟着这个键走;
|
|
124
|
+
* - 「上下都被空行夹着」的注释块(分节标题那种)→ 也算下面那个键的;
|
|
125
|
+
* - 其余(段尾注释、被缩进的注释)留在原处,跟着上面那块走。
|
|
126
|
+
*/
|
|
127
|
+
function blockStart(lines, at) {
|
|
128
|
+
let start = at;
|
|
129
|
+
while (start > 0 && isTopComment(lines[start - 1])) start--;
|
|
130
|
+
if (start === 0 || !isBlank(lines[start - 1])) return start;
|
|
131
|
+
// 再往上是空行:看看空行之上是不是一整块独立的注释(分节标题),是就一并搬走
|
|
132
|
+
let head = start - 1;
|
|
133
|
+
while (head > 0 && isBlank(lines[head - 1])) head--;
|
|
134
|
+
let top = head;
|
|
135
|
+
while (top > 0 && isTopComment(lines[top - 1])) top--;
|
|
136
|
+
const isDetachedBlock = top < head && (top === 0 || isBlank(lines[top - 1]));
|
|
137
|
+
return isDetachedBlock ? top : start;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 块内容的结束行(去掉尾部的空行,空行由拼装时统一给一个) */
|
|
141
|
+
function blockEnd(lines, start, next) {
|
|
142
|
+
let end = next;
|
|
143
|
+
while (end > start && isBlank(lines[end - 1])) end--;
|
|
144
|
+
return end;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 按 {@link TOP_LEVEL_ORDER} 重排 config.yaml 的顶层键,返回新文本(末尾保证一个换行)。
|
|
149
|
+
* 内容一字不改,只是整块换位置;没有顶层键时原样返回。
|
|
150
|
+
* @param {string} text
|
|
151
|
+
* @returns {string}
|
|
152
|
+
*/
|
|
153
|
+
export function orderTopLevel(text) {
|
|
154
|
+
const lines = text.split('\n');
|
|
155
|
+
/** 顶层键所在行 */
|
|
156
|
+
const keyLines = [];
|
|
157
|
+
for (let i = 0; i < lines.length; i++) {
|
|
158
|
+
if (indentOf(lines[i]) === 0 && keyOf(lines[i])) keyLines.push(i);
|
|
159
|
+
}
|
|
160
|
+
if (!keyLines.length) return text;
|
|
161
|
+
|
|
162
|
+
// 第一个键上面的注释是文件头,不跟着键走;其余键上方紧贴的注释跟着键
|
|
163
|
+
const starts = keyLines.map((i, n) => (n === 0 ? i : blockStart(lines, i)));
|
|
164
|
+
/** @type {{ start: number, end: number, key: string }[]} 每块:起点(含自己的注释)到下一块起点(去掉尾随空行) */
|
|
165
|
+
const blocks = keyLines.map((i, n) => {
|
|
166
|
+
const rawEnd = n + 1 < starts.length ? starts[n + 1] : lines.length;
|
|
167
|
+
return { start: starts[n], end: blockEnd(lines, starts[n], rawEnd), key: keyOf(lines[i]) };
|
|
168
|
+
});
|
|
169
|
+
const prologue = lines.slice(0, blocks[0].start);
|
|
170
|
+
// 文件头与第一个键之间也不留空行
|
|
171
|
+
while (prologue.length && isBlank(prologue[prologue.length - 1])) prologue.pop();
|
|
172
|
+
|
|
173
|
+
const ordered = blocks
|
|
174
|
+
.map((b, i) => ({ ...b, order: i, rank: rankOf(b.key) }))
|
|
175
|
+
.sort((a, b) => a.rank - b.rank || a.order - b.order);
|
|
176
|
+
|
|
177
|
+
const out = [...prologue];
|
|
178
|
+
for (const b of ordered) out.push(...lines.slice(b.start, b.end)); // 键挨着键,不插空行
|
|
179
|
+
return `${out.join('\n').replace(/\n+$/, '')}\n`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 排序权重:认识的键按表里的位置,不认识的统一排在「规则」组前面 */
|
|
183
|
+
function rankOf(key) {
|
|
184
|
+
const i = TOP_LEVEL_ORDER.indexOf(key);
|
|
185
|
+
return i < 0 ? UNKNOWN_RANK - 0.5 : i;
|
|
186
|
+
}
|