Files
im0o a40bd6a01b 历史记录:每条可展开 diff,连续保存合并进 5 秒窗
备份存的是「改动前」,所以一条记录的 diff 两端是它自己和更新一份的
状态(最新一条对比当前文件)。行级 diff 先掐公共前后缀再做 LCS,一次
改动通常只碰几行,表撑得住;大面积不同时退化成整段删增。

合并窗做在服务端而不是前端防抖:普通保存(opts.merge)发现最新备份
还在窗内就不新建,连点删除共享一条历史、一步撤回。窗锚定在这一串的
第一下,不滚雪球;还原、导入、撤回重做不合并,各占一条。合并时没有
备份文件,重载失败的还原改用刚读出的原文写回。顺带消除了备份名只精
确到秒、同秒保存互相覆盖的隐患。
2026-08-12 13:17:11 +08:00

511 lines
21 KiB
TypeScript
Raw Permalink 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 都得绕路才能进去修。所以落盘不是「写文件」这一个动作,而是一串顺序
* 严格的动作,每一步都要留好退路。
*
* 顺序是:
* 读当前文件 → 检查是否被外部改过 → 生成新内容 → 自己检查一遍
* → 备份原文件 → 写入 → 让内核重载,被拒就从备份还原并载回原样
* 重载这一步自带校验:内核先完整解析、解析失败什么都不应用并返回 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<boolean> {
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<ConfigDocument> {
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<ConfigSnapshot> {
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<ApplyOutcome> {
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<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 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<ApplyOutcome> {
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<string> {
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<string[]> {
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<void> {
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<string> {
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<ApplyOutcome> {
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<ApplyOutcome> {
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<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)
}