Files
mihomo-patchbay/server/kernel.ts
T
im0o afe53b9790 后端补上流式透传和两个写操作,给后面几个页面铺路
原以为流量和日志要写 WebSocket 转发,实测不用:内核在没有 WebSocket 升级时把
traffic、memory、logs 退化成分块的 HTTP 流,一行一个 JSON(server.go:380-383)。
现有的透传本来就能流式传,唯一的问题是那个 15 秒超时会每隔一会儿掐断一次,
给这三个路径去掉超时即可。

写操作照旧是白名单,逐条列出:断开连接(DELETE /connections 和 /connections/{id})、
刷新订阅(PUT /providers/proxies/{名字})。两者都只改内核运行状态、不碰配置文件,
所以不走落盘那条保护链路。方法和路径都要匹配才放行,测试里有反例。
2026-08-10 19:45:31 +08:00

141 lines
5.0 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)}` }
}
}
/**
* 切换某个策略组当前选中哪个成员。
*
* 这是运行时状态,不写配置文件——所以不走 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)}` }
}
}
/**
* 透传一个管理接口的 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) })
}