写出配置落盘的保护链路
顺序是:检查基准是否过时 → 自己校验 → 临时文件上跑 mihomo -t → 备份 → 写入 → 重载, 最后两步任一失败就从备份还原并让内核载回原样。 自己校验那一步不能省:实测 v1.19.29,入站的 proxy 指向不存在的出站时 mihomo -t 会通过、 重载会成功,直到有流量走到那个入站才在日志里报错。这类错误只有 Patchbay 查得出来。 路径在读环境变量时就转成绝对路径,因为内核的 PUT /configs 拒绝相对路径。
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 配置文件的读写,以及改动落盘时的那条保护链路。
|
||||
*
|
||||
* 这个文件存在的全部理由是一句话:**配置写坏了要能把用户救回来**。
|
||||
* 一台跑着 mihomo 的机器,配置一旦被写成内核起不来的样子,用户很可能连
|
||||
* SSH 都得绕路才能进去修。所以落盘不是「写文件」这一个动作,而是一串顺序
|
||||
* 严格的动作,每一步都要留好退路。
|
||||
*
|
||||
* 顺序是:
|
||||
* 读当前文件 → 检查是否被外部改过 → 生成新内容 → 自己检查一遍
|
||||
* → 写到临时文件 → mihomo -t 校验 → 备份原文件 → 写入 → 通知内核重载
|
||||
* 最后两步中任何一步出问题,都从备份还原并让内核载回原样。
|
||||
*
|
||||
* 「自己检查一遍」不能省。实测 v1.19.29:入站的 `proxy:` 指向一个不存在的
|
||||
* 出站时,`mihomo -t` 会通过、重载会成功,直到有流量走到那个入站才在日志里
|
||||
* 报错。这类错误只有 Patchbay 自己查得出来。
|
||||
*/
|
||||
import { copyFile, mkdir, readdir, readFile, rm, unlink, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import {
|
||||
openDocument,
|
||||
replaceDocumentData,
|
||||
validateDocument,
|
||||
} from '../vendor/config-editor/index.ts'
|
||||
import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts'
|
||||
import { restoreInlineCommentAlignment } from '../shared/yaml-cosmetics.ts'
|
||||
import { validateConfigFile, reloadConfig } from './kernel.ts'
|
||||
import type { Env } from './env.ts'
|
||||
|
||||
/** 备份保留的份数。再多没有意义——真要回到很久以前的版本,那是版本控制的活。 */
|
||||
const KEEP_BACKUPS = 10
|
||||
|
||||
export interface ConfigSnapshot {
|
||||
/** 解析出来的配置数据 */
|
||||
data: ConfigObject
|
||||
/** 文件原文 */
|
||||
yaml: string
|
||||
/**
|
||||
* 内容指纹。客户端改完提交时要带上它,服务端据此判断这份改动
|
||||
* 是不是基于最新的文件——中途有人 SSH 上去手改过就会对不上。
|
||||
*/
|
||||
revision: string
|
||||
/** 解析和引用检查的结果,含只是提醒的项 */
|
||||
diagnostics: ConfigDiagnostic[]
|
||||
}
|
||||
|
||||
export type ApplyOutcome =
|
||||
| { ok: true; snapshot: ConfigSnapshot; reloaded: boolean; reloadMessage: string }
|
||||
| { ok: false; stage: ApplyFailureStage; message: string; hint: string; restored: boolean }
|
||||
|
||||
/** 失败发生在哪一步。界面据此决定说什么,而不是把原始错误直接甩给用户。 */
|
||||
export type ApplyFailureStage =
|
||||
| 'stale' // 文件被外部改过,这份改动的基准已经过时
|
||||
| 'self-check' // Patchbay 自己的检查没过
|
||||
| 'kernel-check' // mihomo -t 没过
|
||||
| 'write' // 写文件失败
|
||||
| 'reload' // 文件写进去了,但内核没能重新加载
|
||||
|
||||
export async function readConfig(env: Env): Promise<ConfigSnapshot> {
|
||||
const yaml = await readFile(env.configPath, 'utf8')
|
||||
const doc = openDocument(yaml)
|
||||
return {
|
||||
data: doc.data,
|
||||
yaml: doc.yaml,
|
||||
revision: doc.revision,
|
||||
diagnostics: [...doc.diagnostics, ...validateDocument(doc.data)],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一份新的配置数据落盘。
|
||||
*
|
||||
* `baseRevision` 是客户端读到这份配置时拿到的指纹。传 undefined 表示
|
||||
* 「不管别人改没改,以我为准」,仅用于还原备份这类明确要覆盖的场景。
|
||||
*/
|
||||
export async function applyConfig(
|
||||
env: Env,
|
||||
nextData: ConfigObject,
|
||||
baseRevision?: string,
|
||||
): Promise<ApplyOutcome> {
|
||||
const current = await readConfig(env)
|
||||
|
||||
if (baseRevision !== undefined && baseRevision !== current.revision) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'stale',
|
||||
message: '配置文件在你编辑期间被改动过',
|
||||
hint: '刷新页面重新载入当前配置,再把你的改动做一遍。直接覆盖会丢掉别人的改动。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
|
||||
// 生成新内容。restoreInlineCommentAlignment 把只是丢了对齐空格的行还原成原样,
|
||||
// 否则没被改动的段落也会出现在 diff 里,见 ORIGIN.md。
|
||||
const rewritten = replaceDocumentData(openDocument(current.yaml), nextData)
|
||||
const nextYaml = restoreInlineCommentAlignment(current.yaml, rewritten.yaml)
|
||||
|
||||
// 内容没变就不要走后面那一串——不必要的备份和重载只会制造噪音。
|
||||
if (nextYaml === current.yaml) {
|
||||
return { ok: true, snapshot: current, reloaded: false, reloadMessage: '内容没有变化' }
|
||||
}
|
||||
|
||||
const problems = validateDocument(nextData).filter((d) => d.severity === 'error')
|
||||
if (problems.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'self-check',
|
||||
message: problems.map((p) => `${p.path.join('.')}:${p.message}`).join('\n'),
|
||||
hint: '这些问题内核未必会拦下来,但会让配置在运行时出错,所以这里先拦住了。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
|
||||
// 先写到临时文件交给内核校验,别碰真文件。
|
||||
const tempPath = join(dirname(env.configPath), `.patchbay-check-${String(Date.now())}.yaml`)
|
||||
try {
|
||||
await writeFile(tempPath, nextYaml, 'utf8')
|
||||
const checked = await validateConfigFile(env, tempPath)
|
||||
if (!checked.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'kernel-check',
|
||||
message: checked.message,
|
||||
hint: '内核拒绝了这份配置,真文件一个字节都没动,你可以直接继续改。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(tempPath, { force: true })
|
||||
}
|
||||
|
||||
// 校验过了才动真文件。备份放在写入之前。
|
||||
const backupPath = await createBackup(env)
|
||||
try {
|
||||
await writeFile(env.configPath, nextYaml, 'utf8')
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'write',
|
||||
message: String(error),
|
||||
hint: '文件没能写进去,配置还是原来的样子。检查一下这个进程对配置文件有没有写权限。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
|
||||
const reloaded = await reloadConfig(env, env.configPath)
|
||||
if (!reloaded.ok) {
|
||||
// 文件已经写进去了但内核没接受。把文件还原,再让内核载回原样,
|
||||
// 避免留下「文件是新的、内核跑的是旧的」这种最难排查的状态。
|
||||
let restored = false
|
||||
try {
|
||||
await copyFile(backupPath, env.configPath)
|
||||
await reloadConfig(env, env.configPath)
|
||||
restored = true
|
||||
} catch {
|
||||
restored = false
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'reload',
|
||||
message: reloaded.message,
|
||||
hint: restored
|
||||
? '已经把配置还原成改动前的样子,内核也载回去了。改动没有生效。'
|
||||
: `还原也失败了。改动前的配置留在 ${backupPath},需要手动拷回 ${env.configPath}。`,
|
||||
restored,
|
||||
}
|
||||
}
|
||||
|
||||
await pruneBackups(env)
|
||||
return {
|
||||
ok: true,
|
||||
snapshot: await readConfig(env),
|
||||
reloaded: true,
|
||||
reloadMessage: '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 备份文件名形如 `config.yaml.bak-20260810-024900`。 */
|
||||
function backupName(configPath: string, at: Date): string {
|
||||
const p = (n: number, w = 2) => String(n).padStart(w, '0')
|
||||
const stamp =
|
||||
`${String(at.getFullYear())}${p(at.getMonth() + 1)}${p(at.getDate())}` +
|
||||
`-${p(at.getHours())}${p(at.getMinutes())}${p(at.getSeconds())}`
|
||||
return `${basename(configPath)}.bak-${stamp}`
|
||||
}
|
||||
|
||||
async function backupDir(env: Env): Promise<string> {
|
||||
const dir = join(dirname(env.configPath), '.patchbay-backups')
|
||||
await mkdir(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
async function createBackup(env: Env): Promise<string> {
|
||||
const dir = await backupDir(env)
|
||||
const path = join(dir, backupName(env.configPath, new Date()))
|
||||
await copyFile(env.configPath, path)
|
||||
return path
|
||||
}
|
||||
|
||||
/** 备份文件按名字排序就是按时间排序,因为时间戳是定长的。 */
|
||||
export async function listBackups(env: Env): Promise<string[]> {
|
||||
const dir = await backupDir(env)
|
||||
const prefix = `${basename(env.configPath)}.bak-`
|
||||
const entries = await readdir(dir)
|
||||
return entries.filter((name) => name.startsWith(prefix)).sort().reverse()
|
||||
}
|
||||
|
||||
async function pruneBackups(env: Env): Promise<void> {
|
||||
const dir = await backupDir(env)
|
||||
const stale = (await listBackups(env)).slice(KEEP_BACKUPS)
|
||||
await Promise.all(stale.map((name) => unlink(join(dir, name)).catch(() => {})))
|
||||
}
|
||||
|
||||
/** 把某个备份恢复成当前配置。走的是和普通改动同一条保护链路。 */
|
||||
export async function restoreBackup(env: Env, name: string): Promise<ApplyOutcome> {
|
||||
const dir = await backupDir(env)
|
||||
// 只接受 listBackups 列出来的名字,避免有人拿路径穿越读到别的文件。
|
||||
if (!(await listBackups(env)).includes(name)) {
|
||||
return {
|
||||
ok: false,
|
||||
stage: 'self-check',
|
||||
message: `没有这个备份:${name}`,
|
||||
hint: '刷新一下备份列表再试。',
|
||||
restored: false,
|
||||
}
|
||||
}
|
||||
const yaml = await readFile(join(dir, name), 'utf8')
|
||||
return applyConfig(env, openDocument(yaml).data)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 运行所需的配置项,全部来自环境变量(Bun 会自动读取 .env)。
|
||||
*
|
||||
* 这里刻意在启动时就把缺失项报出来,而不是等到某个请求进来才失败——
|
||||
* 配置类工具最糟糕的失败方式是「界面能打开、点保存才发现连不上内核」。
|
||||
*/
|
||||
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
function required(name: string): string {
|
||||
const value = Bun.env[name]
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
`缺少环境变量 ${name}。把 .env.example 复制成 .env 再填,或者在启动命令里带上它。`,
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function optional(name: string, fallback: string): string {
|
||||
return Bun.env[name] || fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径一律转成绝对路径。
|
||||
*
|
||||
* 不只是为了整洁:内核的 `PUT /configs` 接口拒绝相对路径,会直接回
|
||||
* `path is not a absolute path`。在这里统一处理,别让 .env 里怎么写决定后面能不能跑。
|
||||
*/
|
||||
function absolute(name: string): string {
|
||||
return resolve(required(name))
|
||||
}
|
||||
|
||||
export interface Env {
|
||||
/** mihomo 管理接口的地址,例如 http://127.0.0.1:9090 */
|
||||
mihomoApi: string
|
||||
/** 管理接口的访问密钥,即配置文件里 secret: 那一行 */
|
||||
mihomoSecret: string
|
||||
/** 要编辑的配置文件路径 */
|
||||
configPath: string
|
||||
/** mihomo 可执行文件路径,用于运行 `mihomo -t` 校验 */
|
||||
mihomoBin: string
|
||||
/** 传给 `mihomo -t -d` 的工作目录 */
|
||||
mihomoHome: string
|
||||
/** Patchbay 自己监听的地址 */
|
||||
host: string
|
||||
/** Patchbay 自己监听的端口 */
|
||||
port: number
|
||||
}
|
||||
|
||||
export function readEnv(): Env {
|
||||
const port = Number(optional('PATCHBAY_PORT', '7580'))
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`PATCHBAY_PORT 不是合法端口号:${String(Bun.env.PATCHBAY_PORT)}`)
|
||||
}
|
||||
|
||||
return {
|
||||
mihomoApi: required('MIHOMO_API').replace(/\/+$/, ''),
|
||||
mihomoSecret: required('MIHOMO_SECRET'),
|
||||
configPath: absolute('PATCHBAY_CONFIG'),
|
||||
mihomoBin: absolute('PATCHBAY_MIHOMO_BIN'),
|
||||
mihomoHome: absolute('PATCHBAY_MIHOMO_HOME'),
|
||||
host: optional('PATCHBAY_HOST', '127.0.0.1'),
|
||||
port,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 跟 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) })
|
||||
}
|
||||
Reference in New Issue
Block a user