diff --git a/server/config-store.test.ts b/server/config-store.test.ts index 699ea75..ff6ceec 100644 --- a/server/config-store.test.ts +++ b/server/config-store.test.ts @@ -82,8 +82,13 @@ describe('落盘保护链路(内核在线)', () => { it('内容没变直接返回,不备份也不重载', async () => { const env = makeEnv() + // 先落一次盘让文件带上 Patchbay 标记——没有标记的文件第一次保存必有 + // 「补标记」这一处内容变化,短路只发生在稳态 + const first = await readConfig(env) + await applyConfig(env, first.data, first.revision) const { data, revision } = await readConfig(env) const before = reloadCount + const backupsBefore = (await listBackups(env)).length const result = await applyConfig(env, data, revision) @@ -93,7 +98,7 @@ describe('落盘保护链路(内核在线)', () => { expect(result.reloadMessage).toBe('内容没有变化') } expect(reloadCount).toBe(before) - expect(await listBackups(env)).toHaveLength(0) + expect((await listBackups(env)).length).toBe(backupsBefore) }) it('自检拦下断引用:入站的 proxy 指向不存在的出站', async () => { @@ -137,7 +142,11 @@ describe('落盘保护链路(内核在线)', () => { const result = await restoreBackup(env, backup!) expect(result.ok).toBe(true) - expect(readFileSync(env.configPath, 'utf8')).toBe(ORIGINAL) + // 还原也走落盘链路,所以会重新打上 Patchbay 标记——内容回去了,但不逐字节等于旧文件 + const body = readFileSync(env.configPath, 'utf8') + expect(body).toContain('mixed-port: 7890') + expect(body).not.toContain('7891') + expect(body).toContain('patchbay:') }) it('恢复只认列表里的名字,路径穿越进不来', async () => { diff --git a/server/config-store.ts b/server/config-store.ts index 528a0e9..d4bcc17 100644 --- a/server/config-store.ts +++ b/server/config-store.ts @@ -30,6 +30,7 @@ import { } 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' @@ -133,7 +134,10 @@ export async function applyConfig( return `${stage} ${String(ms)}ms` } - const nextYaml = rewriteYaml(current.yaml, nextData) + // 每次落盘都打上 Patchbay 标记(顶层 patchbay: 键,内核忽略未知键)。 + // 上传配置时靠它认「这是 Patchbay 管理过的配置」。标记是幂等的, + // 已带标记的配置重打一遍内容不变,不影响下面的「内容没变就短路」 + const nextYaml = rewriteYaml(current.yaml, withMarker(nextData)) // 内容没变就不要走后面那一串——不必要的备份和重载只会制造噪音。 if (nextYaml === current.yaml) { @@ -282,6 +286,39 @@ async function pruneBackups(env: Env): Promise { await Promise.all(stale.map((name) => unlink(join(dir, name)).catch(() => {}))) } +/** + * 用上传的 YAML 原文整份替换当前配置。 + * + * 解析用和读配置同一个引擎,之后走 applyConfig 完整的保护链路(自检、备份、 + * 重载、失败回退)。没有 baseRevision——整份替换本来就是覆盖语义,同还原备份。 + */ +export async function importConfig(env: Env, content: string): Promise { + 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 { const dir = await backupDir(env) diff --git a/server/kernel.ts b/server/kernel.ts index 54168cb..d6f2c87 100644 --- a/server/kernel.ts +++ b/server/kernel.ts @@ -160,6 +160,23 @@ export async function restartKernel(env: Env): Promise { } } +/** + * 让内核更新 GEO 数据库(POST /configs/geo,内核源码 hub/route/configs.go:30,442-452)。 + * 同步接口,下载完才回话,超时要给足——GEO 文件几十兆、走的还是外网。 + */ +export async function updateGeoDatabases(env: Env): Promise { + try { + const res = await callApi(env, '/configs/geo', { + method: 'POST', + signal: AbortSignal.timeout(300_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 请求。 * diff --git a/server/routes.ts b/server/routes.ts index 25294bd..6656029 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -12,9 +12,16 @@ import { resolve, sep } from 'node:path' import listenerFields from '../shared/listener-fields.json' with { type: 'json' } import { isProviderFilePath } from '../shared/provider-files.ts' -import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts' +import { applyConfig, importConfig, listBackups, readConfig, restoreBackup } from './config-store.ts' import { hostInfo, probeExit } from './host.ts' -import { kernelVersion, proxyGet, proxyWrite, restartKernel, selectProxy } from './kernel.ts' +import { + kernelVersion, + proxyGet, + proxyWrite, + restartKernel, + selectProxy, + updateGeoDatabases, +} from './kernel.ts' import { tokenFromRequest, tokenMatches } from './auth.ts' import type { ConfigObject } from '../vendor/config-editor/index.ts' import type { Env } from './env.ts' @@ -176,6 +183,27 @@ function makeRoute(env: Env, token: string) { return json({ backups: await listBackups(env) }) } + // 配置文件原文,给「下载当前配置」用。带 attachment 让浏览器按下载处理 + if (path === '/api/config/raw' && req.method === 'GET') { + const file = Bun.file(env.configPath) + return new Response(file, { + headers: { + 'Content-Type': 'text/yaml; charset=utf-8', + 'Content-Disposition': 'attachment; filename="config.yaml"', + }, + }) + } + + // 上传的 YAML 整份替换当前配置。走 config-store 完整的保护链路 + if (path === '/api/config/import' && req.method === 'POST') { + const body = (await req.json()) as { content?: string } + if (typeof body.content !== 'string' || body.content.trim() === '') { + return json({ message: '请求缺少 content 字段' }, 400) + } + const result = await importConfig(env, body.content) + return result.ok ? json(result) : json(result, applyFailureStatus(result.stage)) + } + const restoreMatch = /^\/api\/config\/backups\/([^/]+)\/restore$/.exec(path) if (restoreMatch && req.method === 'POST') { const name = decodeURIComponent(restoreMatch[1]!) @@ -236,6 +264,14 @@ function makeRoute(env: Env, token: string) { : json({ message: upstream.message || '内核拒绝了这次切换' }, 422) } + // 更新 GEO 数据库。运行时动作;同步接口,透传层给了长超时 + if (path === '/api/kernel/geo' && req.method === 'POST') { + const upstream = await updateGeoDatabases(env) + return upstream.ok + ? json({ ok: true }) + : json({ message: upstream.message || '内核更新 GEO 数据库失败' }, 502) + } + // 重启内核进程。运行时动作,不碰配置文件;和其它写操作一样单独点名,不开通用口子 if (path === '/api/kernel/restart' && req.method === 'POST') { const upstream = await restartKernel(env) diff --git a/shared/config-marker.test.ts b/shared/config-marker.test.ts new file mode 100644 index 0000000..6d0ede8 --- /dev/null +++ b/shared/config-marker.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test' +import { MARKER_KEY, hasValidMarker, withMarker } from './config-marker.ts' +import type { ConfigObject } from '../vendor/config-editor/index.ts' + +describe('配置标记', () => { + test('打上标记后校验通过,其余键不动', () => { + const data: ConfigObject = { 'mixed-port': 7890 } + const marked = withMarker(data) + expect(hasValidMarker(marked)).toBe(true) + expect(marked['mixed-port']).toBe(7890) + }) + + test('没有标记、标记形状不对、sig 对不上都算无效', () => { + expect(hasValidMarker({})).toBe(false) + expect(hasValidMarker({ [MARKER_KEY]: 'yes' })).toBe(false) + expect(hasValidMarker({ [MARKER_KEY]: { v: 1, sig: 'deadbeef' } })).toBe(false) + }) + + test('打两次标记结果一样(落盘链路的内容不变检查靠它短路)', () => { + const once = withMarker({}) + expect(withMarker(once)).toEqual(once) + }) +}) diff --git a/shared/config-marker.ts b/shared/config-marker.ts new file mode 100644 index 0000000..c5816ad --- /dev/null +++ b/shared/config-marker.ts @@ -0,0 +1,44 @@ +/** + * 配置文件里的 Patchbay 标记。 + * + * 每次落盘都在顶层写一个 `patchbay:` 键(内核对未知顶层键不报错:解析用的 + * `yaml.Unmarshal` 没开严格模式,config/config.go:612)。上传配置文件时靠它 + * 区分「Patchbay 管理过的配置」和随便一份 mihomo 配置——前者直接还原, + * 后者提示走导入。 + * + * sig 是盐加版本号的哈希,回答的是「这个标记是不是 Patchbay 写的」,不是防伪 + * ——在用户自己的机器上没有对手。刻意不对配置内容做哈希:这个项目只接管部分 + * 段落,手改其余段落是正常用法(决策记录 0003),内容哈希会把合法的手改 + * 判成无效。 + */ +import type { ConfigObject } from '../vendor/config-editor/index.ts' + +export const MARKER_KEY = 'patchbay' + +const VERSION = 1 +const SALT = 'patchbay-config-salt' + +/** FNV-1a。只做标记指纹,不是安全用途。 */ +function fnv(text: string): string { + let h = 0x811c9dc5 + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i) + h = Math.imul(h, 0x01000193) + } + return (h >>> 0).toString(16).padStart(8, '0') +} + +const sigOf = (version: number): string => fnv(`${SALT}:${String(version)}`) + +/** 给一份配置打上标记。已有的旧标记会被换成当前版本的。 */ +export function withMarker(data: ConfigObject): ConfigObject { + return { ...data, [MARKER_KEY]: { v: VERSION, sig: sigOf(VERSION) } } +} + +/** 这份配置带不带有效的 Patchbay 标记。 */ +export function hasValidMarker(data: ConfigObject): boolean { + const marker = data[MARKER_KEY] + if (typeof marker !== 'object' || marker === null || Array.isArray(marker)) return false + const { v, sig } = marker as Record + return typeof v === 'number' && sig === sigOf(v) +} diff --git a/web/App.vue b/web/App.vue index f3307dd..4bdd093 100644 --- a/web/App.vue +++ b/web/App.vue @@ -17,6 +17,7 @@ import SettingsPage from './views/SettingsPage.vue' import TokenGate from './views/TokenGate.vue' import IconFold from '~icons/ri/sidebar-fold-line' import IconUnfold from '~icons/ri/sidebar-unfold-line' +import IconLogout from '~icons/ri/logout-box-r-line' const snapshot = ref(null) const needsToken = ref(!getToken()) @@ -70,6 +71,11 @@ function submitToken(value: string) { void load() } +function logout() { + clearToken() + location.reload() +} + onMounted(() => { if (!needsToken.value) void load() // 流量、每路吞吐和日志的历史跨页面留存,所以收集挂在外壳上而不是各页里。 @@ -126,6 +132,11 @@ const current = computed(() => NAV.find((n) => n.key === active.value)) + +
@@ -195,7 +206,12 @@ const current = computed(() => NAV.find((n) => n.key === active.value)) - +
@@ -358,6 +374,11 @@ const current = computed(() => NAV.find((n) => n.key === active.value)) height: 18px; } +/* 退出登录:形态同导航项,但不参与「当前页」的高亮,收拢时同样缩成方块 */ +.off { + color: var(--c-text-faint); +} + .side-foot { display: flex; align-items: center; diff --git a/web/api.ts b/web/api.ts index 42d9a52..c0785aa 100644 --- a/web/api.ts +++ b/web/api.ts @@ -7,12 +7,10 @@ import type { ConfigDiagnostic, ConfigObject } from '../vendor/config-editor/index.ts' import type { ConnectionSnapshot } from '@shared/connections.ts' import type { ExitProbe, HostInfo } from '@shared/host-info.ts' +import { DEFAULT_TEST_URL, testUrl } from './prefs' const TOKEN_KEY = 'patchbay.token' -/** 测延迟用的目标。204 空响应,体积最小,全球都有部署。 */ -const TEST_URL = 'https://www.gstatic.com/generate_204' - export function getToken(): string { return localStorage.getItem(TOKEN_KEY) ?? '' } @@ -144,6 +142,26 @@ export const api = { listBackups: () => request<{ backups: string[] }>('GET', '/api/config/backups'), + /** 配置文件的原文,给「下载当前配置」用。 */ + configRaw: async (): Promise => { + const res = await fetch('/api/config/raw', { + headers: { Authorization: `Bearer ${getToken()}` }, + }) + if (!res.ok) { + const text = await res.text() + const payload = text ? (JSON.parse(text) as Record) : {} + throw new ApiError(res.status, payload) + } + return res.text() + }, + + /** + * 用上传的 YAML 原文整份替换当前配置。走服务端完整的保护链路 + * (校验、备份、重载、失败回退),没有 baseRevision——整份替换本来就是覆盖语义。 + */ + importConfig: (content: string) => + request('POST', '/api/config/import', { content }), + restoreBackup: (name: string) => request('POST', `/api/config/backups/${encodeURIComponent(name)}/restore`), @@ -170,6 +188,12 @@ export const api = { */ restartKernel: () => request<{ ok: true }>('POST', '/api/kernel/restart'), + /** + * 让内核更新 GEO 数据库(GEOIP、GEOSITE、MMDB、ASN)。 + * 同步接口,下载完才回话,可能要等上几十秒。 + */ + updateGeo: () => request<{ ok: true }>('POST', '/api/kernel/geo'), + /** * 打开一个持续吐数据的流式接口(/traffic、/memory),每秒一行 JSON。 * @@ -222,7 +246,9 @@ export const api = { * 订阅线路不在表里(那正是它们无法被入站直接引用的原因),只能从所属订阅进去测。 */ testLine: (name: string, provider?: string, timeout = 5000) => { - const q = `url=${encodeURIComponent(TEST_URL)}&timeout=${String(timeout)}` + // 测速地址是设置页里的偏好,空值退回默认 + const target = testUrl.value.trim() || DEFAULT_TEST_URL + const q = `url=${encodeURIComponent(target)}&timeout=${String(timeout)}` const path = provider ? `/api/kernel/providers/proxies/${encodeURIComponent(provider)}/${encodeURIComponent(name)}/healthcheck?${q}` : `/api/kernel/proxies/${encodeURIComponent(name)}/delay?${q}` diff --git a/web/legal.ts b/web/legal.ts new file mode 100644 index 0000000..6d9eb69 --- /dev/null +++ b/web/legal.ts @@ -0,0 +1,11 @@ +/** + * 免责声明的文案。设置页的子页和登录页的弹层共用同一份,不出现两个版本。 + */ +export const DISCLAIMER_TITLE = '免责声明' + +export const DISCLAIMER = [ + 'Patchbay 是编辑 mihomo 配置文件的本地网页工具,按现状提供,不提供任何形式的担保。因配置错误、软件缺陷或网络中断造成的服务不可用、数据丢失,作者不承担责任;重要改动前请自行留存配置文件副本。', + '代理技术本身是中性的。使用者应遵守所在国家和地区的法律法规,因使用本工具产生的一切后果由使用者自行承担。', + '配置文件与备份中可能包含密钥、订阅地址等敏感信息,访问令牌等同于密码,请妥善保管,不要在公开场合分享。', + '继续使用 Patchbay 即视为已阅读并同意以上条款。', +] diff --git a/web/prefs.ts b/web/prefs.ts index 918ddaa..ad676b5 100644 --- a/web/prefs.ts +++ b/web/prefs.ts @@ -3,32 +3,50 @@ * * 密度:工具栏和导航是只显示图标,还是图标加文字。 * 默认是「完整」——这个工具是隔一阵子才动一次的,纯图标对偶尔来一次的人不友好; - * 想要紧凑的人自己去设置里切。 + * 想收拢的人用侧栏底部的按钮切。 * * 主题:跟随系统、浅色、深色。默认跟随系统。 + * + * localStorage 和 document 都加了存在性守卫:api.ts 引用这里(测速地址), + * 而 api.ts 被 bun test 环境里跑的模块间接引用——那里没有浏览器全局, + * 模块顶层直接碰它们会把整个测试文件炸掉。 */ import { ref, watch } from 'vue' +const storage = typeof localStorage === 'undefined' ? null : localStorage + export type Density = 'full' | 'compact' const KEY = 'patchbay.density' -const stored = localStorage.getItem(KEY) +const stored = storage?.getItem(KEY) export const density = ref(stored === 'compact' ? 'compact' : 'full') watch(density, (value) => { - localStorage.setItem(KEY, value) + storage?.setItem(KEY, value) }) export function toggleDensity(): void { density.value = density.value === 'full' ? 'compact' : 'full' } +/** 测延迟用的目标地址。204 空响应的端点最合适,默认值全球都有部署。 */ +export const DEFAULT_TEST_URL = 'https://www.gstatic.com/generate_204' + +const TEST_URL_KEY = 'patchbay.test-url' + +export const testUrl = ref(storage?.getItem(TEST_URL_KEY) || DEFAULT_TEST_URL) + +watch(testUrl, (value) => { + if (value.trim() === '' || value === DEFAULT_TEST_URL) storage?.removeItem(TEST_URL_KEY) + else storage?.setItem(TEST_URL_KEY, value.trim()) +}) + export type Theme = 'system' | 'light' | 'dark' const THEME_KEY = 'patchbay.theme' -const storedTheme = localStorage.getItem(THEME_KEY) +const storedTheme = storage?.getItem(THEME_KEY) export const theme = ref( storedTheme === 'light' || storedTheme === 'dark' ? storedTheme : 'system', ) @@ -39,7 +57,8 @@ export const theme = ref( watch( theme, (value) => { - localStorage.setItem(THEME_KEY, value) + storage?.setItem(THEME_KEY, value) + if (typeof document === 'undefined') return if (value === 'system') document.documentElement.removeAttribute('data-theme') else document.documentElement.setAttribute('data-theme', value) }, diff --git a/web/views/SettingsPage.vue b/web/views/SettingsPage.vue index 061e1ef..072b920 100644 --- a/web/views/SettingsPage.vue +++ b/web/views/SettingsPage.vue @@ -1,27 +1,37 @@ diff --git a/web/views/TokenGate.vue b/web/views/TokenGate.vue index a13121b..4d96017 100644 --- a/web/views/TokenGate.vue +++ b/web/views/TokenGate.vue @@ -1,8 +1,23 @@ @@ -85,4 +119,75 @@ h1 { line-height: 1.6; margin-top: var(--s-2); } + +.legal { + align-self: flex-start; + padding: 0; + border: none; + background: none; + color: var(--c-text-faint); + font-size: var(--t-xs); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 3px; +} +.legal:hover { + color: var(--c-text-soft); +} + +.dlg { + width: min(480px, calc(100vw - 2 * var(--s-5))); + padding: 0; + border: 1px solid var(--c-border-strong); + border-radius: var(--r-lg); + background: var(--c-surface); + color: var(--c-text); +} + +.dlg::backdrop { + background: rgb(0 0 0 / 0.45); +} + +.dlg-body { + padding: var(--s-5); +} + +.dlg-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--s-3); +} + +.dlg-head h2 { + font-size: var(--t-lg); + font-weight: 600; +} + +.x { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid transparent; + border-radius: var(--r-sm); + background: none; + color: var(--c-text-faint); + cursor: pointer; +} +.x:hover { + background: var(--c-surface-sunken); + color: var(--c-text); +} + +.para { + color: var(--c-text-soft); + font-size: var(--t-sm); + line-height: var(--line); +} + +.para + .para { + margin-top: var(--s-3); +}