Files
mihomo-patchbay/server/kernel.ts
T
im0o 07a43b5a56 仪表盘细节修整,内核卡片搬左列并加重启
- 曲线空态和图共用一个定高容器,数据到来不再挤下面的内容
- 路由行去掉行间分割线:它会吃掉 hover 高亮的圆角
- 每路走势线先合并成 28 桶再画。3 秒一个采样直接全画,在一条
  百来像素宽的线上只剩噪声;没采集的桶画断口,不画假零
- 环形图两段加圆角,交界用底色描边让出间隙
- 内核卡片从右列搬到服务器和路由之间(左列本来就矮一截),加
  「重启内核」:POST /restart 后内核自我 exec、PID 不变,恢复没有
  任何通知,只能每 2 秒探一次,半分钟探不到交还给外壳心跳
2026-08-11 22:42:40 +08:00

182 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 跟 mihomo 内核打交道的两条通道。
*
* 一条是命令行:`mihomo -t` 校验一份配置能不能被内核接受。
* 一条是 HTTP 管理接口:读运行时状态、让内核重新加载配置。
*
* 两条通道的能力边界不一样,而且有一处不对称必须记住:
* `mihomo -t` **不检查入站的 `proxy:` 指向的出站是否存在**(实测 v1.19.29,
* 这类配置校验通过、重载成功,直到有流量走到那个入站才在日志里报错)。
* 所以这一层不是安全网的全部,落盘前还要跑 Patchbay 自己的检查。
*/
import type { Env } from './env.ts'
export interface ValidateResult {
ok: boolean
/** 内核输出的原文,校验失败时用来告诉用户到底哪里不对 */
message: string
}
/**
* 用 `mihomo -t` 校验一份配置文件。
*
* 超时设得比较宽,因为内核首次校验时可能同步下载 GEO 数据库。
*/
export async function validateConfigFile(
env: Env,
configPath: string,
timeoutMs = 300_000,
): Promise<ValidateResult> {
const proc = Bun.spawn([env.mihomoBin, '-t', '-d', env.mihomoHome, '-f', configPath], {
stdout: 'pipe',
stderr: 'pipe',
})
const timer = setTimeout(() => proc.kill(), timeoutMs)
try {
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
const message = [stdout, stderr].filter(Boolean).join('\n').trim()
if (exitCode === 0) return { ok: true, message }
return {
ok: false,
message: message || `mihomo -t 以退出码 ${String(exitCode)} 结束,但没有任何输出`,
}
} finally {
clearTimeout(timer)
}
}
/**
* 启动时先把内核二进制跑一次,什么都不做。
*
* `mihomo -t` 的第一次调用要 4 秒,之后 55 毫秒(在目标机器那种规格上实测差距只会更大)。
* 差的是把这个几十兆的可执行文件读进系统文件缓存的时间,跟配置内容无关。现在只有内核
* 不在线时保存才走 `mihomo -t`,而那正是用户最着急的场景,这 4 秒不该落在他头上。
*
* 失败不用管:内核二进制不可用这件事,第一次真正校验时会带着完整错误信息报出来。
*/
export function warmUpKernelBinary(env: Env): void {
try {
const proc = Bun.spawn([env.mihomoBin, '-v'], { stdout: 'ignore', stderr: 'ignore' })
void proc.exited
} catch {
// 同上
}
}
/** 带上认证头访问管理接口。 */
async function callApi(env: Env, path: string, init?: RequestInit): Promise<Response> {
return fetch(`${env.mihomoApi}${path}`, {
...init,
headers: {
Authorization: `Bearer ${env.mihomoSecret}`,
'Content-Type': 'application/json',
...init?.headers,
},
})
}
/** 内核是否在线,在线时返回版本号。 */
export async function kernelVersion(env: Env): Promise<{ online: boolean; version?: string }> {
try {
const res = await callApi(env, '/version', { signal: AbortSignal.timeout(5_000) })
if (!res.ok) return { online: false }
const body = (await res.json()) as { version?: string }
return { online: true, version: body.version }
} catch {
return { online: false }
}
}
/**
* 让内核重新加载配置文件,不重启进程。
*
* 这一步同时就是校验:内核先完整解析,解析失败什么都不应用并返回 400,响应体是解析
* 错误原文(内核源码 hub/route/configs.go:430-438)。落盘链路在内核在线时靠它把关,
* 不再单独跑 `mihomo -t`。
*
* `force=true` 意味着连端口这类需要重开监听的改动也一并生效——正是本项目改入站时需要的。
* 注意 `path` 是**内核所在机器上的路径**:部署时 Patchbay 和内核在同一台机器上所以没有问题,
* 但开发时如果 Patchbay 在本机、内核在远端,这个调用不会加载到本地的文件。
*/
export async function reloadConfig(env: Env, configPath: string): Promise<ValidateResult> {
try {
const res = await callApi(env, '/configs?force=true', {
method: 'PUT',
body: JSON.stringify({ path: configPath }),
signal: AbortSignal.timeout(30_000),
})
if (res.ok) return { ok: true, message: '' }
return { ok: false, message: (await res.text()) || `管理接口返回 ${String(res.status)}` }
} catch (error) {
return { ok: false, message: `连不上管理接口:${String(error)}` }
}
}
/**
* 切换某个策略组当前选中哪个成员。
*
* 这是运行时状态,不写配置文件——所以不走 config-store 那条保护链路,也不需要备份和还原。
* 内核重启后会不会记住,取决于配置里的 profile.store-selected。
*/
export async function selectProxy(
env: Env,
group: string,
member: string,
): Promise<ValidateResult> {
try {
const res = await callApi(env, `/proxies/${encodeURIComponent(group)}`, {
method: 'PUT',
body: JSON.stringify({ name: member }),
signal: AbortSignal.timeout(10_000),
})
if (res.ok) return { ok: true, message: '' }
return { ok: false, message: (await res.text()) || `管理接口返回 ${String(res.status)}` }
} catch (error) {
return { ok: false, message: `连不上管理接口:${String(error)}` }
}
}
/**
* 让内核重启进程(POST /restart,内核源码 hub/route/restart.go:20-43)。
*
* 内核先回 {"status":"ok"} 再自我 exec。Linux 上用的是 syscall.Exec,PID 不变,
* systemd 托管不受影响;策略组的选中靠 profile.store-selected 恢复。
*/
export async function restartKernel(env: Env): Promise<ValidateResult> {
try {
const res = await callApi(env, '/restart', {
method: 'POST',
signal: AbortSignal.timeout(10_000),
})
if (res.ok) return { ok: true, message: '' }
return { ok: false, message: (await res.text()) || `管理接口返回 ${String(res.status)}` }
} catch (error) {
return { ok: false, message: `连不上管理接口:${String(error)}` }
}
}
/**
* 透传一个管理接口的 GET 请求。
*
* `stream` 为真时不设超时:流量、内存、日志这三个接口不升级 WebSocket 时会退化成
* 持续吐 JSON 的分块流(内核源码 hub/route/server.go:380-383),给它们设超时等于
* 每隔一会儿就把连接掐断一次。
*/
export async function proxyGet(env: Env, path: string, stream = false): Promise<Response> {
return callApi(env, path, stream ? {} : { signal: AbortSignal.timeout(15_000) })
}
/** 透传一个写操作。目前只有断开连接和刷新订阅两处。 */
export async function proxyWrite(
env: Env,
method: 'PUT' | 'DELETE',
path: string,
): Promise<Response> {
return callApi(env, path, { method, signal: AbortSignal.timeout(30_000) })
}