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

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

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

230 lines
8.6 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 的机器,配置一旦被写成内核起不来的样子,用户很可能连
* 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)
}