From 0062a261f760c042e8fa462cf93ebdc4fdf73299 Mon Sep 17 00:00:00 2001 From: im0o <0ojixueseno0@gmail.com> Date: Mon, 10 Aug 2026 12:44:31 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E4=B8=8A=E7=9C=9F=E6=AD=A3=E6=97=A5?= =?UTF-8?q?=E5=B8=B8=E8=A6=81=E7=82=B9=E7=9A=84=E9=82=A3=E4=B8=AA=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=EF=BC=9A=E5=88=87=E6=8D=A2=E7=AD=96=E7=95=A5=E7=BB=84?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E9=80=89=E4=B8=AD=E8=B0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前的面板只做了「这个入站指向哪个出站」,那是改配置文件。但 metacubexd 里点一条线路 改的根本不是入站,是策略组的当前选中——调 PUT /proxies/{组},不写文件、立刻生效。 mixed-port 走规则表,末尾 MATCH,PROXY 把流量交给 PROXY 组,所以换那个组的选中就等于 换了这个端口的出口。这是每天都要点的动作,之前整个漏了。 面板顶部现在显示完整链条(PROXY → AUTO → 某条线路),并让人在最后一个策略组上换选中。 只显示第一层等于没回答「这个端口现在走哪条线」。 透传的白名单里为此开了唯一一个写操作,单独一条路由而不是放开整个转发。 --- server/kernel.ts | 24 ++++++++++ server/routes.ts | 15 +++++- shared/inbounds.test.ts | 41 ++++++++++++++++- shared/inbounds.ts | 31 +++++++++++++ web/api.ts | 9 ++++ web/views/InboundDetail.vue | 91 ++++++++++++++++++++++++++++++++++++- web/views/RouterPage.vue | 27 ++++++++++- 7 files changed, 234 insertions(+), 4 deletions(-) diff --git a/server/kernel.ts b/server/kernel.ts index d00d4ba..4713c3e 100644 --- a/server/kernel.ts +++ b/server/kernel.ts @@ -95,6 +95,30 @@ export async function reloadConfig(env: Env, configPath: string): Promise { + try { + const res = await callApi(env, `/proxies/${encodeURIComponent(group)}`, { + method: 'PUT', + body: JSON.stringify({ name: member }), + signal: AbortSignal.timeout(10_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 请求,用于运行时数据(线路、连接、日志等)。 */ export async function proxyGet(env: Env, path: string): Promise { return callApi(env, path, { signal: AbortSignal.timeout(15_000) }) diff --git a/server/routes.ts b/server/routes.ts index 425037d..778891c 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 } from './kernel.ts' +import { kernelVersion, proxyGet, 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' @@ -103,6 +103,19 @@ export function createRouter(env: Env, token: string) { return json(await kernelVersion(env)) } + // 切换某个策略组当前选中哪个成员。这是白名单里唯一的写操作,单独开而不是放开整个透传: + // 它只改内核的运行时状态,不碰配置文件,而且是这个工具最日常的动作。 + const selectMatch = /^\/api\/kernel\/proxies\/([^/]+)$/.exec(path) + if (selectMatch && req.method === 'PUT') { + const group = decodeURIComponent(selectMatch[1]!) + const body = (await req.json()) as { name?: string } + if (!body.name) return json({ error: '请求里没有 name 字段' }, 400) + const upstream = await selectProxy(env, group, body.name) + return upstream.ok + ? json({ ok: true }) + : json({ error: upstream.message || `内核拒绝了这次切换` }, 422) + } + if (path.startsWith('/api/kernel/') && req.method === 'GET') { const target = path.slice('/api/kernel'.length) if (!READABLE_KERNEL_PATHS.has(target)) { diff --git a/shared/inbounds.test.ts b/shared/inbounds.test.ts index f90a1f9..626e549 100644 --- a/shared/inbounds.test.ts +++ b/shared/inbounds.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from 'bun:test' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { openDocument } from '../vendor/config-editor/index.ts' -import { collectInbounds, collectOutboundNames, outboundToProxyField } from './inbounds.ts' +import { + collectInbounds, + collectOutboundNames, + fallbackTarget, + outboundToProxyField, + resolveDestination, +} from './inbounds.ts' const fixture = openDocument( readFileSync(join(import.meta.dir, '__fixtures__', 'config-with-comments.yaml'), 'utf8'), @@ -106,3 +112,36 @@ describe('能被入站引用的出站名字', () => { expect([...names.groups, ...names.ownProxies, ...names.builtins]).not.toContain('subs') }) }) + +describe('流量最终去哪', () => { + const cfg = { + 'mixed-port': 7777, + rules: ['IP-CIDR,127.0.0.0/8,DIRECT,no-resolve', 'MATCH,PROXY'], + listeners: [ + { name: '固定', type: 'mixed', port: 7778, proxy: 'AUTO' }, + { name: '直连', type: 'mixed', port: 7779, proxy: 'DIRECT' }, + ], + } + + it('走规则的入站要继续查到规则表末尾的 MATCH', () => { + // 用户想知道的是流量去哪,不是 proxy 字段写没写 + const legacy = collectInbounds(cfg)[0]! + expect(legacy.outbound).toEqual({ kind: 'rules' }) + expect(resolveDestination(cfg, legacy)).toBe('PROXY') + }) + + it('指定了出站的入站就是那个出站', () => { + const list = collectInbounds(cfg) + expect(resolveDestination(cfg, list[1]!)).toBe('AUTO') + expect(resolveDestination(cfg, list[2]!)).toBe('DIRECT') + }) + + it('MATCH 之后的规则到不了,取第一条', () => { + expect(fallbackTarget({ rules: ['MATCH,A', 'MATCH,B'] })).toBe('A') + }) + + it('没有 MATCH 时没有兜底', () => { + expect(fallbackTarget({ rules: ['MATCH-NOT-REALLY,X'] })).toBeNull() + expect(fallbackTarget({})).toBeNull() + }) +}) diff --git a/shared/inbounds.ts b/shared/inbounds.ts index e47122a..8f3f7c4 100644 --- a/shared/inbounds.ts +++ b/shared/inbounds.ts @@ -138,6 +138,37 @@ export function collectInbounds(data: ConfigObject): Inbound[] { return result } +/** + * 走规则表的流量最后交给谁。 + * + * `MATCH` 匹配一切,所以第一条 MATCH 之后的规则都到不了,它就是兜底出站。 + * 没有 MATCH 时内核会直连,返回 null。 + */ +export function fallbackTarget(data: ConfigObject): string | null { + const rules = Array.isArray(data.rules) ? data.rules : [] + for (const rule of rules) { + if (typeof rule !== 'string') continue + const parts = rule.split(',').map((p) => p.trim()) + if (parts[0]?.toUpperCase() === 'MATCH' && parts[1]) return parts[1] + } + return null +} + +/** + * 这一路入站的流量最终交给哪个出站。 + * + * 和 `inbound.outbound` 的区别在于「跟着规则走」会被继续解析下去:那种情况下真正决定 + * 去向的是规则表末尾的 MATCH。界面上要显示的是这个结果——用户想知道的是流量去哪, + * 不是配置里那个字段写没写。 + */ +export function resolveDestination(data: ConfigObject, inbound: Inbound): string | null { + const mode = inbound.outbound + if (mode.kind === 'direct') return 'DIRECT' + if (mode.kind === 'global') return 'GLOBAL' + if (mode.kind === 'named') return mode.name + return fallbackTarget(data) +} + /** * 所有可以被入站的出站字段引用的名字。 * diff --git a/web/api.ts b/web/api.ts index 10d0e7b..3bd852a 100644 --- a/web/api.ts +++ b/web/api.ts @@ -125,6 +125,15 @@ export const api = { */ proxies: () => request<{ proxies: Record }>('GET', '/api/kernel/proxies'), + /** + * 切换某个策略组当前选中哪个成员。 + * 这是运行时状态,不写配置文件,立刻生效——metacubexd 里点一条线路做的就是这件事。 + */ + selectProxy: (group: string, member: string) => + request<{ ok: true }>('PUT', `/api/kernel/proxies/${encodeURIComponent(group)}`, { + name: member, + }), + /** 订阅里当前有哪些线路。配置文件给不出这个答案,只能问运行中的内核。 */ providers: () => request<{ providers: Record }>( diff --git a/web/views/InboundDetail.vue b/web/views/InboundDetail.vue index 5a65e1e..24182e6 100644 --- a/web/views/InboundDetail.vue +++ b/web/views/InboundDetail.vue @@ -13,13 +13,49 @@ const props = defineProps<{ groups: string[] ownProxies: string[] providerLines: string[] + /** 流量最终交给哪个出站。走规则的入站要查到规则表末尾的 MATCH */ + destination: string | null /** 运行时状态,键是出站名字。内核没连接时是空的 */ runtime: Record kernelOnline: boolean saving: boolean }>() -const emit = defineEmits<{ pick: [mode: OutboundMode]; edit: []; remove: []; convert: [] }>() +const emit = defineEmits<{ + pick: [mode: OutboundMode] + edit: [] + remove: [] + convert: [] + select: [group: string, member: string] +}>() + +/** + * 从落脚的出站一路跟着「当前选中」往下走,直到走到一条真正的线路。 + * + * 策略组可以选中另一个策略组,所以「这个端口现在走哪条线」往往要跳好几层: + * PROXY 选了 AUTO,AUTO 选了某条线路。只显示第一层等于没回答问题。 + */ +function resolveChain(start: string | null): string[] { + const chain: string[] = [] + let cursor = start + while (cursor && !chain.includes(cursor)) { + chain.push(cursor) + cursor = props.runtime[cursor]?.now ?? null + } + return chain +} + +const chain = computed(() => resolveChain(props.destination)) + +/** 链条上最后一个还是策略组的环节——用户能在这里换选中。 */ +const switchable = computed(() => { + for (let i = chain.value.length - 1; i >= 0; i -= 1) { + const name = chain.value[i]! + const info = props.runtime[name] + if (info?.all?.length) return { group: name, members: info.all, now: info.now ?? '' } + } + return null +}) interface Option { key: string @@ -130,6 +166,35 @@ function delayClass(ms: number | null): string { +
+

流量去向

+

+ +

+ + +
+

出站

@@ -351,6 +416,30 @@ function delayClass(ms: number | null): string { padding-top: var(--s-4); } +.pick + .pick { + margin-top: var(--s-5); + border-top: 1px solid var(--c-border); +} + +.chain { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s-2); + font-size: var(--t-sm); + color: var(--c-text-soft); + margin-bottom: var(--s-2); +} + +.chain .arrow { + color: var(--c-text-faint); +} + +.chain .tail { + color: var(--c-accent-text); + font-weight: 500; +} + h3 { font-size: var(--t-sm); font-weight: 600; diff --git a/web/views/RouterPage.vue b/web/views/RouterPage.vue index c51b8fb..c8b1fc1 100644 --- a/web/views/RouterPage.vue +++ b/web/views/RouterPage.vue @@ -1,6 +1,11 @@