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

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

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

118 lines
5.1 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.
/**
* 把「改动落盘」的保护链路对着一个真的内核跑一遍。
*
* 这条链路的价值全在出错的时候,所以这里大部分场景是故意写坏配置,
* 看它拦在哪一步、真文件有没有被动过、还原有没有生效。
*
* 前置条件:本地测试内核正在运行(见 .env.example 的「本地开发」一节)
* ./binary/mihomo-windows-amd64-v3-go125.exe -d patchbay/dev/home -f patchbay/dev/home/config.yaml
*
* 用法:bun run scripts/safety-net.ts
*/
import { copyFile, readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { readEnv } from '../server/env.ts'
import { applyConfig, listBackups, readConfig } from '../server/config-store.ts'
import { kernelVersion } from '../server/kernel.ts'
import type { ConfigObject } from '../vendor/config-editor/index.ts'
const env = readEnv()
const kernel = await kernelVersion(env)
if (!kernel.online) {
console.error('本地测试内核没在跑。先启动它,命令见本文件顶部的注释。')
process.exit(1)
}
console.log(`内核在线 ${kernel.version ?? ''}\n`)
// 每次从模板重置,保证多次运行结果一致。
await copyFile('./dev/config.yaml', env.configPath)
const clone = (v: unknown) => JSON.parse(JSON.stringify(v)) as ConfigObject
let failures = 0
async function check(name: string, run: () => Promise<string | null>) {
const problem = await run()
if (problem) {
failures += 1
console.log(`✗ ${name}\n ${problem}`)
} else {
console.log(`✓ ${name}`)
}
}
await check('正常改动能落盘并让内核重载', async () => {
const before = await readConfig(env)
const draft = clone(before.data)
draft.listeners = [{ name: '容器A', type: 'mixed', port: 17891, proxy: 'AUTO' }]
const result = await applyConfig(env, draft, before.revision)
if (!result.ok) return `本该成功,却停在 ${result.stage}:${result.message}`
if (!result.reloaded) return '文件写了但没重载内核'
const onDisk = await readFile(env.configPath, 'utf8')
if (!onDisk.includes('port: 17891')) return '文件里没有新增的入站'
return null
})
await check('未改动的行没有被动过', async () => {
const onDisk = await readFile(env.configPath, 'utf8')
const template = await readFile('./dev/config.yaml', 'utf8')
const untouched = template.split('\n').filter((l) => l.trim() && !l.startsWith('listeners'))
const missing = untouched.filter((line) => !onDisk.includes(line))
return missing.length === 0 ? null : `这些行被改动了:\n ${missing.join('\n ')}`
})
await check('入站指向不存在的出站会被自己的检查拦下(内核不管这类)', async () => {
const before = await readConfig(env)
const draft = clone(before.data)
draft.listeners = [{ name: '坏口', type: 'mixed', port: 17892, proxy: '这个出站不存在' }]
const result = await applyConfig(env, draft, before.revision)
if (result.ok) return '居然放行了——这正是内核 mihomo -t 会漏掉的那类错误'
if (result.stage !== 'self-check') return `拦下了,但停在 ${result.stage} 而不是 self-check`
const after = await readConfig(env)
if (after.revision !== before.revision) return '真文件被动过了'
return null
})
await check('内核拒绝的配置被拦下,且真文件一个字节没动', async () => {
const before = await readConfig(env)
const beforeYaml = await readFile(env.configPath, 'utf8')
const draft = clone(before.data)
// 自己的检查只要求入站有 name 和 type,不校验 type 的取值,所以这份会走到内核那一步。
draft.listeners = [{ name: '怪口', type: '这不是合法类型', port: 17893 }]
const result = await applyConfig(env, draft, before.revision)
if (result.ok) return '内核居然接受了一个非法的入站类型'
if (result.stage !== 'kernel-check') return `停在 ${result.stage} 而不是 kernel-check`
if (!result.message.trim()) return '拦下了但没给出内核的原话,用户会不知道哪里错了'
const afterYaml = await readFile(env.configPath, 'utf8')
if (afterYaml !== beforeYaml) return '真文件被动过了——校验本该在临时文件上做'
return null
})
await check('基准过时的改动会被拒绝,不会覆盖别人的修改', async () => {
const before = await readConfig(env)
const draft = clone(before.data)
draft.mode = 'global'
const result = await applyConfig(env, draft, '这是一个过时的指纹')
if (result.ok) return '居然放行了,别人的改动会被悄悄覆盖'
if (result.stage !== 'stale') return `停在 ${result.stage} 而不是 stale`
return null
})
await check('成功的改动会留下备份', async () => {
const backups = await listBackups(env)
if (backups.length === 0) return '一个备份都没有'
const newest = await readFile(join(dirname(env.configPath), '.patchbay-backups', backups[0]!), 'utf8')
if (newest.includes('port: 17891')) return '备份里已经含有新改动,说明备份晚于写入'
return null
})
console.log(failures === 0 ? '\n全部通过' : `\n${String(failures)} 项没通过`)
process.exit(failures === 0 ? 0 : 1)