/** * 配置文件的读写,以及改动落盘时的那条保护链路。 * * 这个文件存在的全部理由是一句话:**配置写坏了要能把用户救回来**。 * 一台跑着 mihomo 的机器,配置一旦被写成内核起不来的样子,用户很可能连 * SSH 都得绕路才能进去修。所以落盘不是「写文件」这一个动作,而是一串顺序 * 严格的动作,每一步都要留好退路。 * * 顺序是: * 读当前文件 → 检查是否被外部改过 → 生成新内容 → 自己检查一遍 * → 备份原文件 → 写入 → 让内核重载,被拒就从备份还原并载回原样 * 重载这一步自带校验:内核先完整解析、解析失败什么都不应用并返回 400 * (内核源码 hub/route/configs.go:430-438),还原链路补上「把文件退回去」。 * * 内核不在线时没有这层校验,换成: * → 写到临时文件 → 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 { backupDate } from '../shared/backups.ts' import { withMarker } from '../shared/config-marker.ts' import { kernelVersion, validateConfigFile, reloadConfig } from './kernel.ts' import type { Env } from './env.ts' /** 备份保留的份数。再多没有意义——真要回到很久以前的版本,那是版本控制的活。 */ const KEEP_BACKUPS = 10 /** * 界面上的连续保存合并成一条历史的时间窗:最新备份还在窗内就不再新建, * 这一串操作共享一条记录、一步撤回。连点删除不再刷出一屏历史。 * 顺带解决了备份名只精确到秒的隐患——窗内不建新备份,同秒的覆盖不会发生。 */ const MERGE_WINDOW_MS = 5_000 /** 最新备份是否还在合并窗内。时间戳从备份名里解析,时钟往回跳时当不在窗内。 */ async function hasRecentBackup(env: Env): Promise { const [newest] = await listBackups(env) const at = newest ? backupDate(newest) : null if (!at) return false const age = Date.now() - at.getTime() return age >= 0 && age < MERGE_WINDOW_MS } export interface ConfigSnapshot { /** 解析出来的配置数据 */ data: ConfigObject /** * 内容指纹。客户端改完提交时要带上它,服务端据此判断这份改动 * 是不是基于最新的文件——中途有人 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' // 文件写进去了,但内核没能重新加载 /** 内部用的完整读取,带原文。原文只在生成新内容时需要,不下发给前端。 */ interface ConfigDocument extends ConfigSnapshot { yaml: string } async function readDocument(env: Env): Promise { 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)], } } /** * 读当前配置。 * * 不含文件原文:前端从来不读它,带上只会让每次请求的响应体积翻倍。要原文的地方 * 在这个文件内部,用 readDocument。 */ export async function readConfig(env: Env): Promise { const { yaml: _yaml, ...snapshot } = await readDocument(env) return snapshot } /** * 把新的配置数据写成 YAML 文本。 * * 单独导出是为了让测试能走**生产用的这一条**路径。之前测试自己拼了 * 「解析 → 替换 → 还原对齐」三步,于是如果有人在落盘链路里漏掉最后那步, * 那些专为守护它而写的测试照样全绿。 */ export function rewriteYaml(currentYaml: string, nextData: ConfigObject): string { const rewritten = replaceDocumentData(openDocument(currentYaml), nextData) // 把只是丢了对齐空格的行还原成原样,否则没被改动的段落也会出现在 diff 里,见 ORIGIN.md return restoreInlineCommentAlignment(currentYaml, rewritten.yaml) } /** * 把一份新的配置数据落盘。 * * `baseRevision` 是客户端读到这份配置时拿到的指纹。传 undefined 表示 * 「不管别人改没改,以我为准」,仅用于还原备份这类明确要覆盖的场景。 * * `opts.restoreFrom`:撤回/重做专用。给了它就不再新建备份(否则备份链会被 * 撤回本身污染,撤两次等于原地打转),重载失败时用它当还原源;同时不清空 * 重做栈——只有「正常的新改动」才让已撤回的未来失效。 * * `opts.merge`:界面上的普通保存专用。最新备份还在合并窗内时不新建备份, * 连续操作共享一条历史。还原备份、上传导入这类独立动作不传,各占一条。 */ export async function applyConfig( env: Env, nextData: ConfigObject, baseRevision?: string, opts?: { restoreFrom?: string; merge?: boolean }, ): Promise { const current = await readDocument(env) if (baseRevision !== undefined && baseRevision !== current.revision) { return { ok: false, stage: 'stale', message: '配置文件在你编辑期间被改动过', hint: '刷新页面重新载入当前配置,再把你的改动做一遍。直接覆盖会丢掉别人的改动。', restored: false, } } // 落盘链路有五步各自可能变慢(校验要起一个内核进程、重载要等内核跑完、备份和写盘是磁盘 // IO)。出问题时不靠猜,日志里直接给出每步耗时。 const clock = { start: Date.now(), mark: Date.now() } const lap = (stage: string) => { const now = Date.now() const ms = now - clock.mark clock.mark = now return `${stage} ${String(ms)}ms` } // 每次落盘都打上 Patchbay 标记(顶层 patchbay: 键,内核忽略未知键)。 // 上传配置时靠它认「这是 Patchbay 管理过的配置」。标记是幂等的, // 已带标记的配置重打一遍内容不变,不影响下面的「内容没变就短路」 const nextYaml = rewriteYaml(current.yaml, withMarker(nextData)) // 内容没变就不要走后面那一串——不必要的备份和重载只会制造噪音。 if (nextYaml === current.yaml) { const { yaml: _yaml, ...snapshot } = current return { ok: true, snapshot, 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, } } // 内核在线时不跑 mihomo -t:起一个进程把配置重新解析一遍占整条链路九成以上的耗时 // (实测保存 4-10 秒里它占 4-9.4 秒,重载只要几十到几百毫秒),而重载请求自带同一套 // 解析、失败时什么都不应用,还原链路补上「把文件退回去」,效果等同校验。代价是写盘到 // 还原之间的短暂窗口里磁盘上存在一份可能有错的配置,进程恰好在这时被杀才会留下事故。 // 内核不在线时 mihomo -t 是唯一的校验手段。 const kernel = await kernelVersion(env) let checkTiming = '' if (!kernel.online) { // 先写到临时文件交给内核校验,别碰真文件。 const tempPath = join(dirname(env.configPath), `.patchbay-check-${String(Date.now())}.yaml`) try { await writeFile(tempPath, nextYaml, 'utf8') const checked = await validateConfigFile(env, tempPath) checkTiming = lap('内核校验') if (!checked.ok) { return { ok: false, stage: 'kernel-check', message: checked.message, hint: '内核拒绝了这份配置,真文件一个字节都没动,你可以直接继续改。', restored: false, } } } finally { await rm(tempPath, { force: true }) } } // 校验过了才动真文件。备份放在写入之前;合并窗内跳过新建,重载失败的还原 // 不受影响——没有备份文件时用刚读出的原文写回(见下面的还原分支)。 const merged = opts?.merge === true && opts.restoreFrom === undefined && (await hasRecentBackup(env)) const backupPath = opts?.restoreFrom ?? (merged ? null : await createBackup(env)) const backupTiming = backupPath !== null && opts?.restoreFrom === undefined ? lap('备份') : '' try { await writeFile(env.configPath, nextYaml, 'utf8') } catch (error) { return { ok: false, stage: 'write', message: String(error), hint: '文件没能写进去,配置还是原来的样子。检查一下这个进程对配置文件有没有写权限。', restored: false, } } const writeTiming = lap('写盘') if (!kernel.online) { // mihomo -t 已经把过关,文件留给内核下次启动时读。 await pruneBackups(env) if (!opts?.restoreFrom) await clearRedo(env) console.log( `[apply] 共 ${String(Date.now() - clock.start)}ms(${checkTiming},${backupTiming},${writeTiming},未重载)`, ) return { ok: true, snapshot: await readConfig(env), reloaded: false, reloadMessage: '内核未连接,改动已写入文件,等内核启动时生效', } } const reloaded = await reloadConfig(env, env.configPath) const reloadTiming = lap('重载') if (!reloaded.ok) { // 文件已经写进去了但内核没接受。把文件还原,再让内核载回原样, // 避免留下「文件是新的、内核跑的是旧的」这种最难排查的状态。 // 合并窗内没有备份文件,还原源用刚读出的原文。 let restored = false try { if (backupPath !== null) await copyFile(backupPath, env.configPath) else await writeFile(env.configPath, current.yaml, 'utf8') await reloadConfig(env, env.configPath) restored = true } catch { restored = false } return { ok: false, stage: 'reload', message: reloaded.message, hint: restored ? '已经把配置还原成改动前的样子,内核也载回去了。改动没有生效。' : backupPath !== null ? `还原也失败了。改动前的配置留在 ${backupPath},需要手动拷回 ${env.configPath}。` : '还原也失败了。历史记录里最新的备份和改动前至多差几秒内的操作,可以从它还原。', restored, } } await pruneBackups(env) if (!opts?.restoreFrom) await clearRedo(env) const timings = [checkTiming, backupTiming, writeTiming, reloadTiming].filter(Boolean).join(',') console.log(`[apply] 共 ${String(Date.now() - clock.start)}ms(${timings})`) 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 { const dir = join(dirname(env.configPath), '.patchbay-backups') await mkdir(dir, { recursive: true }) return dir } async function createBackup(env: Env): Promise { 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 { 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 { 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 type ImportMode = 'override' | 'compatible' /** * 兼容模式搬运的段落:Patchbay 接管的那几个(决策记录 0003)。 * 老式顶层端口、dns、tun 这类属于机器本身的设置不在其中——兼容恢复的语义是 * 「把我在 Patchbay 里做的接线搬过来」,不动当前机器的底座。 */ const MANAGED_KEYS = ['listeners', 'proxy-groups', 'rules', 'proxy-providers', 'rule-providers'] /** * 用上传的 YAML 替换当前配置。 * * `override` 整份替换;`compatible` 只把上传文件里 Patchbay 接管的段落搬到 * 当前配置上(上传文件里没有的段落维持当前的样子)。解析用和读配置同一个引擎, * 之后走 applyConfig 完整的保护链路(自检、备份、重载、失败回退)。 * 没有 baseRevision——替换本来就是覆盖语义,同还原备份。 */ export async function importConfig( env: Env, content: string, mode: ImportMode, ): Promise { let uploaded: ConfigObject try { const doc = openDocument(content) const broken = doc.diagnostics.filter((d) => d.severity === 'error') if (broken.length > 0) { return { ok: false, stage: 'self-check', message: broken.map((p) => `${p.path.join('.')}:${p.message}`).join('\n'), hint: '上传的文件解析不过,当前配置没有被改动。', restored: false, } } uploaded = doc.data } catch (error) { return { ok: false, stage: 'self-check', message: `上传的文件不是合法的 YAML:${String(error)}`, hint: '当前配置没有被改动。', restored: false, } } if (mode === 'override') return applyConfig(env, uploaded) const current = await readDocument(env) const merged: ConfigObject = { ...current.data } for (const key of MANAGED_KEYS) { const value = uploaded[key] if (value !== undefined) merged[key] = value } return applyConfig(env, merged) } /* ── 撤回 / 重做 ────────────────────────────────── * * 撤回栈就是备份链:每次成功落盘都留了「改动前」的备份。撤回把最新备份应用 * 回去并**消费掉它**(从链里删除),同时把撤回前的状态推进重做栈;重做反向。 * 任何正常的新改动清空重做栈——和编辑器撤回的通行语义一致,不留分叉历史。 */ async function redoDir(env: Env): Promise { const dir = join(dirname(env.configPath), '.patchbay-redo') await mkdir(dir, { recursive: true }) return dir } /** 重做条目按名字排序就是按时间排序。带毫秒:连续快速撤回不能互相覆盖。 */ function redoName(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())}${p(at.getMilliseconds(), 3)}` return `${basename(configPath)}.redo-${stamp}` } /** 新的在前,同 listBackups。 */ export async function listRedo(env: Env): Promise { const dir = await redoDir(env) const prefix = `${basename(env.configPath)}.redo-` const entries = await readdir(dir) return entries.filter((name) => name.startsWith(prefix)).sort().reverse() } async function clearRedo(env: Env): Promise { const dir = await redoDir(env) const entries = await listRedo(env) await Promise.all(entries.map((name) => unlink(join(dir, name)).catch(() => {}))) } /** 当前文件进重做栈,返回落点路径。 */ async function pushRedo(env: Env): Promise { const dir = await redoDir(env) const path = join(dir, redoName(env.configPath, new Date())) await copyFile(env.configPath, path) // 和备份同一个上限。裁掉的是最「未来」的状态,同备份链裁掉最远的过去 const stale = (await listRedo(env)).slice(KEEP_BACKUPS) await Promise.all(stale.map((name) => unlink(join(dir, name)).catch(() => {}))) return path } /** 撤回最近一次改动:应用最新备份并消费掉它,撤回前的状态进重做栈。 */ export async function undoConfig(env: Env): Promise { const [target] = await listBackups(env) if (!target) { return { ok: false, stage: 'self-check', message: '没有可以撤回的改动', hint: '每次保存都会把改动前的状态留进撤回栈,现在栈是空的。', restored: false, } } const dir = await backupDir(env) const targetYaml = await readFile(join(dir, target), 'utf8') const redoPath = await pushRedo(env) const result = await applyConfig(env, openDocument(targetYaml).data, undefined, { restoreFrom: redoPath, }) if (result.ok) await unlink(join(dir, target)).catch(() => {}) else await unlink(redoPath).catch(() => {}) return result } /** 重做最近一次被撤回的改动。当前状态回到撤回栈,重做条目被消费。 */ export async function redoConfig(env: Env): Promise { const [target] = await listRedo(env) if (!target) { return { ok: false, stage: 'self-check', message: '没有可以重做的改动', hint: '只有撤回过的改动才能重做,新的保存会清空重做栈。', restored: false, } } const dir = await redoDir(env) const targetYaml = await readFile(join(dir, target), 'utf8') const backupPath = await createBackup(env) const result = await applyConfig(env, openDocument(targetYaml).data, undefined, { restoreFrom: backupPath, }) if (result.ok) await unlink(join(dir, target)).catch(() => {}) else await unlink(backupPath).catch(() => {}) return result } /** * 一条历史记录展开时要对比的两份原文。 * * 备份存的是「改动前」,所以这次改动的对比对象是更新一份的状态:列表里 * 它前面那份备份,最新一条则对比当前文件。名字对着 listBackups 校验, * 同 restoreBackup,防路径穿越。没有这个备份时返回 null。 */ export async function backupDiff( env: Env, name: string, ): Promise<{ before: string; after: string } | null> { const backups = await listBackups(env) const index = backups.indexOf(name) if (index < 0) return null const dir = await backupDir(env) const before = await readFile(join(dir, name), 'utf8') const after = index === 0 ? await readFile(env.configPath, 'utf8') : await readFile(join(dir, backups[index - 1]!), 'utf8') return { before, after } } /** 把某个备份恢复成当前配置。走的是和普通改动同一条保护链路。 */ export async function restoreBackup(env: Env, name: string): Promise { 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) }