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

之前的面板只做了「这个入站指向哪个出站」,那是改配置文件。但 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
+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)
}
/**
* 所有可以被入站的出站字段引用的名字。
*