补上真正日常要点的那个动作:切换策略组当前选中谁
之前的面板只做了「这个入站指向哪个出站」,那是改配置文件。但 metacubexd 里点一条线路
改的根本不是入站,是策略组的当前选中——调 PUT /proxies/{组},不写文件、立刻生效。
mixed-port 走规则表,末尾 MATCH,PROXY 把流量交给 PROXY 组,所以换那个组的选中就等于
换了这个端口的出口。这是每天都要点的动作,之前整个漏了。
面板顶部现在显示完整链条(PROXY → AUTO → 某条线路),并让人在最后一个策略组上换选中。
只显示第一层等于没回答「这个端口现在走哪条线」。
透传的白名单里为此开了唯一一个写操作,单独一条路由而不是放开整个转发。
This commit is contained in:
@@ -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
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有可以被入站的出站字段引用的名字。
|
||||
*
|
||||
|
||||
@@ -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 }[] }> }>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user