diff --git a/.env.example b/.env.example index 74c855f..92144ab 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,25 @@ # 复制成 .env 再填。.env 已在 .gitignore 里,不会被提交。 -# mihomo 管理接口的地址。开发时通过 SSH 隧道映射到本机: +# ── 本地开发(默认这一套)──────────────────────────────── +# 用仓库里的 dev/config.yaml 和本地内核,整条「写回 → 校验 → 重载 → 失败还原」 +# 都能在本机真跑一遍,包括故意写坏配置看它怎么恢复。 +# 端口刻意避开 mihomo 的默认值(7890 / 9090),免得和机器上其它内核打架。 +MIHOMO_API=http://127.0.0.1:19090 +MIHOMO_SECRET=dev-secret-local-only +PATCHBAY_CONFIG=./dev/home/config.yaml +PATCHBAY_MIHOMO_BIN=../binary/mihomo-windows-amd64-v3-go125.exe +PATCHBAY_MIHOMO_HOME=./dev/home + +# ── 想对着真实机器调试时改成这一套 ────────────────────── +# 先开隧道把管理接口映射到本机: # ssh -N -f -L 9090:127.0.0.1:9090 用户名@服务器地址 -MIHOMO_API=http://127.0.0.1:9090 +# 注意:隧道只转发管理接口。配置文件和内核在远端,所以 mihomo -t 校验和 +# PUT /configs 重载都作用不到本地的文件,这套只适合读真实的订阅和线路数据。 +# MIHOMO_API=http://127.0.0.1:9090 +# MIHOMO_SECRET=服务器上 secret: 那一行的值 +# PATCHBAY_CONFIG=../pkgs/sample-config.yaml -# 管理接口的访问密钥,即配置文件里 secret: 那一行的值 -MIHOMO_SECRET= - -# 要编辑的配置文件路径。开发时指向本地副本,部署时指向 /etc/mihomo/config.yaml -PATCHBAY_CONFIG=../pkgs/sample-config.yaml - -# Patchbay 自己监听的地址和端口。默认只监听本机,远程访问走 SSH 隧道。 +# ── Patchbay 自己 ────────────────────────────────────── +# 默认只监听本机,远程访问走 SSH 隧道。 PATCHBAY_HOST=127.0.0.1 PATCHBAY_PORT=7580 diff --git a/.gitignore b/.gitignore index 7d8f467..92bfdee 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ dist/ # 从目标机器拷回来的配置副本 *.local.yaml config.local.* + +# 本地测试内核的工作目录。dev/config.yaml 是模板,要提交; +# dev/home/ 是运行时目录,内核会往里写 GEO 数据和被改动的配置副本,不提交。 +dev/home/ diff --git a/dev/config.yaml b/dev/config.yaml new file mode 100644 index 0000000..201e4c8 --- /dev/null +++ b/dev/config.yaml @@ -0,0 +1,42 @@ +# 本地开发用的测试配置 — 不含任何真实信息 +# 端口刻意避开默认值(7890 / 9090),免得和机器上其它 mihomo 打架。 +# 里面的服务器地址全是占位的,从不会真的连上去;这份配置只用来验证 +# 「写回 → 校验 → 重载 → 失败还原」这条链路,不用来真的代理流量。 + +mixed-port: 17890 # 代理端口,避开默认的 7890 +allow-lan: false # 本地测试不对局域网开放 +mode: rule +log-level: warning +ipv6: false + +external-controller: 127.0.0.1:19090 # 管理接口,避开默认的 9090 +secret: "dev-secret-local-only" + +# 关掉自动更新,避免测试时下载 GEO 数据库 +geo-auto-update: false + +proxies: + - name: 占位线路甲 + type: socks5 + server: 127.0.0.1 + port: 1080 + - name: 占位线路乙 + type: socks5 + server: 127.0.0.1 + port: 1081 + +proxy-groups: + - name: PROXY # 总开关,手动选 + type: select + proxies: [AUTO, DIRECT] + - name: AUTO # 自动挑延迟最低的 + type: url-test + proxies: [占位线路甲, 占位线路乙] + url: https://www.gstatic.com/generate_204 + interval: 300 + +rules: + # 本机和内网地址一律直连 + - IP-CIDR,127.0.0.0/8,DIRECT,no-resolve + - IP-CIDR,192.168.0.0/16,DIRECT,no-resolve + - MATCH,PROXY diff --git a/scripts/safety-net.ts b/scripts/safety-net.ts new file mode 100644 index 0000000..fd8843f --- /dev/null +++ b/scripts/safety-net.ts @@ -0,0 +1,117 @@ +/** + * 把「改动落盘」的保护链路对着一个真的内核跑一遍。 + * + * 这条链路的价值全在出错的时候,所以这里大部分场景是故意写坏配置, + * 看它拦在哪一步、真文件有没有被动过、还原有没有生效。 + * + * 前置条件:本地测试内核正在运行(见 .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) { + 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) diff --git a/server/config-store.ts b/server/config-store.ts new file mode 100644 index 0000000..ef6c2e5 --- /dev/null +++ b/server/config-store.ts @@ -0,0 +1,229 @@ +/** + * 配置文件的读写,以及改动落盘时的那条保护链路。 + * + * 这个文件存在的全部理由是一句话:**配置写坏了要能把用户救回来**。 + * 一台跑着 mihomo 的机器,配置一旦被写成内核起不来的样子,用户很可能连 + * SSH 都得绕路才能进去修。所以落盘不是「写文件」这一个动作,而是一串顺序 + * 严格的动作,每一步都要留好退路。 + * + * 顺序是: + * 读当前文件 → 检查是否被外部改过 → 生成新内容 → 自己检查一遍 + * → 写到临时文件 → 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 { validateConfigFile, reloadConfig } from './kernel.ts' +import type { Env } from './env.ts' + +/** 备份保留的份数。再多没有意义——真要回到很久以前的版本,那是版本控制的活。 */ +const KEEP_BACKUPS = 10 + +export interface ConfigSnapshot { + /** 解析出来的配置数据 */ + data: ConfigObject + /** 文件原文 */ + yaml: string + /** + * 内容指纹。客户端改完提交时要带上它,服务端据此判断这份改动 + * 是不是基于最新的文件——中途有人 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' // 文件写进去了,但内核没能重新加载 + +export async function readConfig(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)], + } +} + +/** + * 把一份新的配置数据落盘。 + * + * `baseRevision` 是客户端读到这份配置时拿到的指纹。传 undefined 表示 + * 「不管别人改没改,以我为准」,仅用于还原备份这类明确要覆盖的场景。 + */ +export async function applyConfig( + env: Env, + nextData: ConfigObject, + baseRevision?: string, +): Promise { + const current = await readConfig(env) + + if (baseRevision !== undefined && baseRevision !== current.revision) { + return { + ok: false, + stage: 'stale', + message: '配置文件在你编辑期间被改动过', + hint: '刷新页面重新载入当前配置,再把你的改动做一遍。直接覆盖会丢掉别人的改动。', + restored: false, + } + } + + // 生成新内容。restoreInlineCommentAlignment 把只是丢了对齐空格的行还原成原样, + // 否则没被改动的段落也会出现在 diff 里,见 ORIGIN.md。 + const rewritten = replaceDocumentData(openDocument(current.yaml), nextData) + const nextYaml = restoreInlineCommentAlignment(current.yaml, rewritten.yaml) + + // 内容没变就不要走后面那一串——不必要的备份和重载只会制造噪音。 + if (nextYaml === current.yaml) { + return { ok: true, snapshot: current, 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, + } + } + + // 先写到临时文件交给内核校验,别碰真文件。 + const tempPath = join(dirname(env.configPath), `.patchbay-check-${String(Date.now())}.yaml`) + try { + await writeFile(tempPath, nextYaml, 'utf8') + const checked = await validateConfigFile(env, tempPath) + 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) + try { + await writeFile(env.configPath, nextYaml, 'utf8') + } catch (error) { + return { + ok: false, + stage: 'write', + message: String(error), + hint: '文件没能写进去,配置还是原来的样子。检查一下这个进程对配置文件有没有写权限。', + restored: false, + } + } + + const reloaded = await reloadConfig(env, env.configPath) + 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) + 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 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) +} diff --git a/server/env.ts b/server/env.ts new file mode 100644 index 0000000..fab45d1 --- /dev/null +++ b/server/env.ts @@ -0,0 +1,66 @@ +/** + * 运行所需的配置项,全部来自环境变量(Bun 会自动读取 .env)。 + * + * 这里刻意在启动时就把缺失项报出来,而不是等到某个请求进来才失败—— + * 配置类工具最糟糕的失败方式是「界面能打开、点保存才发现连不上内核」。 + */ + +import { resolve } from 'node:path' + +function required(name: string): string { + const value = Bun.env[name] + if (!value) { + throw new Error( + `缺少环境变量 ${name}。把 .env.example 复制成 .env 再填,或者在启动命令里带上它。`, + ) + } + return value +} + +function optional(name: string, fallback: string): string { + return Bun.env[name] || fallback +} + +/** + * 路径一律转成绝对路径。 + * + * 不只是为了整洁:内核的 `PUT /configs` 接口拒绝相对路径,会直接回 + * `path is not a absolute path`。在这里统一处理,别让 .env 里怎么写决定后面能不能跑。 + */ +function absolute(name: string): string { + return resolve(required(name)) +} + +export interface Env { + /** mihomo 管理接口的地址,例如 http://127.0.0.1:9090 */ + mihomoApi: string + /** 管理接口的访问密钥,即配置文件里 secret: 那一行 */ + mihomoSecret: string + /** 要编辑的配置文件路径 */ + configPath: string + /** mihomo 可执行文件路径,用于运行 `mihomo -t` 校验 */ + mihomoBin: string + /** 传给 `mihomo -t -d` 的工作目录 */ + mihomoHome: string + /** Patchbay 自己监听的地址 */ + host: string + /** Patchbay 自己监听的端口 */ + port: number +} + +export function readEnv(): Env { + const port = Number(optional('PATCHBAY_PORT', '7580')) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`PATCHBAY_PORT 不是合法端口号:${String(Bun.env.PATCHBAY_PORT)}`) + } + + return { + mihomoApi: required('MIHOMO_API').replace(/\/+$/, ''), + mihomoSecret: required('MIHOMO_SECRET'), + configPath: absolute('PATCHBAY_CONFIG'), + mihomoBin: absolute('PATCHBAY_MIHOMO_BIN'), + mihomoHome: absolute('PATCHBAY_MIHOMO_HOME'), + host: optional('PATCHBAY_HOST', '127.0.0.1'), + port, + } +} diff --git a/server/kernel.ts b/server/kernel.ts new file mode 100644 index 0000000..d00d4ba --- /dev/null +++ b/server/kernel.ts @@ -0,0 +1,101 @@ +/** + * 跟 mihomo 内核打交道的两条通道。 + * + * 一条是命令行:`mihomo -t` 校验一份配置能不能被内核接受。 + * 一条是 HTTP 管理接口:读运行时状态、让内核重新加载配置。 + * + * 两条通道的能力边界不一样,而且有一处不对称必须记住: + * `mihomo -t` **不检查入站的 `proxy:` 指向的出站是否存在**(实测 v1.19.29, + * 这类配置校验通过、重载成功,直到有流量走到那个入站才在日志里报错)。 + * 所以这一层不是安全网的全部,落盘前还要跑 Patchbay 自己的检查。 + */ +import type { Env } from './env.ts' + +export interface ValidateResult { + ok: boolean + /** 内核输出的原文,校验失败时用来告诉用户到底哪里不对 */ + message: string +} + +/** + * 用 `mihomo -t` 校验一份配置文件。 + * + * 超时设得比较宽,因为内核首次校验时可能同步下载 GEO 数据库。 + */ +export async function validateConfigFile( + env: Env, + configPath: string, + timeoutMs = 300_000, +): Promise { + const proc = Bun.spawn([env.mihomoBin, '-t', '-d', env.mihomoHome, '-f', configPath], { + stdout: 'pipe', + stderr: 'pipe', + }) + + const timer = setTimeout(() => proc.kill(), timeoutMs) + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + const message = [stdout, stderr].filter(Boolean).join('\n').trim() + if (exitCode === 0) return { ok: true, message } + return { + ok: false, + message: message || `mihomo -t 以退出码 ${String(exitCode)} 结束,但没有任何输出`, + } + } finally { + clearTimeout(timer) + } +} + +/** 带上认证头访问管理接口。 */ +async function callApi(env: Env, path: string, init?: RequestInit): Promise { + return fetch(`${env.mihomoApi}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${env.mihomoSecret}`, + 'Content-Type': 'application/json', + ...init?.headers, + }, + }) +} + +/** 内核是否在线,在线时返回版本号。 */ +export async function kernelVersion(env: Env): Promise<{ online: boolean; version?: string }> { + try { + const res = await callApi(env, '/version', { signal: AbortSignal.timeout(5_000) }) + if (!res.ok) return { online: false } + const body = (await res.json()) as { version?: string } + return { online: true, version: body.version } + } catch { + return { online: false } + } +} + +/** + * 让内核重新加载配置文件,不重启进程。 + * + * `force=true` 意味着连端口这类需要重开监听的改动也一并生效——正是本项目改入站时需要的。 + * 注意 `path` 是**内核所在机器上的路径**:部署时 Patchbay 和内核在同一台机器上所以没有问题, + * 但开发时如果 Patchbay 在本机、内核在远端,这个调用不会加载到本地的文件。 + */ +export async function reloadConfig(env: Env, configPath: string): Promise { + try { + const res = await callApi(env, '/configs?force=true', { + method: 'PUT', + body: JSON.stringify({ path: configPath }), + signal: AbortSignal.timeout(30_000), + }) + if (res.ok) return { ok: true, message: '' } + return { ok: false, message: (await res.text()) || `管理接口返回 ${String(res.status)}` } + } catch (error) { + return { ok: false, message: `连不上管理接口:${String(error)}` } + } +} + +/** 透传一个管理接口的 GET 请求,用于运行时数据(线路、连接、日志等)。 */ +export async function proxyGet(env: Env, path: string): Promise { + return callApi(env, path, { signal: AbortSignal.timeout(15_000) }) +}