diff --git a/shared/inbound-groups.test.ts b/shared/inbound-groups.test.ts index 6714345..5e5851c 100644 --- a/shared/inbound-groups.test.ts +++ b/shared/inbound-groups.test.ts @@ -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() + }) +}) diff --git a/shared/inbound-groups.ts b/shared/inbound-groups.ts index 4f23ac0..abeacfc 100644 --- a/shared/inbound-groups.ts +++ b/shared/inbound-groups.ts @@ -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, 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 { diff --git a/vendor/config-editor/ORIGIN.md b/vendor/config-editor/ORIGIN.md index aca12da..af58296 100644 --- a/vendor/config-editor/ORIGIN.md +++ b/vendor/config-editor/ORIGIN.md @@ -12,6 +12,7 @@ |---|---|---| | `index.test.ts` | 测试框架的导入从 `vitest` 换成 `bun:test` | 本项目用 Bun 自带的测试运行器,不引入 vitest。两者的 `describe` / `it` / `expect` 接口一致,用例本身一行没动 | | `index.ts` | `stringifyTouched` 结尾的 `String(doc)` 换成 `doc.toString({ flowCollectionPadding: false, lineWidth: 0 })` | 上游用默认输出参数,会把 `[a, b]` 改写成 `[ a, b ]`,并且在超过 80 列时折行——目标机器的订阅地址就够长。这两样都会让没被改动的段落出现在 diff 里 | +| `index.ts` | `validateDocument` 末尾新增:listener 的 `rule:` 指向不存在的 sub-rules 表时报 `missing-reference` | 内核对这种引用静默退回全局规则(tunnel.go getRules 的 else 分支),`mihomo -t` 和重载都不会拦,只有这层自检能拦住 | ## 配套的本项目代码 diff --git a/vendor/config-editor/index.ts b/vendor/config-editor/index.ts index f3a11e6..50906e5 100644 --- a/vendor/config-editor/index.ts +++ b/vendor/config-editor/index.ts @@ -1056,6 +1056,25 @@ export function validateDocument(data: ConfigObject): ConfigDiagnostic[] { } }) } + + // Patchbay local addition (see ORIGIN.md): a listener whose `rule:` names a + // missing sub-rules table silently falls back to the global rules (mihomo + // tunnel.go getRules), so neither `mihomo -t` nor a reload will ever flag it. + if (Array.isArray(data.listeners)) { + data.listeners.forEach((entry, index) => { + if (!isObject(entry)) return + const rule = entry.rule + if (typeof rule !== 'string' || !rule) return + if (!isObject(subRules) || !(rule in subRules)) { + diagnostics.push({ + path: ['listeners', index, 'rule'], + code: 'missing-reference', + message: `Unknown sub-rule: ${rule}`, + severity: 'error', + }) + } + }) + } return diagnostics } diff --git a/web/views/InboundDetail.vue b/web/views/InboundDetail.vue index 538422a..14181f1 100644 --- a/web/views/InboundDetail.vue +++ b/web/views/InboundDetail.vue @@ -18,7 +18,9 @@ import { fallbackGroupName, linesMatching, proxyGroupName, + subRuleName, } from '@shared/inbound-groups.ts' +import { missingLanRules } from '@shared/rules.ts' import type { GroupSpec, OutboundSetup, PanelGroup } from '@shared/inbound-groups.ts' import { resolveChain } from '@shared/outbound-chain.ts' import { latestDelay } from '../api' @@ -32,6 +34,9 @@ import IconEdit from '~icons/ri/edit-line' import IconDelete from '~icons/ri/delete-bin-line' import IconRight from '~icons/ri/arrow-right-s-line' import IconOffline from '~icons/ri/cloud-off-line' +import IconAdd from '~icons/ri/add-line' +import IconUp from '~icons/ri/arrow-up-line' +import IconDown from '~icons/ri/arrow-down-line' const props = defineProps<{ inbound: Inbound @@ -45,6 +50,8 @@ const props = defineProps<{ subscriptions: string[] /** 当前的来源和筛选,配置表单的初值 */ setup: OutboundSetup | null + /** 这一路的附加规则(不含表底的兜底 MATCH,那条由 Patchbay 自动维护) */ + subRules: string[] /** 正在测延迟的线路 */ testing: string[] /** 配置正在保存 */ @@ -61,8 +68,22 @@ const emit = defineEmits<{ save: [setup: OutboundSetup, picks: { proxy: string | null; chain: string | null }] /** 把这一路改成专属代理组模式 */ adopt: [] + /** 附加规则的增删改和排序。规则文本的编辑走父组件的 RuleForm */ + 'sub-add': [] + 'sub-edit': [index: number] + 'sub-remove': [index: number] + 'sub-move': [index: number, delta: -1 | 1] + 'sub-lan': [] }>() +/** 手写的 rule 字段(不是 Patchbay 的附加规则表)才在事实行里原样展示 */ +const foreignRuleSet = computed( + () => props.inbound.ruleSet !== null && props.inbound.ruleSet !== subRuleName(props.inbound.name), +) + +/** 内网直连还缺几条。全齐了按钮就不出现 */ +const lanMissing = computed(() => missingLanRules(props.subRules).length) + /** 默认入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删。 */ const locked = computed(() => props.inbound.isDefault) @@ -434,7 +455,8 @@ const complete = computed(
{{ inbound.type }}
监听
{{ inbound.listen ?? '0.0.0.0' }}
- @@ -798,6 +885,111 @@ const complete = computed( border-top: 1px solid var(--c-border); } +/* ── 附加规则 ────────────────────────────────── */ +.h3-acts { + margin-left: auto; + display: inline-flex; + gap: var(--s-2); +} + +.mini { + display: inline-flex; + align-items: center; + gap: var(--s-1); + padding: 2px var(--s-2); + border: none; + border-radius: var(--r-sm); + background: none; + color: var(--c-text-soft); + font-size: var(--t-xs); + font-weight: 400; + cursor: pointer; +} +.mini:hover:not(:disabled) { + background: var(--c-surface-sunken); + color: var(--c-text); +} +.mini:disabled { + color: var(--c-text-faint); + cursor: not-allowed; +} +.mini svg { + width: 13px; + height: 13px; +} + +.srules { + list-style: none; + margin: 0; + padding: 0; +} + +.srule { + display: flex; + align-items: center; + gap: var(--s-2); + padding: var(--s-1) var(--s-2); + border-radius: var(--r-sm); +} +.srule:hover { + background: var(--c-surface-sunken); +} + +.s-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--t-xs); +} + +/* 兜底那行是生成物:淡色示意,不给操作 */ +.is-final .s-text { + color: var(--c-text-faint); +} + +/* 操作按钮悬停到行上才现身,规则文本是主角 */ +.s-acts { + flex: none; + display: inline-flex; + gap: 1px; + opacity: 0; + transition: opacity 0.15s ease; +} +.srule:hover .s-acts, +.s-acts:focus-within { + opacity: 1; +} + +.s-acts button { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + border-radius: var(--r-sm); + background: none; + color: var(--c-text-faint); + cursor: pointer; +} +.s-acts button:hover:not(:disabled) { + background: var(--c-surface); + color: var(--c-text); +} +.s-acts button:disabled { + opacity: 0.35; + cursor: not-allowed; +} +.s-acts .s-danger:hover:not(:disabled) { + color: var(--c-danger); +} +.s-acts svg { + width: 13px; + height: 13px; +} + h3 { display: flex; align-items: center; diff --git a/web/views/RouterPage.vue b/web/views/RouterPage.vue index be3e372..0a54809 100644 --- a/web/views/RouterPage.vue +++ b/web/views/RouterPage.vue @@ -11,16 +11,19 @@ import { collectInbounds } from '@shared/inbounds.ts' import type { Inbound } from '@shared/inbounds.ts' import { applySetup, + applySubRules, chainGroupName, groupUses, panelGroups, proxyGroupName, pruneGenerated, + readSubRules, sharedPeers, syncGenerated, readSetup, userProviders, } from '@shared/inbound-groups.ts' +import { missingLanRules, moveRule, ruleProvidersOf } from '@shared/rules.ts' import type { OutboundSetup } from '@shared/inbound-groups.ts' import type { ConfigObject } from '../../vendor/config-editor/index.ts' import { ApiError, api } from '../api' @@ -33,6 +36,7 @@ import ConfirmDialog from '../components/ConfirmDialog.vue' import InboundList from '../components/InboundList.vue' import InboundDetail from './InboundDetail.vue' import InboundForm from './InboundForm.vue' +import RuleForm from './RuleForm.vue' import IconAdd from '~icons/ri/add-line' const props = defineProps<{ @@ -219,6 +223,71 @@ async function selectMember(group: string, member: string) { } } +/* ── 附加规则 ─────────────────────────────────── */ + +/** 选中这一路的附加规则(不含表底的兜底 MATCH)。 */ +const subRules = computed(() => + selected.value && setup.value !== null ? readSubRules(props.data, selected.value.name) : [], +) + +/** RULE-SET 载荷的候选,给规则表单用 */ +const ruleProviderNames = computed(() => Object.keys(ruleProvidersOf(props.data))) + +const subFormOpen = ref(false) +/** 编辑时是下标,新增时是 null */ +const subEditing = ref(null) +/** 待确认删除的附加规则下标 */ +const removingSubRule = ref(null) + +function openSubAdd() { + subEditing.value = null + subFormOpen.value = true +} + +function openSubEdit(index: number) { + subEditing.value = index + subFormOpen.value = true +} + +/** 所有附加规则的改动都收敛到这里:算出新表,整表写回。 */ +async function commitSubRules(next: string[], success: string): Promise { + const target = selected.value + if (!target || saving.value) return false + return commit(applySubRules(clone(), target.name, next), success) +} + +async function submitSubRule(ruleText: string) { + const editing = subEditing.value + const next = + editing === null + ? [...subRules.value, ruleText] + : subRules.value.map((rule, i) => (i === editing ? ruleText : rule)) + const ok = await commitSubRules(next, editing === null ? '已新增附加规则' : '已保存') + // 失败时不能关对话框——关了再打开就是空表单 + if (ok) subFormOpen.value = false +} + +async function moveSubRule(index: number, delta: -1 | 1) { + if (saving.value) return + await commitSubRules(moveRule(subRules.value, index, delta), '顺序已保存') +} + +async function removeSubRuleConfirmed() { + const index = removingSubRule.value + removingSubRule.value = null + if (index === null) return + await commitSubRules( + subRules.value.filter((_, i) => i !== index), + '已删除这条附加规则', + ) +} + +async function addLanSubRules() { + const lan = missingLanRules(subRules.value) + if (lan.length === 0) return + await commitSubRules([...subRules.value, ...lan], `已加入 ${lan.length} 条内网直连规则`) +} + /** 待确认删除的那一路。非 null 时确认对话框打开着。 */ const removing = ref(null) @@ -280,6 +349,7 @@ async function removeConfirmed() { :chain="chainView" :subscriptions="subscriptions" :setup="setup" + :sub-rules="subRules" :testing="testing" :saving="saving" @edit="openEdit" @@ -288,6 +358,11 @@ async function removeConfirmed() { @test="test" @save="saveSetup" @adopt="adopt" + @sub-add="openSubAdd" + @sub-edit="openSubEdit" + @sub-remove="removingSubRule = $event" + @sub-move="moveSubRule" + @sub-lan="addLanSubRules" /> @@ -310,6 +385,25 @@ async function removeConfirmed() { @close="removing = null" @confirm="removeConfirmed" /> + + + +