@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,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 面板自有内核(面板「内核」页装的那个)的进程生命周期:
|
|
3
|
+
* detached 拉起 / 探活 / 停止 / 日志。
|
|
4
|
+
*
|
|
5
|
+
* 默认只服务状态目录下的内核,启动时读的是同一份 config.yaml —— 所以
|
|
6
|
+
* external-controller 端口已经有东西在响应时会直接失败,这里提前拦一道并给提示。
|
|
7
|
+
* 所有函数都返回结果、不自行退出,退出码由调用方(面板端点 / runner 的 `kernel` job)决定。
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { bareVersion, readKernelVersion } from '../kernel/binary.js';
|
|
13
|
+
import { getController, normalizeUrl, readControllerConfig } from '../kernel/client.js';
|
|
14
|
+
import {
|
|
15
|
+
KERNEL_BIN,
|
|
16
|
+
KERNEL_LOG_FILE,
|
|
17
|
+
KERNEL_META_FILE,
|
|
18
|
+
KERNEL_PID_FILE,
|
|
19
|
+
detectMihomoDir,
|
|
20
|
+
ensureCliDir,
|
|
21
|
+
} from '../shared/paths.js';
|
|
22
|
+
import { readTailLines, rotateLogFile } from '../shared/log-file.js';
|
|
23
|
+
|
|
24
|
+
/** pid 活着没(不发信号判断,避免误杀) */
|
|
25
|
+
export function isAlive(pid) {
|
|
26
|
+
const n = Number(pid);
|
|
27
|
+
if (!Number.isInteger(n) || n <= 0) return false;
|
|
28
|
+
try {
|
|
29
|
+
process.kill(n, 0);
|
|
30
|
+
return true;
|
|
31
|
+
} catch (err) {
|
|
32
|
+
return err?.code === 'EPERM';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 把内核日志尾部写给调用方(CLI 的 tail 场景),与 daemon 无关 */
|
|
37
|
+
export function tailLogFile({ file, tail = 200, write = (t) => process.stdout.write(t) } = {}) {
|
|
38
|
+
write(readTailLines(file, { lines: tail }).join('\n'));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
42
|
+
/** 启动等待上限:内核要读配置、拉订阅、起监听,给宽一点 */
|
|
43
|
+
const READY_TIMEOUT_MS = 15000;
|
|
44
|
+
const READY_INTERVAL_MS = 300;
|
|
45
|
+
/** 测试用:把这些间隔调小,行为不变(不设则用上面的默认值) */
|
|
46
|
+
const envMs = (name, fallback) => {
|
|
47
|
+
const value = Number(process.env[name]);
|
|
48
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
49
|
+
};
|
|
50
|
+
const readyIntervalMs = () => envMs('MIHOMO_DASH_READY_INTERVAL_MS', READY_INTERVAL_MS);
|
|
51
|
+
const stopIntervalMs = () => envMs('MIHOMO_DASH_STOP_INTERVAL_MS', 200);
|
|
52
|
+
/** 停止时留给内核优雅退出的时间(它要把 selected 写进 cache.db) */
|
|
53
|
+
const STOP_TIMEOUT_MS = 5000;
|
|
54
|
+
/** 启动失败时回显的日志尾部行数 */
|
|
55
|
+
const FAIL_TAIL = 20;
|
|
56
|
+
|
|
57
|
+
export function readKernelPid() {
|
|
58
|
+
try {
|
|
59
|
+
return Number(fs.readFileSync(KERNEL_PID_FILE, 'utf8').trim()) || 0;
|
|
60
|
+
} catch {
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function readKernelMeta() {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(fs.readFileSync(KERNEL_META_FILE, 'utf8'));
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 面板自有内核是否在跑(pid 文件 + 进程存活) */
|
|
74
|
+
export function ownKernelRunning() {
|
|
75
|
+
const pid = readKernelPid();
|
|
76
|
+
const alive = isAlive(pid);
|
|
77
|
+
return { running: alive, pid: alive ? pid : 0, meta: readKernelMeta() };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function cleanupRunFiles() {
|
|
81
|
+
for (const f of [KERNEL_PID_FILE, KERNEL_META_FILE]) {
|
|
82
|
+
try {
|
|
83
|
+
fs.unlinkSync(f);
|
|
84
|
+
} catch {
|
|
85
|
+
/* 已删则忽略 */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 探控制接口:拿到 /version 说明有内核在监听(可能是别人的)。
|
|
92
|
+
* @returns {Promise<{ url: string, version: string }|null>}
|
|
93
|
+
*/
|
|
94
|
+
export async function probeKernel(timeoutMs = 1200) {
|
|
95
|
+
const { url, secret } = getController();
|
|
96
|
+
try {
|
|
97
|
+
const res = await fetch(`${url}/version`, {
|
|
98
|
+
headers: secret ? { authorization: `Bearer ${secret}` } : {},
|
|
99
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
100
|
+
});
|
|
101
|
+
if (!res.ok) return null;
|
|
102
|
+
const data = await res.json();
|
|
103
|
+
// 内核回的就是 `v1.19.32`,统一成裸版本号,让调用方自己拼 v(否则会打印出 vv1.19.32)
|
|
104
|
+
return { url, version: `${bareVersion(data.version)}${data.meta ? ' (Meta)' : ''}` };
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 启动前的校验:配置目录 → 配置里的控制接口 → 二进制 → 是否已在跑 → 控制接口是否被占。
|
|
112
|
+
* 抽出来是为了让「后台启动」与「受服务管理器监管的前台启动」走同一套前置检查。
|
|
113
|
+
*
|
|
114
|
+
* 关于前两项为何卡这么死(2026-10 实测踩过):内核只在配置文件里认 `external-controller`,
|
|
115
|
+
* 配置里没这个键(或目录里压根没有 config.yaml,内核会自己生成一份只开 mixed-port 的初始配置)时,
|
|
116
|
+
* 它根本不会开控制接口 —— 拉起来也只会得到一个面板永远连不上、还占着 mixed-port 的裸内核。
|
|
117
|
+
* @param {{ bin?: string, dir?: string }} opts
|
|
118
|
+
* @returns {Promise<{ ok: boolean, dir?: string, bin?: string, api?: string, panelUrl?: string, configFile?: string, reason?: string }>}
|
|
119
|
+
*/
|
|
120
|
+
async function checkBeforeStart(opts = {}) {
|
|
121
|
+
const dir = opts.dir || detectMihomoDir();
|
|
122
|
+
const configFile = path.join(dir, 'config.yaml');
|
|
123
|
+
if (!fs.existsSync(configFile)) {
|
|
124
|
+
console.error(
|
|
125
|
+
`${dir} 里没有 config.yaml:内核会自己生成一份只开 mixed-port 的初始配置(没有控制接口),`,
|
|
126
|
+
'面板永远连不上它',
|
|
127
|
+
);
|
|
128
|
+
return { ok: false, reason: 'no-config-in-dir', dir, configFile };
|
|
129
|
+
}
|
|
130
|
+
// 读的是文件当下内容,不是 getController() 那份缓存(这里要判断二者是否已经对不上)
|
|
131
|
+
const { external } = readControllerConfig(dir);
|
|
132
|
+
if (!external) {
|
|
133
|
+
console.error(
|
|
134
|
+
`${configFile} 里没有 external-controller:内核不会开控制接口,面板连不上它。`,
|
|
135
|
+
'\n加一行 external-controller: 127.0.0.1:8190(面板「配置文件」页),再重启内核',
|
|
136
|
+
);
|
|
137
|
+
return { ok: false, reason: 'no-controller', dir, configFile };
|
|
138
|
+
}
|
|
139
|
+
const api = normalizeUrl(external);
|
|
140
|
+
const panelUrl = getController().url;
|
|
141
|
+
if (api !== panelUrl) {
|
|
142
|
+
console.error(
|
|
143
|
+
`配置里的控制接口是 ${api},面板记的是 ${panelUrl}(启动面板时读的):`,
|
|
144
|
+
'external-controller 改完要重启面板才生效',
|
|
145
|
+
);
|
|
146
|
+
return { ok: false, reason: 'controller-changed', dir, configFile, api, panelUrl };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const bin = opts.bin || KERNEL_BIN;
|
|
150
|
+
if (!fs.existsSync(bin)) {
|
|
151
|
+
console.error(
|
|
152
|
+
`没有可用的内核二进制:${bin}\n先在面板「内核」页安装(面板只跑自己装的那个内核)`,
|
|
153
|
+
);
|
|
154
|
+
return { ok: false, reason: 'no-binary' };
|
|
155
|
+
}
|
|
156
|
+
const own = ownKernelRunning();
|
|
157
|
+
if (own.running) {
|
|
158
|
+
console.error(`自有内核已在运行(pid ${own.pid}),要重启用侧栏底部的「重启内核」`);
|
|
159
|
+
return { ok: false, reason: 'already-running' };
|
|
160
|
+
}
|
|
161
|
+
cleanupRunFiles();
|
|
162
|
+
|
|
163
|
+
const occupied = await probeKernel(800);
|
|
164
|
+
if (occupied) {
|
|
165
|
+
console.error(
|
|
166
|
+
`控制接口 ${occupied.url} 已被占用(有 v${occupied.version} 在响应):两个实例会抢同一个端口。\n` +
|
|
167
|
+
'先停掉占着它的那个进程(可能是上次没退干净的实例,或你自己另外启的 mihomo),或改 config.yaml 的 external-controller',
|
|
168
|
+
);
|
|
169
|
+
return { ok: false, reason: 'port-occupied' };
|
|
170
|
+
}
|
|
171
|
+
return { ok: true, dir, bin };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 把 pid / 运行时元数据落盘(kernel stop / status 靠它们);`supervised` = 由系统服务管理器拉起(runner 的 `kernel` job) */
|
|
175
|
+
function writeRunFiles(pid, { bin, dir, supervised }) {
|
|
176
|
+
fs.writeFileSync(KERNEL_PID_FILE, String(pid));
|
|
177
|
+
fs.writeFileSync(
|
|
178
|
+
KERNEL_META_FILE,
|
|
179
|
+
JSON.stringify(
|
|
180
|
+
{
|
|
181
|
+
pid,
|
|
182
|
+
bin,
|
|
183
|
+
dir,
|
|
184
|
+
api: getController().url,
|
|
185
|
+
supervised: !!supervised,
|
|
186
|
+
startedAt: Date.now(),
|
|
187
|
+
},
|
|
188
|
+
null,
|
|
189
|
+
2,
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 拉起自有内核:detached 子进程,pid / 元数据 / 日志都落状态目录,然后等控制接口就绪。
|
|
196
|
+
*
|
|
197
|
+
* `foreground: true` 时(runner 的 `kernel` job 用,见 `bin/mihomo-dash-runner.js`)不 detach、
|
|
198
|
+
* 不 unref,并且等内核退出后把退出码回传 —— 这样服务管理器那一层才有真实的常驻进程可看。
|
|
199
|
+
* @param {{ bin?: string, dir?: string, foreground?: boolean }} [opts]
|
|
200
|
+
* @returns {Promise<{ ok: boolean, pid?: number, exitCode?: number, reason?: string }>}
|
|
201
|
+
*/
|
|
202
|
+
export async function kernelStart(opts = {}) {
|
|
203
|
+
const pre = await checkBeforeStart(opts);
|
|
204
|
+
if (!pre.ok) return pre;
|
|
205
|
+
const { dir, bin } = pre;
|
|
206
|
+
|
|
207
|
+
ensureCliDir();
|
|
208
|
+
// 内核日志只追加、无大小上限(实测能到几十 MB):启动前轮转一份,避免无限长大。
|
|
209
|
+
// 前台(服务管理器监管)模式不轮转:那时 stdout 的 fd 是 launchd / systemd 自己开的,
|
|
210
|
+
// 在我们手里 rename 会把它们的写入甩到 `.1` 上。
|
|
211
|
+
if (!opts.foreground) rotateLogFile(KERNEL_LOG_FILE);
|
|
212
|
+
const logFd = fs.openSync(KERNEL_LOG_FILE, 'a');
|
|
213
|
+
const child = spawn(bin, ['-d', dir], {
|
|
214
|
+
detached: !opts.foreground,
|
|
215
|
+
stdio: ['ignore', logFd, logFd],
|
|
216
|
+
});
|
|
217
|
+
if (!opts.foreground) child.unref();
|
|
218
|
+
fs.closeSync(logFd);
|
|
219
|
+
writeRunFiles(child.pid, { bin, dir, supervised: !!opts.foreground });
|
|
220
|
+
|
|
221
|
+
const interval = readyIntervalMs();
|
|
222
|
+
for (let waited = 0; waited < READY_TIMEOUT_MS; waited += interval) {
|
|
223
|
+
if (!isAlive(child.pid)) break;
|
|
224
|
+
const ready = await probeKernel(600);
|
|
225
|
+
if (ready) {
|
|
226
|
+
if (opts.foreground) return waitChildExit(child);
|
|
227
|
+
console.log(`内核已启动: ${ready.url}(pid ${child.pid},v${ready.version})`);
|
|
228
|
+
return { ok: true, pid: child.pid };
|
|
229
|
+
}
|
|
230
|
+
await sleep(interval);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (isAlive(child.pid)) {
|
|
234
|
+
try {
|
|
235
|
+
process.kill(child.pid, 'SIGKILL');
|
|
236
|
+
} catch {
|
|
237
|
+
/* 已退出 */
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
cleanupRunFiles();
|
|
241
|
+
console.error('内核启动失败:控制接口没起来。日志尾部:');
|
|
242
|
+
tailLogFile({ file: KERNEL_LOG_FILE, tail: FAIL_TAIL, write: (t) => process.stderr.write(t) });
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
pid: child.pid,
|
|
246
|
+
reason: 'not-ready',
|
|
247
|
+
dir,
|
|
248
|
+
api: getController().url,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 前台模式下等内核退出:正常退出或被停掉都算 0(不保活,所以退出码只用于日志好看),
|
|
254
|
+
* 只有内核自己以非 0 退出(配置坏等)才把退出码透传给服务管理器。
|
|
255
|
+
* @param {import('node:child_process').ChildProcess} child
|
|
256
|
+
*/
|
|
257
|
+
function waitChildExit(child) {
|
|
258
|
+
return new Promise((resolve) => {
|
|
259
|
+
child.on('exit', (code, signal) => {
|
|
260
|
+
cleanupRunFiles();
|
|
261
|
+
const how = signal ? `被 ${signal} 结束` : `退出码 ${code}`;
|
|
262
|
+
console.log(`内核已退出(pid ${child.pid},${how})`);
|
|
263
|
+
resolve({ ok: true, pid: child.pid, exitCode: signal ? 0 : code || 0 });
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 停止自有内核(只杀 pid 文件里那一个,绝不碰这个进程之外的任何东西)。
|
|
270
|
+
* @param {{ quiet?: boolean }} [opts]
|
|
271
|
+
* @returns {Promise<boolean>} 真的停掉了一个进程返回 true
|
|
272
|
+
*/
|
|
273
|
+
export async function kernelStop(opts = {}) {
|
|
274
|
+
const pid = readKernelPid();
|
|
275
|
+
if (!isAlive(pid)) {
|
|
276
|
+
cleanupRunFiles();
|
|
277
|
+
if (!opts.quiet) console.log('自有内核未在运行');
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
process.kill(pid, 'SIGTERM');
|
|
282
|
+
} catch {
|
|
283
|
+
/* 刚好自己退了 */
|
|
284
|
+
}
|
|
285
|
+
const step = stopIntervalMs();
|
|
286
|
+
for (let waited = 0; waited < STOP_TIMEOUT_MS && isAlive(pid); waited += step) {
|
|
287
|
+
await sleep(step);
|
|
288
|
+
}
|
|
289
|
+
if (isAlive(pid)) {
|
|
290
|
+
try {
|
|
291
|
+
process.kill(pid, 'SIGKILL');
|
|
292
|
+
} catch {
|
|
293
|
+
/* 同上 */
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
cleanupRunFiles();
|
|
297
|
+
if (!opts.quiet) console.log(`内核已停止(pid ${pid})`);
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** 重启自有内核:优先沿用 start 时记下的二进制与配置目录(那才是它真正在跑的),没记过才用传入值 */
|
|
302
|
+
export async function kernelRestart(opts = {}) {
|
|
303
|
+
const meta = readKernelMeta();
|
|
304
|
+
await kernelStop({ quiet: true });
|
|
305
|
+
return kernelStart({ bin: meta?.bin || opts.bin, dir: meta?.dir || opts.dir });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 运行状态:自有内核进程 + 控制接口(后者可能是别的进程,会明说)。
|
|
310
|
+
* @returns {Promise<{ running: boolean, pid: number, api: string|null, version: string|null }>}
|
|
311
|
+
*/
|
|
312
|
+
export async function kernelStatus() {
|
|
313
|
+
const { running, pid, meta } = ownKernelRunning();
|
|
314
|
+
const probe = await probeKernel();
|
|
315
|
+
const { url: apiUrl } = getController();
|
|
316
|
+
|
|
317
|
+
if (running) {
|
|
318
|
+
const binVersion = meta?.bin ? await readKernelVersion(meta.bin) : null;
|
|
319
|
+
console.log(`自有内核: 运行中(pid ${pid})`);
|
|
320
|
+
console.log(`二进制: ${meta?.bin || KERNEL_BIN}${binVersion ? `(v${binVersion})` : ''}`);
|
|
321
|
+
} else {
|
|
322
|
+
console.log('自有内核: 未运行(用侧栏底部的「启动内核」拉起)');
|
|
323
|
+
}
|
|
324
|
+
if (probe) {
|
|
325
|
+
const who = running ? '' : '(这个内核不是面板拉起的)';
|
|
326
|
+
console.log(`控制接口: ${probe.url} → v${probe.version}${who}`);
|
|
327
|
+
} else {
|
|
328
|
+
console.log(`控制接口: ${apiUrl} 无响应`);
|
|
329
|
+
}
|
|
330
|
+
return { running, pid, api: probe?.url || null, version: probe?.version || null };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 看自有内核日志(stdout/stderr 都写在这里)。
|
|
335
|
+
* @param {{ follow?: boolean, tail?: number }} [opts]
|
|
336
|
+
*/
|
|
337
|
+
export function kernelLogs(opts = {}) {
|
|
338
|
+
tailLogFile({ ...opts, file: KERNEL_LOG_FILE });
|
|
339
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 内核的前台入口:系统服务管理器的 `kernel` job 指向它(`[Unit]` 里没有 job 参数,
|
|
4
|
+
* 内核入口就是这一个文件)。**不给人手敲**,面板侧栏底部的「启动内核」走的是
|
|
5
|
+
* `kernelStart()`(后台 detached),这条路径只服务「开机自启」。
|
|
6
|
+
*/
|
|
7
|
+
import { kernelStart } from './lifecycle.js';
|
|
8
|
+
|
|
9
|
+
const res = await kernelStart({ foreground: true });
|
|
10
|
+
if (!res.ok) process.exit(1);
|
|
11
|
+
if (res.exitCode) process.exit(res.exitCode);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** 轻量参数解析:--k=v / --k v / 布尔开关 / -f 这类短别名,子命令固定所以不引第三方。 */
|
|
2
|
+
|
|
3
|
+
const VALUE_FLAGS = new Set(['port', 'host', 'tail']);
|
|
4
|
+
const ALIASES = { f: 'follow', o: 'open', h: 'help', V: 'version' };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string[]} argv 不含 node 与脚本路径的参数
|
|
8
|
+
* @returns {{ positionals: string[], flags: Record<string, string|boolean> }}
|
|
9
|
+
*/
|
|
10
|
+
export function parseArgs(argv) {
|
|
11
|
+
const positionals = [];
|
|
12
|
+
const flags = {};
|
|
13
|
+
for (let i = 0; i < argv.length; i++) {
|
|
14
|
+
const a = argv[i];
|
|
15
|
+
if (a.startsWith('--')) {
|
|
16
|
+
const eq = a.indexOf('=');
|
|
17
|
+
if (eq !== -1) {
|
|
18
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
19
|
+
} else {
|
|
20
|
+
const key = a.slice(2);
|
|
21
|
+
const next = argv[i + 1];
|
|
22
|
+
if (VALUE_FLAGS.has(key) && next !== undefined && !next.startsWith('-')) {
|
|
23
|
+
flags[key] = next;
|
|
24
|
+
i++;
|
|
25
|
+
} else {
|
|
26
|
+
flags[key] = true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
} else if (a.startsWith('-') && a.length > 1) {
|
|
30
|
+
for (const c of a.slice(1)) {
|
|
31
|
+
flags[ALIASES[c] || c] = true;
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
positionals.push(a);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { positionals, flags };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 整数选项解析:非整数、布尔 true(只写了开关没给值)或超出 `min`-`max` 时报错退出。
|
|
42
|
+
* @param {string|boolean|undefined} value 原始值;undefined 表示没给,直接返回 fallback
|
|
43
|
+
* @param {number} fallback
|
|
44
|
+
* @param {{ label?: string, min?: number, max?: number }} [opts]
|
|
45
|
+
*/
|
|
46
|
+
export function parseIntFlag(value, fallback, { label = '数值', min = 1, max = 65535 } = {}) {
|
|
47
|
+
if (value === undefined) return fallback;
|
|
48
|
+
const n = typeof value === 'boolean' ? Number.NaN : Number(value);
|
|
49
|
+
if (!Number.isInteger(n) || n < min || n > max) {
|
|
50
|
+
console.error(`无效${label}: ${value === true ? '(缺值)' : value}`);
|
|
51
|
+
process.exit(2);
|
|
52
|
+
}
|
|
53
|
+
return n;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 端口解析(1-65535 的整数,否则报错退出) */
|
|
57
|
+
export const parsePort = (value, fallback) => parseIntFlag(value, fallback, { label: '端口' });
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 原子写文件:**同目录临时文件 → rename 覆盖**。
|
|
3
|
+
*
|
|
4
|
+
* 配置类文件以前都是 `fs.writeFileSync(p, text)` 直接覆盖主文件:写到一半崩溃 / 断电 / 磁盘满
|
|
5
|
+
* 就会留下一个截断的 config.yaml,而它是用户唯一的配置(内核二进制那条路径早就是 rename 原子的,
|
|
6
|
+
* 这里对齐)。rename 在同一文件系统内是原子的:读者只会看到「旧内容」或「新内容」,不会看到半份。
|
|
7
|
+
*
|
|
8
|
+
* 权限沿用原文件(新建时按 umask 走默认)。临时文件带 `.` 前缀 + pid + 时间戳,写失败会自己清掉;
|
|
9
|
+
* 它和主文件同目录,所以 rename 不会跨设备(`EXDEV`)。
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 原子地把 `text` 写到 `file`。
|
|
16
|
+
* @param {string} file 目标路径(父目录必须已存在)
|
|
17
|
+
* @param {string} text
|
|
18
|
+
* @returns {string} 目标路径
|
|
19
|
+
*/
|
|
20
|
+
export function writeFileAtomic(file, text) {
|
|
21
|
+
const tmp = path.join(
|
|
22
|
+
path.dirname(file),
|
|
23
|
+
`.${path.basename(file)}.tmp-${process.pid}-${Date.now()}`,
|
|
24
|
+
);
|
|
25
|
+
/** 已存在文件的权限原样保留;新文件交给默认 umask */
|
|
26
|
+
let mode = null;
|
|
27
|
+
try {
|
|
28
|
+
mode = fs.statSync(file).mode & 0o777;
|
|
29
|
+
} catch {
|
|
30
|
+
/* 新文件 */
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
fs.writeFileSync(tmp, text);
|
|
34
|
+
if (mode !== null) fs.chmodSync(tmp, mode);
|
|
35
|
+
fs.renameSync(tmp, file);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
fs.rmSync(tmp, { force: true });
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
return file;
|
|
41
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 外部命令执行助手(系统代理 / 开机自启 / 内核版本探测 / 解压共用)。
|
|
3
|
+
*
|
|
4
|
+
* 项目里所有外部命令都是**固定 argv 直接 execFile、不经过 shell**:调用方拼好参数数组,
|
|
5
|
+
* 这里只管执行与超时,绝不接受拼接后的命令串。
|
|
6
|
+
*
|
|
7
|
+
* **异步**(2026-11 从 spawnSync 改成 execFile):面板是单进程 Node 服务,spawnSync 会把整个
|
|
8
|
+
* 事件循环卡住(最长 5s / 条命令)—— `service status` 一次就要串行跑好几条 launchctl /
|
|
9
|
+
* systemctl,期间所有 REST 请求都排队。调用方一律 `await`,返回结构保持不变。
|
|
10
|
+
*/
|
|
11
|
+
import { execFile } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
const RUN_TIMEOUT_MS = 5000;
|
|
14
|
+
/** 单条命令的输出上限(launchctl print 之类的输出能到几十 KB,留足余量) */
|
|
15
|
+
const MAX_OUTPUT = 4 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 默认执行器:固定 argv、不走 shell、带超时。
|
|
19
|
+
* @param {string} cmd
|
|
20
|
+
* @param {string[]} args
|
|
21
|
+
* @param {{ timeout?: number }} [opts]
|
|
22
|
+
* @returns {Promise<{ ok: boolean, status: number, stdout: string, stderr: string }>}
|
|
23
|
+
*/
|
|
24
|
+
export function defaultRun(cmd, args, opts = {}) {
|
|
25
|
+
const timeout = opts.timeout || RUN_TIMEOUT_MS;
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
execFile(
|
|
28
|
+
cmd,
|
|
29
|
+
args,
|
|
30
|
+
{ encoding: 'utf8', timeout, maxBuffer: MAX_OUTPUT },
|
|
31
|
+
(err, stdout, stderr) => {
|
|
32
|
+
// Node 在非 0 退出时会往 stderr 前面拼一行「Command failed: <cmd>」:界面文案里不需要它
|
|
33
|
+
const errText = (stderr || '').replace(/^Command failed: [^\n]*\n?/, '');
|
|
34
|
+
resolve({
|
|
35
|
+
ok: !err,
|
|
36
|
+
// 正常退出 0;子进程自己的非 0 退出码挂在 err.code(数字);启动失败(ENOENT 等)给 -1
|
|
37
|
+
status: err ? (typeof err.code === 'number' ? err.code : -1) : 0,
|
|
38
|
+
stdout: stdout || '',
|
|
39
|
+
stderr: errText || (err ? (err.killed ? `命令超时(${timeout}ms)` : err.message) : ''),
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** 读一次内核 config.yaml(CLI 与面板共用):不存在/解析失败都返回原因,不抛异常。 */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { configPath } from './paths.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @returns {{ path: string|null, config: any, error: string|null }}
|
|
8
|
+
*/
|
|
9
|
+
export function loadKernelConfig() {
|
|
10
|
+
const file = configPath();
|
|
11
|
+
if (!fs.existsSync(file)) {
|
|
12
|
+
return {
|
|
13
|
+
path: file,
|
|
14
|
+
config: null,
|
|
15
|
+
error: `配置目录里没有 config.yaml(${file}):去面板「配置文件」页写好并保存就会创建`,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const config = yaml.load(fs.readFileSync(file, 'utf8')) || {};
|
|
20
|
+
if (typeof config !== 'object' || Array.isArray(config)) {
|
|
21
|
+
return { path: file, config: null, error: '顶层不是映射(YAML 结构不对)' };
|
|
22
|
+
}
|
|
23
|
+
return { path: file, config, error: null };
|
|
24
|
+
} catch (err) {
|
|
25
|
+
return { path: file, config: null, error: err?.message || String(err) };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 日志文件的两个操作:**尾部读取**与**启动前轮转**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个模块:`daemon.log`(面板 job 的 stdout)/ `kernel.log` 是**只追加、没有任何大小上限**的文件
|
|
5
|
+
* (内核 `log-level: info` 跑一段时间就是几十 MB,实测 71.8 MB / 33 万行)。以前两处都用
|
|
6
|
+
* `readFileSync(整个文件)` 再切最后 N 行:
|
|
7
|
+
* - 内核页每 2s 轮询一次 `GET /api/kernel-admin/logs?tail=400`,每次全量读 ≈ 95ms 直接把
|
|
8
|
+
* 面板的事件循环按住;
|
|
9
|
+
* - 以前的 `daemon logs`、现在的内核日志尾读,都一样把整个文件读进内存。
|
|
10
|
+
* 所以这里按块从文件末尾往前读,读到够行数就停(默认最多读 4 MB)。
|
|
11
|
+
*
|
|
12
|
+
* 轮转只在**启动进程之前**做(没有任何进程正持有该文件的写 fd 时最安全),保留一份 `.1`。
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
|
|
16
|
+
/** 单次尾部读取最多往回读多少字节(够几千行了) */
|
|
17
|
+
const MAX_TAIL_BYTES = 4 * 1024 * 1024;
|
|
18
|
+
/** 每次往回读的块大小 */
|
|
19
|
+
const READ_CHUNK = 64 * 1024;
|
|
20
|
+
/** 日志超过这个大小就在下次启动前轮转成 `<file>.1` */
|
|
21
|
+
export const ROTATE_BYTES = 5 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 读文件尾部若干行(不把整个文件读进内存)。
|
|
25
|
+
* @param {string} file
|
|
26
|
+
* @param {{ lines?: number, maxBytes?: number }} [opts]
|
|
27
|
+
* @returns {{ lines: string[], missing: boolean, truncated: boolean }}
|
|
28
|
+
* `truncated` = 读到上限还没覆盖全文(返回的只是尾部)
|
|
29
|
+
*/
|
|
30
|
+
export function readTailLines(file, { lines = 200, maxBytes = MAX_TAIL_BYTES } = {}) {
|
|
31
|
+
const want = Math.max(1, Math.floor(Number(lines) || 1));
|
|
32
|
+
const cap = Math.max(1, Math.floor(Number(maxBytes) || MAX_TAIL_BYTES));
|
|
33
|
+
|
|
34
|
+
let size;
|
|
35
|
+
try {
|
|
36
|
+
size = fs.statSync(file).size;
|
|
37
|
+
} catch {
|
|
38
|
+
return { lines: [], missing: true, truncated: false };
|
|
39
|
+
}
|
|
40
|
+
if (size === 0) return { lines: [], missing: false, truncated: false };
|
|
41
|
+
|
|
42
|
+
/** @type {Buffer[]} */
|
|
43
|
+
const chunks = [];
|
|
44
|
+
let read = 0;
|
|
45
|
+
let pos = size;
|
|
46
|
+
let newlines = 0;
|
|
47
|
+
let fd;
|
|
48
|
+
try {
|
|
49
|
+
fd = fs.openSync(file, 'r');
|
|
50
|
+
while (pos > 0 && read < cap) {
|
|
51
|
+
const len = Math.min(READ_CHUNK, pos, cap - read);
|
|
52
|
+
const buf = Buffer.alloc(len);
|
|
53
|
+
fs.readSync(fd, buf, 0, len, pos - len);
|
|
54
|
+
chunks.unshift(buf);
|
|
55
|
+
read += len;
|
|
56
|
+
pos -= len;
|
|
57
|
+
for (const byte of buf) if (byte === 0x0a) newlines++;
|
|
58
|
+
// 多要一行:拿到第 want+1 个换行才能确定尾部正好有 want 行
|
|
59
|
+
if (newlines > want) break;
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
66
|
+
const all = text.split('\n');
|
|
67
|
+
// 从文件中间开始读时,第一段是被截断的半行,丢掉
|
|
68
|
+
const atStart = pos === 0;
|
|
69
|
+
if (!atStart) all.shift();
|
|
70
|
+
// 文件以换行结尾时 split 会留一个空尾巴
|
|
71
|
+
if (all.length && all[all.length - 1] === '') all.pop();
|
|
72
|
+
return { lines: all.slice(-want), missing: false, truncated: !atStart };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 日志轮转:超过 `maxBytes` 就改名成 `<file>.1`(覆盖旧的那份),返回轮转后的路径。
|
|
77
|
+
* 只在启动进程前调用 —— 那时没有进程持有这个文件的写 fd。
|
|
78
|
+
* @param {string} file
|
|
79
|
+
* @param {number} [maxBytes]
|
|
80
|
+
* @returns {string|null} 没轮转(文件不存在 / 不够大 / 出错)返回 null
|
|
81
|
+
*/
|
|
82
|
+
export function rotateLogFile(file, maxBytes = ROTATE_BYTES) {
|
|
83
|
+
try {
|
|
84
|
+
if (fs.statSync(file).size <= maxBytes) return null;
|
|
85
|
+
const rotated = `${file}.1`;
|
|
86
|
+
fs.rmSync(rotated, { force: true });
|
|
87
|
+
fs.renameSync(file, rotated);
|
|
88
|
+
return rotated;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 面板进程探活:**不再有 daemon.pid / daemon.json**(2026-11 去掉守护进程后)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不看 pid 文件:进程被 kill -9 后文件残留、pid 还会被系统复用,都会骗人;
|
|
5
|
+
* 直接打面板自己的 `/api/health` 是唯一直接证据,顺带能拿到版本与运行时长。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { DEFAULT_HOST, DEFAULT_PORT } from './paths.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 0.0.0.0 / :: 这类监听地址不能直接连,换成回环。
|
|
12
|
+
* @param {string} [host]
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function connectHost(host) {
|
|
16
|
+
if (!host || host === '0.0.0.0' || host === '::' || host === '[::]') return '127.0.0.1';
|
|
17
|
+
return host;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{ host?: string, port?: number }} [target]
|
|
22
|
+
* @returns {{ url: string, host: string, port: number }}
|
|
23
|
+
*/
|
|
24
|
+
export function panelTarget(target = {}) {
|
|
25
|
+
const port = Number(target.port) || DEFAULT_PORT;
|
|
26
|
+
const host = connectHost(target.host || DEFAULT_HOST);
|
|
27
|
+
return { url: `http://${host}:${port}`, host, port };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 探一次面板。连不上 / 超时 / 不是 mihomo-dash 都算「没在跑」,不抛异常。
|
|
32
|
+
* @param {{ host?: string, port?: number, timeoutMs?: number }} [target]
|
|
33
|
+
* @returns {Promise<{ running: boolean, url: string, host: string, port: number,
|
|
34
|
+
* version: string|null, uptime: number|null, pid: number|null }>}
|
|
35
|
+
*/
|
|
36
|
+
export async function probePanel(target = {}) {
|
|
37
|
+
const { url, host, port } = panelTarget(target);
|
|
38
|
+
const dead = { running: false, url, host, port, version: null, uptime: null, pid: null };
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(`${url}/api/health`, {
|
|
41
|
+
signal: AbortSignal.timeout(target.timeoutMs ?? 1500),
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok) return dead;
|
|
44
|
+
const body = await res.json();
|
|
45
|
+
if (body?.name !== 'mihomo-dash') return dead;
|
|
46
|
+
return {
|
|
47
|
+
running: true,
|
|
48
|
+
url,
|
|
49
|
+
host,
|
|
50
|
+
port,
|
|
51
|
+
version: typeof body.version === 'string' ? body.version : null,
|
|
52
|
+
uptime: typeof body.uptime === 'number' ? body.uptime : null,
|
|
53
|
+
pid: typeof body.pid === 'number' ? body.pid : null,
|
|
54
|
+
};
|
|
55
|
+
} catch {
|
|
56
|
+
return dead;
|
|
57
|
+
}
|
|
58
|
+
}
|