设置页重做:配置文件下载上传、内核参数、资源、免责声明

配置每次落盘都打顶层 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。

退出登录挪到侧栏底部;密度设置从设置页撤下(侧栏按钮还在);
免责声明和关于做成子页(左上角返回),登录页输入框下加同一份
免责声明的弹层入口。
This commit is contained in:
2026-08-12 01:01:33 +08:00
parent 013f537eaa
commit 8eb98e2e9a
12 changed files with 792 additions and 147 deletions
+11 -2
View File
@@ -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 () => {
+38 -1
View File
@@ -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<void> {
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)
+17
View File
@@ -160,6 +160,23 @@ export async function restartKernel(env: Env): Promise<ValidateResult> {
}
}
/**
* 让内核更新 GEO 数据库(POST /configs/geo,内核源码 hub/route/configs.go:30,442-452)。
* 同步接口,下载完才回话,超时要给足——GEO 文件几十兆、走的还是外网。
*/
export async function updateGeoDatabases(env: Env): Promise<ValidateResult> {
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 请求。
*
+38 -2
View File
@@ -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)