From afe53b97906588c9f50ab995b29e1f312fe5dbce Mon Sep 17 00:00:00 2001 From: im0o <0ojixueseno0@gmail.com> Date: Mon, 10 Aug 2026 19:45:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E7=AB=AF=E8=A1=A5=E4=B8=8A=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E9=80=8F=E4=BC=A0=E5=92=8C=E4=B8=A4=E4=B8=AA=E5=86=99?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=EF=BC=8C=E7=BB=99=E5=90=8E=E9=9D=A2=E5=87=A0?= =?UTF-8?q?=E4=B8=AA=E9=A1=B5=E9=9D=A2=E9=93=BA=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原以为流量和日志要写 WebSocket 转发,实测不用:内核在没有 WebSocket 升级时把 traffic、memory、logs 退化成分块的 HTTP 流,一行一个 JSON(server.go:380-383)。 现有的透传本来就能流式传,唯一的问题是那个 15 秒超时会每隔一会儿掐断一次, 给这三个路径去掉超时即可。 写操作照旧是白名单,逐条列出:断开连接(DELETE /connections 和 /connections/{id})、 刷新订阅(PUT /providers/proxies/{名字})。两者都只改内核运行状态、不碰配置文件, 所以不走落盘那条保护链路。方法和路径都要匹配才放行,测试里有反例。 --- server/kernel.ts | 21 ++++++++++++++++++--- server/routes.test.ts | 29 +++++++++++++++++++++++++++++ server/routes.ts | 34 ++++++++++++++++++++++++++++++++-- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/server/kernel.ts b/server/kernel.ts index 4713c3e..43b11e4 100644 --- a/server/kernel.ts +++ b/server/kernel.ts @@ -119,7 +119,22 @@ export async function selectProxy( } } -/** 透传一个管理接口的 GET 请求,用于运行时数据(线路、连接、日志等)。 */ -export async function proxyGet(env: Env, path: string): Promise { - return callApi(env, path, { signal: AbortSignal.timeout(15_000) }) +/** + * 透传一个管理接口的 GET 请求。 + * + * `stream` 为真时不设超时:流量、内存、日志这三个接口不升级 WebSocket 时会退化成 + * 持续吐 JSON 的分块流(内核源码 hub/route/server.go:380-383),给它们设超时等于 + * 每隔一会儿就把连接掐断一次。 + */ +export async function proxyGet(env: Env, path: string, stream = false): Promise { + return callApi(env, path, stream ? {} : { signal: AbortSignal.timeout(15_000) }) +} + +/** 透传一个写操作。目前只有断开连接和刷新订阅两处。 */ +export async function proxyWrite( + env: Env, + method: 'PUT' | 'DELETE', + path: string, +): Promise { + return callApi(env, path, { method, signal: AbortSignal.timeout(30_000) }) } diff --git a/server/routes.test.ts b/server/routes.test.ts index baf988d..09609b3 100644 --- a/server/routes.test.ts +++ b/server/routes.test.ts @@ -81,6 +81,35 @@ describe('内核接口的透传白名单', () => { }) }) +describe('写操作的白名单', () => { + const write = (method: string, path: string) => + handle( + new Request(`http://127.0.0.1:7580/api/kernel${path}`, { + method, + headers: { Authorization: `Bearer ${TOKEN}` }, + }), + ) + + it('放行断开连接和刷新订阅', async () => { + expect((await write('DELETE', '/connections')).status).not.toBe(403) + expect((await write('DELETE', '/connections/abc-123')).status).not.toBe(403) + expect((await write('PUT', '/providers/proxies/subs')).status).not.toBe(403) + }) + + it('挡住没列出来的写操作', async () => { + // 重启内核、切换配置这类动作不该从这里走 + expect((await write('PUT', '/configs')).status).toBe(403) + expect((await write('DELETE', '/cache/fakeip/flush')).status).toBe(403) + expect((await write('PUT', '/providers/proxies/subs/healthcheck')).status).toBe(403) + }) + + it('方法对不上也挡住', async () => { + // 断开连接是 DELETE,刷新订阅是 PUT,反过来都不行 + expect((await write('PUT', '/connections')).status).toBe(403) + expect((await write('DELETE', '/providers/proxies/subs')).status).toBe(403) + }) +}) + describe('其它', () => { it('不存在的接口回 404 并带上说明', async () => { const res = await get('/api/nope') diff --git a/server/routes.ts b/server/routes.ts index 01b769c..ca74bdd 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -11,7 +11,7 @@ */ import listenerFields from '../shared/listener-fields.json' with { type: 'json' } import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts' -import { kernelVersion, proxyGet, selectProxy } from './kernel.ts' +import { kernelVersion, proxyGet, proxyWrite, selectProxy } from './kernel.ts' import { tokenFromRequest, tokenMatches } from './auth.ts' import type { ConfigObject } from '../vendor/config-editor/index.ts' import type { Env } from './env.ts' @@ -30,6 +30,25 @@ const DELAY_PATH = /^\/(proxies|group)\/.+\/delay$/ */ const HEALTHCHECK_PATH = /^\/providers\/proxies\/[^/]+(\/[^/]+)?\/healthcheck$/ +/** + * 会持续吐数据的接口。透传它们时不能设超时。 + * 内核在没有 WebSocket 升级时把这三个退化成分块的 HTTP 流,一行一个 JSON。 + */ +const STREAM_PATHS = new Set(['/traffic', '/memory', '/logs']) + +/** + * 允许透传的写操作。 + * + * 每一条都是「只改内核运行状态、不碰配置文件」的动作,所以不走落盘那条保护链路。 + * 仍然是白名单:能写的东西必须一条条列出来。 + */ +const WRITABLE: Array<{ method: 'PUT' | 'DELETE'; pattern: RegExp }> = [ + // 断开某条连接,或者全部断开 + { method: 'DELETE', pattern: /^\/connections(\/[^/]+)?$/ }, + // 让内核重新拉取某个订阅 + { method: 'PUT', pattern: /^\/providers\/proxies\/[^/]+$/ }, +] + /** 允许透传的管理接口路径。白名单而不是黑名单——多开一个口子要有意为之。 */ const READABLE_KERNEL_PATHS = new Set([ '/version', @@ -175,6 +194,17 @@ function makeRoute(env: Env, token: string) { : json({ message: upstream.message || '内核拒绝了这次切换' }, 422) } + if (path.startsWith('/api/kernel/') && (req.method === 'PUT' || req.method === 'DELETE')) { + const target = path.slice('/api/kernel'.length) + const rule = WRITABLE.find((w) => w.method === req.method && w.pattern.test(target)) + if (!rule) return json({ message: `不允许写入该路径:${target}` }, 403) + const upstream = await proxyWrite(env, rule.method, target + url.search) + return new Response(upstream.body, { + status: upstream.status, + headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' }, + }) + } + if (path.startsWith('/api/kernel/') && req.method === 'GET') { const target = path.slice('/api/kernel'.length) const allowed = @@ -182,7 +212,7 @@ function makeRoute(env: Env, token: string) { if (!allowed) { return json({ message: `不允许访问该路径:${target}` }, 403) } - const upstream = await proxyGet(env, target + url.search) + const upstream = await proxyGet(env, target + url.search, STREAM_PATHS.has(target)) return new Response(upstream.body, { status: upstream.status, headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' },