补上真正日常要点的那个动作:切换策略组当前选中谁

之前的面板只做了「这个入站指向哪个出站」,那是改配置文件。但 metacubexd 里点一条线路
改的根本不是入站,是策略组的当前选中——调 PUT /proxies/{组},不写文件、立刻生效。
mixed-port 走规则表,末尾 MATCH,PROXY 把流量交给 PROXY 组,所以换那个组的选中就等于
换了这个端口的出口。这是每天都要点的动作,之前整个漏了。

面板顶部现在显示完整链条(PROXY → AUTO → 某条线路),并让人在最后一个策略组上换选中。
只显示第一层等于没回答「这个端口现在走哪条线」。

透传的白名单里为此开了唯一一个写操作,单独一条路由而不是放开整个转发。
This commit is contained in:
im0o
2026-08-10 12:44:31 +08:00
parent f97981b146
commit 0062a261f7
7 changed files with 234 additions and 4 deletions
+24
View File
@@ -95,6 +95,30 @@ export async function reloadConfig(env: Env, configPath: string): Promise<Valida
}
}
/**
* 切换某个策略组当前选中哪个成员。
*
* 这是运行时状态,不写配置文件——所以不走 config-store 那条保护链路,也不需要备份和还原。
* 内核重启后会不会记住,取决于配置里的 profile.store-selected。
*/
export async function selectProxy(
env: Env,
group: string,
member: string,
): Promise<ValidateResult> {
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<Response> {
return callApi(env, path, { signal: AbortSignal.timeout(15_000) })
+14 -1
View File
@@ -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)) {
+40 -1
View File
@@ -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()
})
})
+31
View File
@@ -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)
}
/**
* 所有可以被入站的出站字段引用的名字。
*
+9
View File
@@ -125,6 +125,15 @@ export const api = {
*/
proxies: () => request<{ proxies: Record<string, ProxyInfo> }>('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<string, { vehicleType: string; proxies: { name: string }[] }> }>(
+90 -1
View File
@@ -13,13 +13,49 @@ const props = defineProps<{
groups: string[]
ownProxies: string[]
providerLines: string[]
/** 流量最终交给哪个出站。走规则的入站要查到规则表末尾的 MATCH */
destination: string | null
/** 运行时状态,键是出站名字。内核没连接时是空的 */
runtime: Record<string, ProxyInfo>
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 {
</dl>
</header>
<section v-if="chain.length" class="pick">
<h3>流量去向</h3>
<p class="chain mono">
<template v-for="(hop, i) in chain" :key="hop">
<span v-if="i" class="arrow">→</span><span :class="i === chain.length - 1 && 'tail'">{{ hop }}</span>
</template>
</p>
<template v-if="switchable">
<h4>{{ switchable.group }} 当前选中</h4>
<div class="grid">
<button
v-for="m in switchable.members"
:key="m"
type="button"
:class="['card', m === switchable.now && 'is-on']"
:disabled="saving"
@click="emit('select', switchable.group, m)"
>
<span class="card-name">{{ m }}</span>
<span class="card-sub">{{ runtime[m]?.now ? `${runtime[m]!.type}(${runtime[m]!.now})` : (runtime[m]?.type ?? '—') }}</span>
<span :class="['card-delay', delayClass(latestDelay(runtime[m]))]">
{{ latestDelay(runtime[m]) === null ? '' : `${latestDelay(runtime[m])} ms` }}
</span>
</button>
</div>
</template>
</section>
<section class="pick">
<h3>出站</h3>
@@ -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;
+26 -1
View File
@@ -1,6 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { collectInbounds, collectOutboundNames, outboundToProxyField } from '@shared/inbounds.ts'
import {
collectInbounds,
collectOutboundNames,
outboundToProxyField,
resolveDestination,
} from '@shared/inbounds.ts'
import type { Inbound, OutboundMode } from '@shared/inbounds.ts'
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
import { ApiError, api } from '../api'
@@ -162,6 +167,24 @@ async function submitForm(entry: ConfigObject) {
if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
}
/** 切换策略组当前选中谁。运行时动作,不写配置文件,所以不走那条落盘链路。 */
async function selectMember(group: string, member: string) {
if (saving.value) return
saving.value = true
failure.value = null
done.value = ''
try {
await api.selectProxy(group, member)
done.value = `${group} 切到了 ${member}`
emit('changed')
} catch (error) {
const message = error instanceof ApiError ? String(error.payload.error ?? error.message) : String(error)
failure.value = { message, hint: '这一步只改内核的运行状态,配置文件没有被动过。' }
} finally {
saving.value = false
}
}
async function removeSelected() {
const target = selected.value
if (!target || target.listenerIndex === null || saving.value) return
@@ -235,6 +258,7 @@ async function removeSelected() {
:groups="outbounds.groups"
:own-proxies="outbounds.ownProxies"
:provider-lines="providerLines"
:destination="resolveDestination(data, selected)"
:runtime="runtime"
:kernel-online="kernelOnline"
:saving="saving"
@@ -242,6 +266,7 @@ async function removeSelected() {
@edit="openEdit"
@remove="removeSelected"
@convert="openConvert"
@select="selectMember"
/>
</div>