@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,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 内核管理接口(面板「内核」页的后端):状态 / 安装 / 升级 / 卸载 / 启停重启 / 日志。
|
|
3
|
+
*
|
|
4
|
+
* 2026-10 的决定:内核的装/升/卸/启停从 CLI 整体搬进面板(`mihomo-dash kernel` 已删除),
|
|
5
|
+
* 面板因此**不再对内核只读** —— 它会替换可执行文件。这个风险是明确接受的
|
|
6
|
+
* (面板仍只监听 127.0.0.1、无鉴权),要收紧就得先把 token 鉴权做出来。
|
|
7
|
+
*
|
|
8
|
+
* 三条自己给自己加的约束:
|
|
9
|
+
* - **只动面板自有落点** `MIHOMO_DASH_DIR/bin/mihomo`:主机上就当只有面板自己这一个内核,
|
|
10
|
+
* 既不去探测 PATH / brew / 包管理器里有没有别的 mihomo,也不会去覆盖别处的文件。
|
|
11
|
+
* - **不联网**(2026-11 改):安装包必须由用户本机上传(浏览器 raw body 落成临时文件,
|
|
12
|
+
* 再走同一条安装流水线)。面板不去 GitHub 查版本、不去下载二进制,也不缓存 release ——
|
|
13
|
+
* 要新版本就自己去 release 页面下好,然后在页面上传。
|
|
14
|
+
* - 安装是**后台任务**:HTTP 只负责启动与轮询(拷包 / 解压 / 试跑不能让请求一直挂着),
|
|
15
|
+
* 进度 / 消息 / 结果都留在进程内的 job 状态里、**不落盘**(`install.log` 已在 2026-11 去掉:
|
|
16
|
+
* 任务重启就没了,想要持久化日志就用下面的内核日志),前端轮询 `GET /api/kernel-admin/install`。
|
|
17
|
+
* 装完**只把文件就位、不动正在跑的进程**:内核只在启动时加载二进制,所以新版本得用户
|
|
18
|
+
* 去侧栏点「重启内核」才生效(旧版本留一个 `.bak-<版本>` 备份,纯手工兜底,没有自动回滚)。
|
|
19
|
+
*/
|
|
20
|
+
import express from 'express';
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import {
|
|
24
|
+
CLI_DIR,
|
|
25
|
+
KERNEL_BIN,
|
|
26
|
+
KERNEL_CONFIG_DIR,
|
|
27
|
+
KERNEL_LOG_FILE,
|
|
28
|
+
KERNEL_META_FILE,
|
|
29
|
+
KERNEL_PID_FILE,
|
|
30
|
+
detectMihomoDir,
|
|
31
|
+
ensureCliDir,
|
|
32
|
+
resetMihomoDirCache,
|
|
33
|
+
} from '../shared/paths.js';
|
|
34
|
+
import { readKernelVersion } from '../kernel/binary.js';
|
|
35
|
+
import { readTailLines } from '../shared/log-file.js';
|
|
36
|
+
import {
|
|
37
|
+
archiveExt,
|
|
38
|
+
assetTarget,
|
|
39
|
+
assertBinaryArchive,
|
|
40
|
+
sniffArchiveExt,
|
|
41
|
+
} from '../kernel/archive.js';
|
|
42
|
+
import { currentInstall, performInstall, readLedger, removeInstalled } from '../kernel/install.js';
|
|
43
|
+
import { resetControllerCache } from '../kernel/client.js';
|
|
44
|
+
import { DEFAULT_CONFIG, writeDefaultConfig } from './default-config.js';
|
|
45
|
+
|
|
46
|
+
export { DEFAULT_CONFIG, writeDefaultConfig };
|
|
47
|
+
import {
|
|
48
|
+
disableSystemProxyIfOn as defaultDisableSystemProxy,
|
|
49
|
+
readSystemProxyState as defaultReadSystemProxy,
|
|
50
|
+
} from './system-proxy.js';
|
|
51
|
+
import {
|
|
52
|
+
kernelRestart,
|
|
53
|
+
kernelStart,
|
|
54
|
+
kernelStop,
|
|
55
|
+
ownKernelRunning,
|
|
56
|
+
probeKernel,
|
|
57
|
+
} from '../kernel/lifecycle.js';
|
|
58
|
+
|
|
59
|
+
/** 日志一次最多回显多少行 */
|
|
60
|
+
const MAX_LOG_TAIL = 2000;
|
|
61
|
+
/** 上传安装包的体积上限(官方 .gz 约 20 MB,给足余量) */
|
|
62
|
+
const MAX_UPLOAD = '300mb';
|
|
63
|
+
/** 启动失败原因码 → 人话;带动态信息的原因码(no-config-in-dir / no-controller / controller-changed / not-ready)由 startFailureText 拼文案 */
|
|
64
|
+
const START_REASON = {
|
|
65
|
+
'no-binary': '面板自有内核没装:先在这个页面安装',
|
|
66
|
+
'already-running': '自有内核已在运行(要重启用侧栏的「重启内核」)',
|
|
67
|
+
'port-occupied':
|
|
68
|
+
'控制接口已被占用:先停掉占着那个端口的进程,或改 config.yaml 的 external-controller',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 启动 / 重启失败时的文案:带上前置检查里拿到的具体路径、地址,让用户能自己改。
|
|
73
|
+
* 尤其是「配置里没有 external-controller」这类情况:内核照样能跑,只是根本不开控制接口,
|
|
74
|
+
* 面板只会得到一句「控制接口不通」,看不出真正的原因。
|
|
75
|
+
* @param {{ reason?: string, dir?: string, configFile?: string, api?: string, panelUrl?: string }} res
|
|
76
|
+
*/
|
|
77
|
+
function startFailureText(res) {
|
|
78
|
+
switch (res.reason) {
|
|
79
|
+
case 'no-config-in-dir':
|
|
80
|
+
return (
|
|
81
|
+
`${res.dir} 里没有 config.yaml。内核只在配置文件里认 external-controller,` +
|
|
82
|
+
'所以它会自己生成一份只开 mixed-port 的初始配置(不监听控制接口),面板永远连不上它。\n' +
|
|
83
|
+
'把配置放进这个目录,或把这份内核配置目录改成面板探测到的那个(安装内核时面板会写一份最小默认配置进去)'
|
|
84
|
+
);
|
|
85
|
+
case 'no-controller':
|
|
86
|
+
return (
|
|
87
|
+
`${res.configFile} 里没有 external-controller,内核不会开控制接口,面板连不上它。\n` +
|
|
88
|
+
'在「配置文件」页给它加一行 external-controller: 127.0.0.1:8190(再用侧栏的「重启内核」生效)'
|
|
89
|
+
);
|
|
90
|
+
case 'controller-changed':
|
|
91
|
+
return (
|
|
92
|
+
`配置里的控制接口是 ${res.api},面板记的还是 ${res.panelUrl}(启动面板时读的):` +
|
|
93
|
+
'external-controller 改完要重启面板才生效'
|
|
94
|
+
);
|
|
95
|
+
case 'not-ready':
|
|
96
|
+
return `控制接口 ${res.api} 不通(内核配置目录 ${res.dir}),看下方日志`;
|
|
97
|
+
default:
|
|
98
|
+
return START_REASON[res.reason] || `启动失败:${res.reason}`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 安装包文件名白名单(会被拼进临时文件名,所以卡严一点) */
|
|
103
|
+
const ASSET_RE = /^[0-9A-Za-z][0-9A-Za-z._-]*$/;
|
|
104
|
+
|
|
105
|
+
function formatSize(bytes) {
|
|
106
|
+
if (!bytes) return '';
|
|
107
|
+
return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 安装的前置校验:上传的临时文件必须存在、是普通文件、且**按内容**确实是 .gz / .zip 压缩包
|
|
112
|
+
* (浏览器给的文件名可能被改过,内容说了算;.deb / .dmg / 纯可执行文件这类一律拒)。
|
|
113
|
+
* @param {string} file
|
|
114
|
+
* @returns {'gz'|'zip'}
|
|
115
|
+
*/
|
|
116
|
+
function assertLocalArchive(file) {
|
|
117
|
+
let st;
|
|
118
|
+
try {
|
|
119
|
+
st = fs.statSync(file);
|
|
120
|
+
} catch {
|
|
121
|
+
throw new Error(`本地文件不存在:${file}`);
|
|
122
|
+
}
|
|
123
|
+
if (!st.isFile()) throw new Error(`不是一个文件:${file}`);
|
|
124
|
+
if (st.size < 1024) throw new Error(`文件太小(${st.size} 字节),不像是官方 release 的压缩包`);
|
|
125
|
+
const ext = sniffArchiveExt(file);
|
|
126
|
+
if (!ext) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`认不出这个压缩包:${file}\n` +
|
|
129
|
+
'只支持官方 release 的 .gz(darwin / linux)或 .zip(Windows),里面是单个 mihomo 可执行文件',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return ext;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* **首次安装**的收尾:把面板自有内核的配置写成最小默认配置(`data/config.yaml`)。
|
|
137
|
+
* 只在落点原本没有内核二进制时调用 —— **升级不动配置**(见安装任务里那一段),这条路才走。
|
|
138
|
+
*
|
|
139
|
+
* **不管原来那份是什么,一律覆盖**(2026-11 定为这样):内核自己生成的 16 字节初始配置、
|
|
140
|
+
* 手写的裸配置、甚至带着订阅的配置,都换成面板这份最小配置(全直连、`external-controller:
|
|
141
|
+
* 127.0.0.1:8190`),所以**不做备份、不做回滚**;升级内核也会重写它(订阅需要重新加)。
|
|
142
|
+
* 别处的配置目录(brew / 系统包管理)一概不碰。
|
|
143
|
+
* @param {(text: string, level?: string) => void} say
|
|
144
|
+
* @returns {{ dir: string }}
|
|
145
|
+
*/
|
|
146
|
+
function ensureConfig(say) {
|
|
147
|
+
const dir = KERNEL_CONFIG_DIR;
|
|
148
|
+
if (fs.existsSync(path.join(dir, 'config.yaml'))) {
|
|
149
|
+
say('面板配置目录里已有一份 config.yaml:直接覆盖它(不备份)', 'warn');
|
|
150
|
+
}
|
|
151
|
+
say(`已写入最小默认配置:${writeDefaultConfig(dir)}`);
|
|
152
|
+
say('这份配置全是直连(没有节点):要节点去「节点」页添加订阅', 'warn');
|
|
153
|
+
|
|
154
|
+
// 内核实际会用哪个目录?写盘后重新探测一次(常量 KERNEL_CONFIG_DIR 通常就是它,但 MIHOMO_DIR 优先级更高)
|
|
155
|
+
const used = detectMihomoDir();
|
|
156
|
+
if (used && path.resolve(used) !== path.resolve(dir)) {
|
|
157
|
+
say(
|
|
158
|
+
`注意:内核实际会用 ${used} 那份配置(环境变量 MIHOMO_DIR 优先于状态目录的 data/),` +
|
|
159
|
+
'要在面板里改配置得把它清掉或改成 data/',
|
|
160
|
+
'warn',
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return { dir };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 面板自有内核的静态信息(不联网、会跑一次 `-v`) */
|
|
167
|
+
async function describeOwn() {
|
|
168
|
+
const entry = currentInstall();
|
|
169
|
+
const exists = fs.existsSync(KERNEL_BIN);
|
|
170
|
+
return {
|
|
171
|
+
path: KERNEL_BIN,
|
|
172
|
+
installed: exists,
|
|
173
|
+
version: exists ? entry?.version || (await readKernelVersion(KERNEL_BIN)) : null,
|
|
174
|
+
entry: entry || null,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 内核管理(一个实例持有自己的安装任务状态,测试可以各起一份)。
|
|
180
|
+
*/
|
|
181
|
+
export function createKernelAdmin(opts = {}) {
|
|
182
|
+
/** 停内核前「如果系统代理开着就先关掉」;测试注入桩,避免碰真实本机设置 */
|
|
183
|
+
const disableSystemProxy = opts.disableSystemProxy || defaultDisableSystemProxy;
|
|
184
|
+
/** 只读探测系统代理(卸载的前置检查用);测试同样注入桩 */
|
|
185
|
+
const readSystemProxy = opts.readSystemProxy || defaultReadSystemProxy;
|
|
186
|
+
/** 内存里的安装任务(单例:同时只跑一个) */
|
|
187
|
+
let installJob = null;
|
|
188
|
+
|
|
189
|
+
/** 内核页首屏数据:自有内核 + 台账 + 运行状态 */
|
|
190
|
+
async function overview() {
|
|
191
|
+
const running = ownKernelRunning();
|
|
192
|
+
const probe = await probeKernel();
|
|
193
|
+
return {
|
|
194
|
+
own: await describeOwn(),
|
|
195
|
+
ledger: readLedger().installed,
|
|
196
|
+
running: { running: running.running, pid: running.pid, meta: running.meta, probe },
|
|
197
|
+
configDir: detectMihomoDir(),
|
|
198
|
+
platform: process.platform,
|
|
199
|
+
arch: process.arch,
|
|
200
|
+
// 本机该下哪个资产(纯字符串拼出来的提示,不联网):页面告诉用户去 release 页面拿哪一个
|
|
201
|
+
assetTarget: assetTarget(),
|
|
202
|
+
archiveExt: archiveExt(),
|
|
203
|
+
job: jobView(),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** job 快照(含「有没有在跑」与产出的台账条目) */
|
|
208
|
+
function jobView() {
|
|
209
|
+
if (!installJob) {
|
|
210
|
+
return {
|
|
211
|
+
running: false,
|
|
212
|
+
phase: 'idle',
|
|
213
|
+
messages: [],
|
|
214
|
+
progress: { written: 0, total: 0 },
|
|
215
|
+
result: null,
|
|
216
|
+
config: null,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
id: installJob.id,
|
|
221
|
+
running: installJob.running,
|
|
222
|
+
phase: installJob.phase,
|
|
223
|
+
startedAt: installJob.startedAt,
|
|
224
|
+
finishedAt: installJob.finishedAt,
|
|
225
|
+
progress: installJob.progress,
|
|
226
|
+
messages: installJob.messages,
|
|
227
|
+
result: installJob.result,
|
|
228
|
+
config: installJob.config,
|
|
229
|
+
error: installJob.error,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 安装 / 升级:立刻返回,真正的活在后台跑(拷包 / 解压 / 试跑 / 替换),前端轮询 jobView()。
|
|
235
|
+
* 只接受**本机已存在的安装包**:浏览器上传时它是状态目录下的临时文件(`cleanupFile` 为真时
|
|
236
|
+
* 任务结束就删),版本号由安装包自己(`mihomo -v`)决定,不需要外部告诉我们。
|
|
237
|
+
* @param {{ file?: string, name?: string, sha256?: string, cleanupFile?: boolean }} [input]
|
|
238
|
+
*/
|
|
239
|
+
function startInstall(input = {}) {
|
|
240
|
+
if (installJob?.running) throw new Error('已有安装任务在进行中,等它结束');
|
|
241
|
+
const localFile =
|
|
242
|
+
typeof input.file === 'string' && input.file.trim() ? path.resolve(input.file) : null;
|
|
243
|
+
if (!localFile) throw new Error('没有收到安装包:请选择官方 release 的 .gz / .zip 压缩包');
|
|
244
|
+
const name = assertBinaryArchive(
|
|
245
|
+
typeof input.name === 'string' && input.name.trim()
|
|
246
|
+
? input.name.trim()
|
|
247
|
+
: path.basename(localFile),
|
|
248
|
+
);
|
|
249
|
+
if (!ASSET_RE.test(name)) throw new Error(`安装包文件名不合法:${name}`);
|
|
250
|
+
const localExt = assertLocalArchive(localFile);
|
|
251
|
+
const sha256 =
|
|
252
|
+
typeof input.sha256 === 'string' && input.sha256.trim() ? input.sha256.trim() : undefined;
|
|
253
|
+
if (sha256 && !/^[0-9a-fA-F]{64}$/.test(sha256)) throw new Error('sha256 应为 64 位十六进制');
|
|
254
|
+
|
|
255
|
+
installJob = {
|
|
256
|
+
id: `${Date.now()}`,
|
|
257
|
+
running: true,
|
|
258
|
+
phase: 'checking',
|
|
259
|
+
startedAt: Date.now(),
|
|
260
|
+
finishedAt: null,
|
|
261
|
+
progress: { written: 0, total: 0 },
|
|
262
|
+
messages: [],
|
|
263
|
+
result: null,
|
|
264
|
+
config: null,
|
|
265
|
+
error: null,
|
|
266
|
+
};
|
|
267
|
+
const job = installJob;
|
|
268
|
+
/** 一条消息:只留在内存里给页面轮询(不写文件,见文件头「安装是后台任务」) */
|
|
269
|
+
const say = (text, level = 'info') => {
|
|
270
|
+
job.messages.push({ at: Date.now(), level, text });
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
(async () => {
|
|
274
|
+
try {
|
|
275
|
+
// 落点原本有没有内核 = 这次是「升级」还是「首次安装」,后面写不写配置就看它
|
|
276
|
+
const upgrade = fs.existsSync(KERNEL_BIN);
|
|
277
|
+
say(`安装包: ${name}(${formatSize(fs.statSync(localFile).size)})`);
|
|
278
|
+
say(
|
|
279
|
+
`落点: ${KERNEL_BIN}` +
|
|
280
|
+
(upgrade ? `(现有 v${(await readKernelVersion(KERNEL_BIN)) || '未知'},会备份)` : ''),
|
|
281
|
+
);
|
|
282
|
+
say('来源: 本地上传的安装包');
|
|
283
|
+
|
|
284
|
+
job.phase = 'installing';
|
|
285
|
+
const entry = await performInstall({
|
|
286
|
+
localFile,
|
|
287
|
+
ext: localExt,
|
|
288
|
+
asset: name,
|
|
289
|
+
target: KERNEL_BIN,
|
|
290
|
+
sha256,
|
|
291
|
+
onProgress: (written, total) => {
|
|
292
|
+
job.progress = { written, total };
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
say(`已安装: v${entry.version} → ${entry.path}`);
|
|
296
|
+
say(`sha256: ${entry.sha256}`);
|
|
297
|
+
if (entry.backup) say(`备份: ${entry.backup}`);
|
|
298
|
+
job.result = entry;
|
|
299
|
+
// 配置只在**首次安装**时写(写最小默认配置,已有的一律覆盖,不备份);
|
|
300
|
+
// 升级只替换二进制,`data/config.yaml` 一个字都不动(订阅 / 策略组 / 分流都留着)。
|
|
301
|
+
if (upgrade) {
|
|
302
|
+
say('升级:只替换内核二进制,配置(data/config.yaml)未动');
|
|
303
|
+
job.config = { dir: KERNEL_CONFIG_DIR, skipped: true, reason: 'upgrade' };
|
|
304
|
+
} else {
|
|
305
|
+
job.config = ensureConfig(say);
|
|
306
|
+
}
|
|
307
|
+
// 安装**不动正在跑的进程**:内核只在启动时加载二进制,所以新版本要重启进程才生效
|
|
308
|
+
say('文件已就位(正在跑的进程没有动):要生效用侧栏底部的「重启内核」');
|
|
309
|
+
job.phase = 'done';
|
|
310
|
+
} catch (err) {
|
|
311
|
+
job.phase = 'failed';
|
|
312
|
+
job.error = String(err?.message || err);
|
|
313
|
+
say(job.error, 'error');
|
|
314
|
+
} finally {
|
|
315
|
+
job.running = false;
|
|
316
|
+
job.finishedAt = Date.now();
|
|
317
|
+
say(job.error ? '任务结束: 失败' : '任务结束: 成功', job.error ? 'error' : 'info');
|
|
318
|
+
// 浏览器上传来的文件是一次性的:任务结束就删(本机路径那种不动用户自己的文件)
|
|
319
|
+
if (input.cleanupFile && localFile) {
|
|
320
|
+
try {
|
|
321
|
+
fs.rmSync(localFile, { force: true });
|
|
322
|
+
} catch {
|
|
323
|
+
/* 删不掉就留着 */
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
})();
|
|
328
|
+
|
|
329
|
+
return jobView();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 卸载面板自有内核:删二进制(含备份)、台账、runtime 文件与安装日志,最后把面板自有配置目录
|
|
334
|
+
* `MIHOMO_DASH_DIR/data/` 整个删掉(config.yaml / providers / cache.db / GEO 数据都在里面,**不备份**)。
|
|
335
|
+
* 只动面板自己的东西,别处的配置目录一概不碰。
|
|
336
|
+
*
|
|
337
|
+
* **两道前置检查(2026-11 按用户要求)**:内核进程必须已经停了、系统代理必须已经关了,否则直接 400 拒绝 ——
|
|
338
|
+
* 卸载不再替用户去杀进程 / 关系统代理(那两件事各有入口:侧栏「停止内核」、顶栏系统代理开关)。
|
|
339
|
+
*/
|
|
340
|
+
async function uninstall() {
|
|
341
|
+
if (installJob?.running) throw new Error('安装任务进行中,等它结束再卸载');
|
|
342
|
+
|
|
343
|
+
// 前置检查(2026-11 按用户要求:不满足就直接拒绝,卸载不再替用户收尾)——
|
|
344
|
+
// ① 内核进程必须已经停了:让面板去杀一个用户以为自己在管的进程太粗暴,而且他可能只是想把二进制删了
|
|
345
|
+
const running = ownKernelRunning();
|
|
346
|
+
if (running.running) {
|
|
347
|
+
throw new Error(`内核还在运行(pid ${running.pid}):先点侧栏底部的「停止内核」,再来卸载`);
|
|
348
|
+
}
|
|
349
|
+
// ② 系统代理必须已经关了:否则卸载后系统还把流量交给没人监听的端口,表现就是「突然上不了网」
|
|
350
|
+
const proxy = await readSystemProxy();
|
|
351
|
+
if (proxy.enabled) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
'系统代理还开着:先在顶栏把它关掉(系统直连),再来卸载 —— 否则卸载后系统会把流量交给没人监听的端口',
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const removed = removeInstalled(KERNEL_BIN);
|
|
358
|
+
// runtime 文件 + 旧版遗留:release-cache.json(2026-11 前「联网查版本」的缓存)、
|
|
359
|
+
// install.log(2026-11 前的安装过程日志,现在只在内存里,已没有这个文件)
|
|
360
|
+
for (const f of [
|
|
361
|
+
KERNEL_PID_FILE,
|
|
362
|
+
KERNEL_META_FILE,
|
|
363
|
+
KERNEL_LOG_FILE,
|
|
364
|
+
path.join(CLI_DIR, 'release-cache.json'),
|
|
365
|
+
path.join(CLI_DIR, 'install.log'),
|
|
366
|
+
]) {
|
|
367
|
+
fs.rmSync(f, { force: true });
|
|
368
|
+
}
|
|
369
|
+
// 内存里那份安装日志(job 快照)也要清:卸载完不该还留着上一次安装任务的过程与结果
|
|
370
|
+
installJob = null;
|
|
371
|
+
|
|
372
|
+
// 面板自有配置目录(data/)整个删掉:它只属于面板自己装的这个内核
|
|
373
|
+
let removedConfigDir = null;
|
|
374
|
+
if (fs.existsSync(KERNEL_CONFIG_DIR)) {
|
|
375
|
+
fs.rmSync(KERNEL_CONFIG_DIR, { recursive: true, force: true });
|
|
376
|
+
removedConfigDir = KERNEL_CONFIG_DIR;
|
|
377
|
+
// 配置目录没了,两个缓存都得失效:不然 overview/探活 还会去找那份已经不存在的配置
|
|
378
|
+
resetMihomoDirCache();
|
|
379
|
+
resetControllerCache();
|
|
380
|
+
}
|
|
381
|
+
return { removed, removedConfigDir, ...(await overview()) };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** 拉起自有内核进程 */
|
|
385
|
+
async function start() {
|
|
386
|
+
const res = await kernelStart({ bin: KERNEL_BIN });
|
|
387
|
+
if (!res.ok) throw new Error(startFailureText(res));
|
|
388
|
+
return { pid: res.pid, ...(await overview()) };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** 停掉自有内核进程(只杀 pid 文件里那一个) */
|
|
392
|
+
async function stop() {
|
|
393
|
+
// 先检查系统代理:内核一停,系统代理指向的 mixed-port 就没人监听了,用户会以为「断网了」。
|
|
394
|
+
// 系统代理开着的话先关掉(= 系统直连),再去停进程。
|
|
395
|
+
const systemProxy = await disableSystemProxy();
|
|
396
|
+
const stopped = await kernelStop({ quiet: true });
|
|
397
|
+
return { stopped, systemProxy, ...(await overview()) };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** 重启自有内核进程(沿用 start 时记下的二进制与配置目录) */
|
|
401
|
+
async function restart() {
|
|
402
|
+
const res = await kernelRestart({ bin: KERNEL_BIN });
|
|
403
|
+
if (!res.ok) throw new Error(startFailureText(res));
|
|
404
|
+
return { pid: res.pid, ...(await overview()) };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* 自有内核日志尾部(stdout/stderr 都写在这个文件里)。
|
|
409
|
+
* 只从文件末尾按块往前读(`shared/log-file.js`):内核日志没有大小上限,全量读进来会给
|
|
410
|
+
* 内核页那个 2s 轮询变成「每次都把几十 MB 读一遍」。
|
|
411
|
+
*/
|
|
412
|
+
function readLog(tail = 200) {
|
|
413
|
+
const n = Math.min(Math.max(Number(tail) || 200, 1), MAX_LOG_TAIL);
|
|
414
|
+
const { lines, missing } = readTailLines(KERNEL_LOG_FILE, { lines: n });
|
|
415
|
+
if (missing) return { file: KERNEL_LOG_FILE, lines: [], missing: true };
|
|
416
|
+
return { file: KERNEL_LOG_FILE, lines: lines.filter((l) => l !== '') };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 挂上 `/api/kernel-admin*` 一组端点。
|
|
421
|
+
* @param {import('express').Express} app
|
|
422
|
+
*/
|
|
423
|
+
function registerRoutes(app) {
|
|
424
|
+
const fail = (res, err, code = 400) =>
|
|
425
|
+
res.status(code).json({ error: String(err?.message || err) });
|
|
426
|
+
|
|
427
|
+
app.get('/api/kernel-admin', async (_req, res) => {
|
|
428
|
+
try {
|
|
429
|
+
res.json(await overview());
|
|
430
|
+
} catch (err) {
|
|
431
|
+
fail(res, err, 500);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// 安装 / 升级:只认**上传的安装包**(raw body 直接落成状态目录下的临时文件,再走安装流水线)。
|
|
436
|
+
// 不用 multipart —— 那要引 multer,而本项目运行时只留 express + js-yaml 两个依赖。
|
|
437
|
+
app.post(
|
|
438
|
+
'/api/kernel-admin/install',
|
|
439
|
+
express.raw({ type: () => true, limit: MAX_UPLOAD }),
|
|
440
|
+
(req, res) => {
|
|
441
|
+
/** @type {string|null} 已经落盘的临时文件:后续任何一步失败都得删掉 */
|
|
442
|
+
let pending = null;
|
|
443
|
+
try {
|
|
444
|
+
const name = String(req.query.name || '');
|
|
445
|
+
assertBinaryArchive(name);
|
|
446
|
+
if (!ASSET_RE.test(name)) throw new Error(`安装包文件名不合法:${name}`);
|
|
447
|
+
const body = req.body;
|
|
448
|
+
if (!Buffer.isBuffer(body) || body.length === 0) throw new Error('没有收到文件内容');
|
|
449
|
+
ensureCliDir();
|
|
450
|
+
pending = path.join(CLI_DIR, `.upload-${process.pid}-${Date.now()}-${name}`);
|
|
451
|
+
fs.writeFileSync(pending, body);
|
|
452
|
+
res.json(
|
|
453
|
+
startInstall({
|
|
454
|
+
file: pending,
|
|
455
|
+
name,
|
|
456
|
+
sha256: typeof req.query.sha256 === 'string' ? req.query.sha256 : undefined,
|
|
457
|
+
cleanupFile: true,
|
|
458
|
+
}),
|
|
459
|
+
);
|
|
460
|
+
} catch (err) {
|
|
461
|
+
// 任务没起来:临时文件留在这里没人会删,当场上手清掉
|
|
462
|
+
if (pending) fs.rmSync(pending, { force: true });
|
|
463
|
+
const msg = String(err?.message || err);
|
|
464
|
+
fail(res, err, /进行中/.test(msg) ? 409 : 400);
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
app.get('/api/kernel-admin/install', (_req, res) => {
|
|
470
|
+
res.json(jobView());
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
app.delete('/api/kernel-admin', async (_req, res) => {
|
|
474
|
+
try {
|
|
475
|
+
res.json(await uninstall());
|
|
476
|
+
} catch (err) {
|
|
477
|
+
fail(res, err, /进行中/.test(String(err?.message)) ? 409 : 400);
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
for (const [action, handler] of [
|
|
482
|
+
['start', start],
|
|
483
|
+
['stop', stop],
|
|
484
|
+
['restart', restart],
|
|
485
|
+
]) {
|
|
486
|
+
app.post(`/api/kernel-admin/${action}`, async (_req, res) => {
|
|
487
|
+
try {
|
|
488
|
+
res.json(await handler());
|
|
489
|
+
} catch (err) {
|
|
490
|
+
fail(res, err);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
app.get('/api/kernel-admin/logs', (req, res) => {
|
|
496
|
+
try {
|
|
497
|
+
res.json(readLog(req.query.tail));
|
|
498
|
+
} catch (err) {
|
|
499
|
+
fail(res, err, 500);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
overview,
|
|
506
|
+
startInstall,
|
|
507
|
+
jobView,
|
|
508
|
+
uninstall,
|
|
509
|
+
start,
|
|
510
|
+
stop,
|
|
511
|
+
restart,
|
|
512
|
+
readLog,
|
|
513
|
+
registerRoutes,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* @param {import('express').Express} app
|
|
519
|
+
*/
|
|
520
|
+
export function registerKernelAdminRoutes(app, opts = {}) {
|
|
521
|
+
createKernelAdmin(opts).registerRoutes(app);
|
|
522
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** 内核控制接口透明转发:/api/kernel/* → {external-controller}/*,自动附 secret。
|
|
2
|
+
* 其中 /traffic /memory /logs 的 GET 是长连接流式接口,做流式透传,其余等完整响应。 */
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
import { getController, kernelFetch, kernelFetchRaw } from '../kernel/client.js';
|
|
5
|
+
|
|
6
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD']);
|
|
7
|
+
// 内核的流式(逐行推送)REST 端点:GET 请求永不结束,必须边收边写
|
|
8
|
+
const STREAM_GET = /^\/(traffic|memory|logs)(\?|$)/;
|
|
9
|
+
|
|
10
|
+
/** Express 中间件:挂载在 /api/kernel 下 */
|
|
11
|
+
export function kernelProxy() {
|
|
12
|
+
return async (req, res) => {
|
|
13
|
+
try {
|
|
14
|
+
const method = req.method;
|
|
15
|
+
if (method === 'GET' && STREAM_GET.test(req.url)) {
|
|
16
|
+
const upstream = await kernelFetchRaw(req.url);
|
|
17
|
+
res.status(upstream.status);
|
|
18
|
+
res.setHeader(
|
|
19
|
+
'content-type',
|
|
20
|
+
upstream.headers.get('content-type') || 'application/x-ndjson',
|
|
21
|
+
);
|
|
22
|
+
Readable.fromWeb(upstream.body).pipe(res);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const data = await kernelFetch(req.url, {
|
|
26
|
+
method,
|
|
27
|
+
body: SAFE_METHODS.has(method) ? undefined : (req.body ?? {}),
|
|
28
|
+
});
|
|
29
|
+
if (data === null) {
|
|
30
|
+
res.status(204).end();
|
|
31
|
+
} else if (typeof data === 'string') {
|
|
32
|
+
res.type('text/plain').send(data);
|
|
33
|
+
} else {
|
|
34
|
+
res.json(data);
|
|
35
|
+
}
|
|
36
|
+
} catch (err) {
|
|
37
|
+
res.status(err?.status || 502).json({
|
|
38
|
+
error: String(err?.message || err),
|
|
39
|
+
api: getController().url,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|