diff --git a/server/routes.ts b/server/routes.ts index 4496682..5ace110 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -22,6 +22,14 @@ import type { Env } from './env.ts' */ const DELAY_PATH = /^\/(proxies|group)\/.+\/delay$/ +/** + * 订阅线路的健康检查。 + * + * 订阅线路不在内核的全局名字表里,所以 /proxies/{名字}/delay 对它们一律 404—— + * 要测只能从它所属的订阅进去。整条订阅测用 {订阅}/healthcheck,单条用 {订阅}/{线路}/healthcheck。 + */ +const HEALTHCHECK_PATH = /^\/providers\/proxies\/[^/]+(\/[^/]+)?\/healthcheck$/ + /** 允许透传的管理接口路径。白名单而不是黑名单——多开一个口子要有意为之。 */ const READABLE_KERNEL_PATHS = new Set([ '/version', @@ -124,7 +132,9 @@ export function createRouter(env: Env, token: string) { if (path.startsWith('/api/kernel/') && req.method === 'GET') { const target = path.slice('/api/kernel'.length) - if (!READABLE_KERNEL_PATHS.has(target) && !DELAY_PATH.test(target)) { + const allowed = + READABLE_KERNEL_PATHS.has(target) || DELAY_PATH.test(target) || HEALTHCHECK_PATH.test(target) + if (!allowed) { return json({ error: `不允许透传这个路径:${target}` }, 403) } const upstream = await proxyGet(env, target + url.search) diff --git a/web/App.vue b/web/App.vue index e9492e1..c3cd394 100644 --- a/web/App.vue +++ b/web/App.vue @@ -12,6 +12,8 @@ import IconUnfold from '~icons/ri/sidebar-unfold-line' const snapshot = ref(null) const kernel = ref<{ online: boolean; version?: string }>({ online: false }) const providerLines = ref([]) +/** 订阅线路 → 它所属的订阅名。测延迟时要用它选对接口 */ +const lineProvider = ref>({}) const runtime = ref>({}) const needsToken = ref(!getToken()) const loadError = ref('') @@ -36,7 +38,6 @@ async function load() { needsToken.value = false // 订阅里有哪些线路只有运行中的内核知道,配置文件里只有一个订阅地址。 // 拿不到不算错误——界面照常能编辑,只是订阅线路那一档不可选。 - providerLines.value = status.online ? await readProviderLines() : [] runtime.value = status.online ? await readRuntime() : {} } catch (error) { if (error instanceof ApiError && error.unauthorized) { @@ -50,25 +51,44 @@ async function load() { } } -/** 只取真正的订阅。内核会给每个用了 proxies 的策略组造一个内部 provider,那些不是订阅。 */ -async function readProviderLines(): Promise { +/** + * 出站的运行时状态:协议类型、策略组当前选中了谁、最近一次延迟。 + * + * 要读两处。/proxies 只包含内核全局名字表里的东西——内置出站、自有线路、策略组; + * 订阅线路不在那张表上,它们的类型和延迟只能从 /providers/proxies 拿。 + * 两边合成一张表,界面上就不用关心某个名字是从哪来的了。 + * + * 顺带记下每条订阅线路属于哪个订阅:测它的延迟必须从所属订阅进去。 + */ +async function readRuntime(): Promise> { + const merged: Record = {} + const lines: string[] = [] + const owner: Record = {} + + try { + Object.assign(merged, (await api.proxies()).proxies) + } catch { + // 拿不到就当没有,界面照常显示,只是没有延迟数字 + } + try { const { providers } = await api.providers() - return Object.values(providers) - .filter((p) => p.vehicleType !== 'Compatible') - .flatMap((p) => p.proxies.map((line) => line.name)) + for (const [name, provider] of Object.entries(providers)) { + // 内核会给每个用了 proxies 的策略组造一个内部 provider,那些不是订阅 + if (provider.vehicleType === 'Compatible') continue + for (const line of provider.proxies) { + merged[line.name] = line + lines.push(line.name) + owner[line.name] = name + } + } } catch { - return [] + // 同上 } -} -/** 出站的运行时状态:协议类型、策略组当前选中了谁、最近一次延迟。 */ -async function readRuntime(): Promise> { - try { - return (await api.proxies()).proxies - } catch { - return {} - } + providerLines.value = lines + lineProvider.value = owner + return merged } /** @@ -162,6 +182,7 @@ const current = computed(() => NAV.find((n) => n.key === active.value)) :revision="snapshot.revision" :kernel-online="kernel.online" :provider-lines="providerLines" + :line-provider="lineProvider" :runtime="runtime" @changed="load" @optimistic-select="optimisticSelect" diff --git a/web/api.ts b/web/api.ts index be87533..1d88ff2 100644 --- a/web/api.ts +++ b/web/api.ts @@ -76,6 +76,8 @@ export interface ApplyFailure { } export interface ProxyInfo { + /** 出站的名字。/proxies 用键名标识,provider 里的线路则带在对象上 */ + name: string /** 协议类型(Vmess、Vless…)或策略组类型(Selector、URLTest…) */ type: string /** 策略组当前选中的成员。不是策略组就没有这个字段 */ @@ -137,23 +139,28 @@ export const api = { name: member, }), - /** 测一条线路的延迟。返回的是这一次的结果,同时也会被记进内核的历史里。 */ - testLine: (name: string, url = TEST_URL, timeout = 5000) => - request<{ delay: number }>( - 'GET', - `/api/kernel/proxies/${encodeURIComponent(name)}/delay?url=${encodeURIComponent(url)}&timeout=${String(timeout)}`, - ), + /** + * 测一条出站的延迟。 + * + * 走哪个接口取决于它在不在内核的全局名字表里:策略组和自有线路在表里,用 /proxies; + * 订阅线路不在表里(那正是它们无法被入站直接引用的原因),只能从所属订阅进去测。 + */ + testLine: (name: string, provider?: string, timeout = 5000) => { + const q = `url=${encodeURIComponent(TEST_URL)}&timeout=${String(timeout)}` + const path = provider + ? `/api/kernel/providers/proxies/${encodeURIComponent(provider)}/${encodeURIComponent(name)}/healthcheck?${q}` + : `/api/kernel/proxies/${encodeURIComponent(name)}/delay?${q}` + return request<{ delay: number }>('GET', path) + }, - /** 测一整个策略组,内核会并发测所有成员。返回每个成员的延迟。 */ - testGroup: (group: string, url = TEST_URL, timeout = 5000) => - request>( - 'GET', - `/api/kernel/group/${encodeURIComponent(group)}/delay?url=${encodeURIComponent(url)}&timeout=${String(timeout)}`, - ), - - /** 订阅里当前有哪些线路。配置文件给不出这个答案,只能问运行中的内核。 */ + /** + * 订阅及其线路。配置文件里只有订阅地址,有哪些线路只有运行中的内核知道。 + * + * 返回的线路带完整的运行时信息(协议、延迟历史),因为这些线路不在 /proxies 那张 + * 全局名字表里,这是拿到它们类型和延迟的唯一途径。 + */ providers: () => - request<{ providers: Record }>( + request<{ providers: Record }>( 'GET', '/api/kernel/providers/proxies', ), diff --git a/web/views/RouterPage.vue b/web/views/RouterPage.vue index 91b68f1..258b6cf 100644 --- a/web/views/RouterPage.vue +++ b/web/views/RouterPage.vue @@ -20,6 +20,8 @@ const props = defineProps<{ revision: string kernelOnline: boolean providerLines: string[] + /** 订阅线路 → 所属订阅 */ + lineProvider: Record runtime: Record }>() @@ -182,6 +184,9 @@ function describeTestFailure(name: string, error: unknown): string { const detail = String(error.payload.message ?? '') const generic = detail === '' || detail === 'An error occurred in the delay test' + if (error.status === 404) { + return `${name} 不在内核的出站表里,无法测试。订阅线路需要从所属订阅进去测。` + } if (error.status === 503) { return generic ? `${name} 无法建立连接,线路可能已失效,或本机到该服务器的网络不通` @@ -195,7 +200,7 @@ async function testLine(name: string) { if (testing.value.includes(name)) return testing.value = [...testing.value, name] try { - await api.testLine(name, undefined, TEST_TIMEOUT_MS) + await api.testLine(name, props.lineProvider[name], TEST_TIMEOUT_MS) emit('changed') } catch (error) { // 测不通是关于线路的结论,不是 Patchbay 出了故障,所以不用错误框