Files
mihomo-patchbay/server/kernel.ts
T
im0o ac91b96ef8 写出配置落盘的保护链路
顺序是:检查基准是否过时 → 自己校验 → 临时文件上跑 mihomo -t → 备份 → 写入 → 重载,
最后两步任一失败就从备份还原并让内核载回原样。

自己校验那一步不能省:实测 v1.19.29,入站的 proxy 指向不存在的出站时 mihomo -t 会通过、
重载会成功,直到有流量走到那个入站才在日志里报错。这类错误只有 Patchbay 查得出来。

路径在读环境变量时就转成绝对路径,因为内核的 PUT /configs 拒绝相对路径。
2026-08-10 02:56:04 +08:00

102 lines
3.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)
}
}
/** 带上认证头访问管理接口。 */
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 }
}
}
/**
* 让内核重新加载配置文件,不重启进程。
*
* `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)}` }
}
}
/** 透传一个管理接口的 GET 请求,用于运行时数据(线路、连接、日志等)。 */
export async function proxyGet(env: Env, path: string): Promise<Response> {
return callApi(env, path, { signal: AbortSignal.timeout(15_000) })
}