路由详情加附加规则:每路入站自己的分流表
用内核的 sub-rules 机制:listener 写 rule: 指向顶层 sub-rules 里的表 (listener/inbound/base.go:96,tunnel.go:713-721)。两个硬约束决定了 形态:proxy 优先级更高且完全短路规则匹配(tunnel.go:318-325),有附加 规则时 listener 只能写 rule;子规则表没命中时兜底是 DIRECT 而不是这一 路的出站(tunnel.go match 循环末尾),表底那条 MATCH,专属组 是承重的, 由 Patchbay 自动维护、界面上单独画不参与编辑。不变式:附加规则空 ⇔ proxy 直连专属组,非空 ⇔ rule 形态。 三处既有逻辑教会认 rule 形态:applySetup 不再无条件写 proxy(会把附加 规则静默短路掉);referencesTo 扫 sub-rules 里的规则——表底 MATCH 是 rule 形态下专属组活着的唯一理由,不数它就会被当孤儿清掉;pruneGenerated 清理没有 listener 引用的生成表。panelGroups 和 syncGenerated 的「是否 由 Patchbay 接线」收敛成一个 managedWiring。 listener 的 rule 指向不存在的表时内核静默退回全局规则(getRules 的 else 分支),-t 和重载都不拦,自检里补上这条(vendor 本地改动,记入 ORIGIN.md)。界面:详情列新增附加规则区块,逐条增删改和排序复用 RuleForm,带内网直连预设;生成形状过了 mihomo -t 实测。
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
applySetup,
|
||||
applySubRules,
|
||||
chainGroupName,
|
||||
chainProviderName,
|
||||
managedWiring,
|
||||
panelGroups,
|
||||
readSubRules,
|
||||
sharedPeers,
|
||||
subRuleName,
|
||||
syncGenerated,
|
||||
isGenerated,
|
||||
linesMatching,
|
||||
@@ -356,3 +360,70 @@ describe('清理', () => {
|
||||
expect((pruneGenerated(orphaned)['proxy-groups'] as ConfigObject[]).length).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('附加规则', () => {
|
||||
const managed = () => applySetup(base(), 'test1', plain)
|
||||
const listenerOf = (data: ConfigObject) => (data.listeners as ConfigObject[])[0]!
|
||||
|
||||
it('写入后 listener 换成 rule 形态,表底自动补兜底 MATCH', () => {
|
||||
const next = applySubRules(managed(), 'test1', ['DOMAIN-SUFFIX,lan,DIRECT'])
|
||||
|
||||
const listener = listenerOf(next)
|
||||
expect(listener.rule).toBe(subRuleName('test1'))
|
||||
expect(listener.proxy).toBeUndefined()
|
||||
expect((next['sub-rules'] as ConfigObject)[subRuleName('test1')]).toEqual([
|
||||
'DOMAIN-SUFFIX,lan,DIRECT',
|
||||
'MATCH,patchbay:test1',
|
||||
])
|
||||
})
|
||||
|
||||
it('读出来不含兜底 MATCH,往返一致', () => {
|
||||
const rules = ['DOMAIN-SUFFIX,lan,DIRECT', 'GEOIP,CN,DIRECT']
|
||||
const next = applySubRules(managed(), 'test1', rules)
|
||||
expect(readSubRules(next, 'test1')).toEqual(rules)
|
||||
expect(readSubRules(managed(), 'test1')).toEqual([])
|
||||
})
|
||||
|
||||
it('清空后恢复 proxy 直连形态,sub-rules 键整个消失', () => {
|
||||
const withRules = applySubRules(managed(), 'test1', ['GEOIP,CN,DIRECT'])
|
||||
const cleared = applySubRules(withRules, 'test1', [])
|
||||
|
||||
const listener = listenerOf(cleared)
|
||||
expect(listener.proxy).toBe(proxyGroupName('test1'))
|
||||
expect(listener.rule).toBeUndefined()
|
||||
expect(cleared['sub-rules']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rule 形态仍被认成 Patchbay 接线,重建设置不会把 proxy 加回来', () => {
|
||||
// proxy 会短路规则匹配(tunnel.go:318-325),加回来附加规则就静默失效了
|
||||
const withRules = applySubRules(managed(), 'test1', ['GEOIP,CN,DIRECT'])
|
||||
const inbound = collectInbounds(withRules).find((i) => i.name === 'test1')!
|
||||
expect(managedWiring(withRules, inbound)).toBe(true)
|
||||
|
||||
const rebuilt = syncGenerated(withRules, collectInbounds(withRules))
|
||||
const listener = listenerOf(rebuilt)
|
||||
expect(listener.rule).toBe(subRuleName('test1'))
|
||||
expect(listener.proxy).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rule 形态下专属组靠表底 MATCH 活着,不会被当孤儿清掉', () => {
|
||||
const withRules = applySubRules(managed(), 'test1', ['GEOIP,CN,DIRECT'])
|
||||
const pruned = pruneGenerated(withRules)
|
||||
const groupNames = (pruned['proxy-groups'] as ConfigObject[]).map((g) => g.name)
|
||||
expect(groupNames).toContain(proxyGroupName('test1'))
|
||||
})
|
||||
|
||||
it('删掉入站后,附加规则表和组连锁消失', () => {
|
||||
const withRules = applySubRules(managed(), 'test1', ['GEOIP,CN,DIRECT'])
|
||||
const removed: ConfigObject = { ...withRules, listeners: [] }
|
||||
|
||||
const cleaned = pruneGenerated(removed)
|
||||
expect(cleaned['sub-rules']).toBeUndefined()
|
||||
expect(cleaned['proxy-groups']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('用户自己的 sub-rules 表不被清理', () => {
|
||||
const data: ConfigObject = { ...base(), 'sub-rules': { mine: ['MATCH,DIRECT'] } }
|
||||
expect((pruneGenerated(data)['sub-rules'] as ConfigObject).mine).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
+129
-10
@@ -55,6 +55,11 @@ export function chainGroupName(inbound: string): string {
|
||||
return `${GENERATED_PREFIX}${inbound} 链式`
|
||||
}
|
||||
|
||||
/** 这一路入站的附加规则表在顶层 `sub-rules:` 里的键名。listener 的 `rule:` 指向它。 */
|
||||
export function subRuleName(inbound: string): string {
|
||||
return `${GENERATED_PREFIX}${inbound} 附加`
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个出站在界面上叫什么。
|
||||
*
|
||||
@@ -69,6 +74,7 @@ export function displayName(name: string): string {
|
||||
[' AUTO', 'AUTO'],
|
||||
[' FALLBACK', 'FALLBACK'],
|
||||
[' 链式', '链式代理'],
|
||||
[' 附加', '附加规则'],
|
||||
] as const) {
|
||||
if (rest.endsWith(suffix)) return label
|
||||
}
|
||||
@@ -219,6 +225,85 @@ export function readSetup(data: ConfigObject, inbound: string): OutboundSetup |
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 附加规则 ─────────────────────────────────────
|
||||
*
|
||||
* 挂在某一路入站上的分流规则(内核的 sub-rules 机制):listener 写 `rule: <表名>`,
|
||||
* 该入站的流量改查顶层 `sub-rules:` 里同名的表(tunnel.go:713-721)。两个硬约束:
|
||||
* - `proxy:` 的优先级更高且完全短路规则匹配(tunnel.go:318-325),所以有附加规则时
|
||||
* listener 只能写 rule 不写 proxy;
|
||||
* - 子规则表没有命中时兜底是 DIRECT 而不是这一路的出站(tunnel.go match 循环末尾),
|
||||
* 所以表底那条 `MATCH,专属组` 是承重的,Patchbay 始终自动补上,不交给用户维护。
|
||||
* 于是不变式是:附加规则为空 ⇔ listener 用 proxy 直连专属组;非空 ⇔ listener 用 rule,
|
||||
* 表底 MATCH 指回专属组。两种形态对「没被规则分走的流量」效果完全相同。
|
||||
*/
|
||||
|
||||
function subRulesOf(data: ConfigObject): ConfigObject {
|
||||
return isPlainObject(data['sub-rules']) ? data['sub-rules'] : {}
|
||||
}
|
||||
|
||||
/** 这一路入站有没有 Patchbay 管理的附加规则表。 */
|
||||
export function hasSubRules(data: ConfigObject, inbound: string): boolean {
|
||||
return subRuleName(inbound) in subRulesOf(data)
|
||||
}
|
||||
|
||||
/** 表底的兜底规则。它把没被分走的流量送回这一路自己的出站。 */
|
||||
function terminalRule(inbound: string): string {
|
||||
return `MATCH,${proxyGroupName(inbound)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 读出这一路的附加规则,不含表底的兜底 MATCH——那条是生成物,界面上单独画、
|
||||
* 不参与编辑。手改过、兜底不在末尾的表原样全给,编辑保存时会重新规整。
|
||||
*/
|
||||
export function readSubRules(data: ConfigObject, inbound: string): string[] {
|
||||
const entry = subRulesOf(data)[subRuleName(inbound)]
|
||||
const rules = Array.isArray(entry)
|
||||
? entry.filter((rule): rule is string => typeof rule === 'string')
|
||||
: []
|
||||
return rules.at(-1) === terminalRule(inbound) ? rules.slice(0, -1) : rules
|
||||
}
|
||||
|
||||
/**
|
||||
* 把这一路的附加规则写回配置。
|
||||
*
|
||||
* 空表恢复 proxy 直连形态(删表、删 rule、接回专属组);非空补上兜底 MATCH、
|
||||
* listener 换成 rule。listener 上用户手写的其它字段原样保留。
|
||||
*/
|
||||
export function applySubRules(data: ConfigObject, inbound: string, rules: string[]): ConfigObject {
|
||||
const next = { ...data }
|
||||
const name = subRuleName(inbound)
|
||||
const tables = { ...subRulesOf(next) }
|
||||
|
||||
if (rules.length === 0) delete tables[name]
|
||||
else tables[name] = [...rules, terminalRule(inbound)]
|
||||
|
||||
if (Object.keys(tables).length === 0) delete next['sub-rules']
|
||||
else next['sub-rules'] = tables
|
||||
|
||||
const listeners = (Array.isArray(next.listeners) ? next.listeners : []).map((entry) => {
|
||||
if (!isPlainObject(entry) || entry.name !== inbound) return entry
|
||||
if (rules.length === 0) {
|
||||
const { rule: _rule, ...rest } = entry
|
||||
return { ...rest, proxy: proxyGroupName(inbound) }
|
||||
}
|
||||
const { proxy: _proxy, ...rest } = entry
|
||||
return { ...rest, rule: name }
|
||||
})
|
||||
if (listeners.length > 0) next.listeners = listeners
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一路入站是否由 Patchbay 接线。两种形态:proxy 直指专属组,
|
||||
* 或经附加规则表兜底到专属组。panelGroups 和 syncGenerated 共用这一个答案。
|
||||
*/
|
||||
export function managedWiring(data: ConfigObject, inbound: Inbound): boolean {
|
||||
const mode = inbound.outbound
|
||||
if (mode.kind === 'named' && mode.name === proxyGroupName(inbound.name)) return true
|
||||
return inbound.ruleSet === subRuleName(inbound.name) && hasSubRules(data, inbound.name)
|
||||
}
|
||||
|
||||
function makeGroup(
|
||||
name: string,
|
||||
type: string,
|
||||
@@ -332,11 +417,17 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
|
||||
if (Object.keys(providers).length === 0) delete next['proxy-providers']
|
||||
else next['proxy-providers'] = providers
|
||||
|
||||
const listeners = (Array.isArray(next.listeners) ? next.listeners : []).map((entry) =>
|
||||
isPlainObject(entry) && entry.name === inbound
|
||||
? { ...entry, proxy: proxyGroupName(inbound) }
|
||||
: entry,
|
||||
)
|
||||
// 有附加规则时 listener 走 rule 形态,proxy 会短路规则匹配、不能写;
|
||||
// 表底的兜底 MATCH 已经指向专属组,接线效果一样
|
||||
const viaSubRules = hasSubRules(next, inbound)
|
||||
const listeners = (Array.isArray(next.listeners) ? next.listeners : []).map((entry) => {
|
||||
if (!isPlainObject(entry) || entry.name !== inbound) return entry
|
||||
if (viaSubRules) {
|
||||
const { proxy: _proxy, ...rest } = entry
|
||||
return { ...rest, rule: subRuleName(inbound) }
|
||||
}
|
||||
return { ...entry, proxy: proxyGroupName(inbound) }
|
||||
})
|
||||
if (listeners.length > 0) next.listeners = listeners
|
||||
|
||||
return next
|
||||
@@ -365,6 +456,17 @@ function referencesTo(data: ConfigObject, name: string): number {
|
||||
if (typeof rule === 'string' && rule.split(',').some((part) => part.trim() === name)) count += 1
|
||||
}
|
||||
|
||||
// 附加规则表里的规则同样引用出站——表底的 MATCH,专属组 正是 rule 形态下
|
||||
// 专属组活着的唯一理由,不数它专属组会被当孤儿清掉
|
||||
for (const table of Object.values(subRulesOf(data))) {
|
||||
if (!Array.isArray(table)) continue
|
||||
for (const rule of table) {
|
||||
if (typeof rule === 'string' && rule.split(',').some((part) => part.trim() === name)) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -392,7 +494,21 @@ export function pruneGenerated(data: ConfigObject): ConfigObject {
|
||||
)
|
||||
const providersChanged = keptProviders.length !== Object.keys(providers).length
|
||||
|
||||
if (keptGroups.length === groups.length && !providersChanged) return data
|
||||
// 生成的附加规则表活着的唯一理由是有 listener 的 rule 指着它。
|
||||
// 入站被删后先清表,表里的 MATCH 引用消失,下一轮才轮到那几个组
|
||||
const listenerRules = new Set(
|
||||
(Array.isArray(data.listeners) ? data.listeners : [])
|
||||
.filter(isPlainObject)
|
||||
.map((entry) => entry.rule)
|
||||
.filter((rule): rule is string => typeof rule === 'string'),
|
||||
)
|
||||
const tables = subRulesOf(data)
|
||||
const keptTables = Object.entries(tables).filter(
|
||||
([name]) => !isGenerated(name) || listenerRules.has(name),
|
||||
)
|
||||
const tablesChanged = keptTables.length !== Object.keys(tables).length
|
||||
|
||||
if (keptGroups.length === groups.length && !providersChanged && !tablesChanged) return data
|
||||
|
||||
const next = { ...data }
|
||||
if (keptGroups.length === 0) delete next['proxy-groups']
|
||||
@@ -403,6 +519,11 @@ export function pruneGenerated(data: ConfigObject): ConfigObject {
|
||||
else next['proxy-providers'] = Object.fromEntries(keptProviders)
|
||||
}
|
||||
|
||||
if (tablesChanged) {
|
||||
if (keptTables.length === 0) delete next['sub-rules']
|
||||
else next['sub-rules'] = Object.fromEntries(keptTables)
|
||||
}
|
||||
|
||||
return pruneGenerated(next)
|
||||
}
|
||||
|
||||
@@ -417,8 +538,7 @@ export function syncGenerated(data: ConfigObject, inbounds: Inbound[]): ConfigOb
|
||||
let next = data
|
||||
for (const inbound of inbounds) {
|
||||
const setup = readSetup(next, inbound.name)
|
||||
const mode = inbound.outbound
|
||||
if (!setup || mode.kind !== 'named' || mode.name !== proxyGroupName(inbound.name)) continue
|
||||
if (!setup || !managedWiring(next, inbound)) continue
|
||||
next = applySetup(next, inbound.name, setup)
|
||||
}
|
||||
return next
|
||||
@@ -449,8 +569,7 @@ export function panelGroups(
|
||||
runtime: Record<string, RuntimeNode>,
|
||||
inbound: Inbound,
|
||||
): { groups: PanelGroup[]; shared: boolean } {
|
||||
const mode = inbound.outbound
|
||||
const managed = mode.kind === 'named' && mode.name === proxyGroupName(inbound.name)
|
||||
const managed = managedWiring(data, inbound)
|
||||
|
||||
if (managed) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user