补上真正日常要点的那个动作:切换策略组当前选中谁
之前的面板只做了「这个入站指向哪个出站」,那是改配置文件。但 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 请求,用于运行时数据(线路、连接、日志等)。 */
|
/** 透传一个管理接口的 GET 请求,用于运行时数据(线路、连接、日志等)。 */
|
||||||
export async function proxyGet(env: Env, path: string): Promise<Response> {
|
export async function proxyGet(env: Env, path: string): Promise<Response> {
|
||||||
return callApi(env, path, { signal: AbortSignal.timeout(15_000) })
|
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 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 } from './kernel.ts'
|
import { kernelVersion, proxyGet, 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'
|
||||||
@@ -103,6 +103,19 @@ export function createRouter(env: Env, token: string) {
|
|||||||
return json(await kernelVersion(env))
|
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') {
|
if (path.startsWith('/api/kernel/') && req.method === 'GET') {
|
||||||
const target = path.slice('/api/kernel'.length)
|
const target = path.slice('/api/kernel'.length)
|
||||||
if (!READABLE_KERNEL_PATHS.has(target)) {
|
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 { readFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { openDocument } from '../vendor/config-editor/index.ts'
|
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(
|
const fixture = openDocument(
|
||||||
readFileSync(join(import.meta.dir, '__fixtures__', 'config-with-comments.yaml'), 'utf8'),
|
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')
|
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
|
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'),
|
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: () =>
|
providers: () =>
|
||||||
request<{ providers: Record<string, { vehicleType: string; proxies: { name: string }[] }> }>(
|
request<{ providers: Record<string, { vehicleType: string; proxies: { name: string }[] }> }>(
|
||||||
|
|||||||
@@ -13,13 +13,49 @@ const props = defineProps<{
|
|||||||
groups: string[]
|
groups: string[]
|
||||||
ownProxies: string[]
|
ownProxies: string[]
|
||||||
providerLines: string[]
|
providerLines: string[]
|
||||||
|
/** 流量最终交给哪个出站。走规则的入站要查到规则表末尾的 MATCH */
|
||||||
|
destination: string | null
|
||||||
/** 运行时状态,键是出站名字。内核没连接时是空的 */
|
/** 运行时状态,键是出站名字。内核没连接时是空的 */
|
||||||
runtime: Record<string, ProxyInfo>
|
runtime: Record<string, ProxyInfo>
|
||||||
kernelOnline: boolean
|
kernelOnline: boolean
|
||||||
saving: 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 {
|
interface Option {
|
||||||
key: string
|
key: string
|
||||||
@@ -130,6 +166,35 @@ function delayClass(ms: number | null): string {
|
|||||||
</dl>
|
</dl>
|
||||||
</header>
|
</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">
|
<section class="pick">
|
||||||
<h3>出站</h3>
|
<h3>出站</h3>
|
||||||
|
|
||||||
@@ -351,6 +416,30 @@ function delayClass(ms: number | null): string {
|
|||||||
padding-top: var(--s-4);
|
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 {
|
h3 {
|
||||||
font-size: var(--t-sm);
|
font-size: var(--t-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
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 { Inbound, OutboundMode } from '@shared/inbounds.ts'
|
||||||
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
import type { ConfigObject } from '../../vendor/config-editor/index.ts'
|
||||||
import { ApiError, api } from '../api'
|
import { ApiError, api } from '../api'
|
||||||
@@ -162,6 +167,24 @@ async function submitForm(entry: ConfigObject) {
|
|||||||
if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
|
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() {
|
async function removeSelected() {
|
||||||
const target = selected.value
|
const target = selected.value
|
||||||
if (!target || target.listenerIndex === null || saving.value) return
|
if (!target || target.listenerIndex === null || saving.value) return
|
||||||
@@ -235,6 +258,7 @@ async function removeSelected() {
|
|||||||
:groups="outbounds.groups"
|
:groups="outbounds.groups"
|
||||||
:own-proxies="outbounds.ownProxies"
|
:own-proxies="outbounds.ownProxies"
|
||||||
:provider-lines="providerLines"
|
:provider-lines="providerLines"
|
||||||
|
:destination="resolveDestination(data, selected)"
|
||||||
:runtime="runtime"
|
:runtime="runtime"
|
||||||
:kernel-online="kernelOnline"
|
:kernel-online="kernelOnline"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
@@ -242,6 +266,7 @@ async function removeSelected() {
|
|||||||
@edit="openEdit"
|
@edit="openEdit"
|
||||||
@remove="removeSelected"
|
@remove="removeSelected"
|
||||||
@convert="openConvert"
|
@convert="openConvert"
|
||||||
|
@select="selectMember"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user