后端补上流式透传和两个写操作,给后面几个页面铺路
原以为流量和日志要写 WebSocket 转发,实测不用:内核在没有 WebSocket 升级时把
traffic、memory、logs 退化成分块的 HTTP 流,一行一个 JSON(server.go:380-383)。
现有的透传本来就能流式传,唯一的问题是那个 15 秒超时会每隔一会儿掐断一次,
给这三个路径去掉超时即可。
写操作照旧是白名单,逐条列出:断开连接(DELETE /connections 和 /connections/{id})、
刷新订阅(PUT /providers/proxies/{名字})。两者都只改内核运行状态、不碰配置文件,
所以不走落盘那条保护链路。方法和路径都要匹配才放行,测试里有反例。
This commit is contained in:
+18
-3
@@ -119,7 +119,22 @@ export async function selectProxy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 透传一个管理接口的 GET 请求,用于运行时数据(线路、连接、日志等)。 */
|
/**
|
||||||
export async function proxyGet(env: Env, path: string): Promise<Response> {
|
* 透传一个管理接口的 GET 请求。
|
||||||
return callApi(env, path, { signal: AbortSignal.timeout(15_000) })
|
*
|
||||||
|
* `stream` 为真时不设超时:流量、内存、日志这三个接口不升级 WebSocket 时会退化成
|
||||||
|
* 持续吐 JSON 的分块流(内核源码 hub/route/server.go:380-383),给它们设超时等于
|
||||||
|
* 每隔一会儿就把连接掐断一次。
|
||||||
|
*/
|
||||||
|
export async function proxyGet(env: Env, path: string, stream = false): Promise<Response> {
|
||||||
|
return callApi(env, path, stream ? {} : { signal: AbortSignal.timeout(15_000) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 透传一个写操作。目前只有断开连接和刷新订阅两处。 */
|
||||||
|
export async function proxyWrite(
|
||||||
|
env: Env,
|
||||||
|
method: 'PUT' | 'DELETE',
|
||||||
|
path: string,
|
||||||
|
): Promise<Response> {
|
||||||
|
return callApi(env, path, { method, signal: AbortSignal.timeout(30_000) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('其它', () => {
|
describe('其它', () => {
|
||||||
it('不存在的接口回 404 并带上说明', async () => {
|
it('不存在的接口回 404 并带上说明', async () => {
|
||||||
const res = await get('/api/nope')
|
const res = await get('/api/nope')
|
||||||
|
|||||||
+32
-2
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
import listenerFields from '../shared/listener-fields.json' with { type: 'json' }
|
import listenerFields from '../shared/listener-fields.json' with { type: 'json' }
|
||||||
import { applyConfig, listBackups, readConfig, restoreBackup } from './config-store.ts'
|
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 { tokenFromRequest, tokenMatches } from './auth.ts'
|
||||||
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
import type { ConfigObject } from '../vendor/config-editor/index.ts'
|
||||||
import type { Env } from './env.ts'
|
import type { Env } from './env.ts'
|
||||||
@@ -30,6 +30,25 @@ const DELAY_PATH = /^\/(proxies|group)\/.+\/delay$/
|
|||||||
*/
|
*/
|
||||||
const HEALTHCHECK_PATH = /^\/providers\/proxies\/[^/]+(\/[^/]+)?\/healthcheck$/
|
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([
|
const READABLE_KERNEL_PATHS = new Set([
|
||||||
'/version',
|
'/version',
|
||||||
@@ -175,6 +194,17 @@ function makeRoute(env: Env, token: string) {
|
|||||||
: json({ message: upstream.message || '内核拒绝了这次切换' }, 422)
|
: 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') {
|
if (path.startsWith('/api/kernel/') && req.method === 'GET') {
|
||||||
const target = path.slice('/api/kernel'.length)
|
const target = path.slice('/api/kernel'.length)
|
||||||
const allowed =
|
const allowed =
|
||||||
@@ -182,7 +212,7 @@ function makeRoute(env: Env, token: string) {
|
|||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
return json({ message: `不允许访问该路径:${target}` }, 403)
|
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, {
|
return new Response(upstream.body, {
|
||||||
status: upstream.status,
|
status: upstream.status,
|
||||||
headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' },
|
headers: { 'Content-Type': upstream.headers.get('Content-Type') ?? 'application/json' },
|
||||||
|
|||||||
Reference in New Issue
Block a user