配置每次落盘都打顶层 patchbay: 标记(盐加版本的哈希)。内核忽略未知 顶层键(yaml.Unmarshal 没开严格模式,config/config.go:612)。刻意不对 内容做哈希:本项目只接管部分段落,手改其余段落是正常用法,内容哈希 会把合法手改判成无效。上传配置时靠标记分流:有标记的确认后替换, 没有的用更重的措辞确认导入;两者都走 config-store 完整保护链路 (新端点 POST /api/config/import)。自动备份列表从界面撤下,改成 下载当前配置 + 拖放上传。 内核参数一组编辑五个老式顶层端口和 keep-alive-interval(config.go:444, 整数秒),走带基准指纹的保存链路。资源一组接 POST /configs/geo (同步接口,透传给了 300 秒超时)。测速地址成为本地偏好,延迟测试 读它;prefs 模块顶层的浏览器全局加了存在性守卫——api.ts 开始引用 prefs,而 bun test 环境没有 localStorage。 退出登录挪到侧栏底部;密度设置从设置页撤下(侧栏按钮还在); 免责声明和关于做成子页(左上角返回),登录页输入框下加同一份 免责声明的弹层入口。
338 lines
13 KiB
TypeScript
338 lines
13 KiB
TypeScript
/**
|
||
* 配置文件的读写,以及改动落盘时的那条保护链路。
|
||
*
|
||
* 这个文件存在的全部理由是一句话:**配置写坏了要能把用户救回来**。
|
||
* 一台跑着 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 { withMarker } from '../shared/config-marker.ts'
|
||
import { kernelVersion, validateConfigFile, reloadConfig } from './kernel.ts'
|
||
import type { Env } from './env.ts'
|
||
|
||
/** 备份保留的份数。再多没有意义——真要回到很久以前的版本,那是版本控制的活。 */
|
||
const KEEP_BACKUPS = 10
|
||
|
||
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 表示
|
||
* 「不管别人改没改,以我为准」,仅用于还原备份这类明确要覆盖的场景。
|
||
*/
|
||
export async function applyConfig(
|
||
env: Env,
|
||
nextData: ConfigObject,
|
||
baseRevision?: string,
|
||
): 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 backupPath = await createBackup(env)
|
||
const backupTiming = 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)
|
||
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 {
|
||
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)
|
||
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(() => {})))
|
||
}
|
||
|
||
/**
|
||
* 用上传的 YAML 原文整份替换当前配置。
|
||
*
|
||
* 解析用和读配置同一个引擎,之后走 applyConfig 完整的保护链路(自检、备份、
|
||
* 重载、失败回退)。没有 baseRevision——整份替换本来就是覆盖语义,同还原备份。
|
||
*/
|
||
export async function importConfig(env: Env, content: string): Promise<ApplyOutcome> {
|
||
let data: 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,
|
||
}
|
||
}
|
||
data = doc.data
|
||
} catch (error) {
|
||
return {
|
||
ok: false,
|
||
stage: 'self-check',
|
||
message: `上传的文件不是合法的 YAML:${String(error)}`,
|
||
hint: '当前配置没有被改动。',
|
||
restored: false,
|
||
}
|
||
}
|
||
return applyConfig(env, data)
|
||
}
|
||
|
||
/** 把某个备份恢复成当前配置。走的是和普通改动同一条保护链路。 */
|
||
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)
|
||
}
|