顺序是:检查基准是否过时 → 自己校验 → 临时文件上跑 mihomo -t → 备份 → 写入 → 重载, 最后两步任一失败就从备份还原并让内核载回原样。 自己校验那一步不能省:实测 v1.19.29,入站的 proxy 指向不存在的出站时 mihomo -t 会通过、 重载会成功,直到有流量走到那个入站才在日志里报错。这类错误只有 Patchbay 查得出来。 路径在读环境变量时就转成绝对路径,因为内核的 PUT /configs 拒绝相对路径。
118 lines
5.1 KiB
TypeScript
118 lines
5.1 KiB
TypeScript
/**
|
||
* 把「改动落盘」的保护链路对着一个真的内核跑一遍。
|
||
*
|
||
* 这条链路的价值全在出错的时候,所以这里大部分场景是故意写坏配置,
|
||
* 看它拦在哪一步、真文件有没有被动过、还原有没有生效。
|
||
*
|
||
* 前置条件:本地测试内核正在运行(见 .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)
|