From cb8effe05b2d7098871ec4d4c9e0ee3af1994efb Mon Sep 17 00:00:00 2001 From: im0o <0ojixueseno0@gmail.com> Date: Mon, 10 Aug 2026 19:33:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=8A=E5=85=A5=E7=AB=99=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E5=88=B0=E6=9F=90=E6=9D=A1=E8=AE=A2=E9=98=85=E7=BA=BF=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 订阅线路不在内核的全局名字表里,入站的 proxy 指不到它(决策记录 0005)。做法是自动建 一个只装它的策略组再指过去,用户看到的只是「这个口固定走这条线」,组是实现细节: 不出现在策略组列表里,列表和详情里都显示成线路本身的名字,没人再引用时自动删掉。 筛选条件必须做对两件事,都写了测试:转义正则元字符(线路名形如 [vmess]DMIT.LAX.Pro 里的方括号和点不转义会匹配错),以及前后加锚点(内核是部分匹配,不加锚点时「香港01」 会把「香港011」也捞进来)。线路名含反引号时表达不了——那是内核切分多个筛选条件的 分隔符——这种情况明说做不到而不是生成一个会静默匹配错的组。 实测:生成的配置 mihomo -t 通过;起一个临时内核问 /proxies,这个组解析出来的成员 恰好只有那一条线路,且确实进了名字表所以入站指得到。 --- scripts/verify-pin.ts | 36 ++++++++++ shared/filter-groups.test.ts | 110 ++++++++++++++++++++++++++++++ shared/filter-groups.ts | 118 +++++++++++++++++++++++++++++++++ web/components/InboundList.vue | 3 +- web/views/InboundDetail.vue | 14 +++- web/views/RouterPage.vue | 58 +++++++++++++++- 6 files changed, 333 insertions(+), 6 deletions(-) create mode 100644 scripts/verify-pin.ts create mode 100644 shared/filter-groups.test.ts create mode 100644 shared/filter-groups.ts diff --git a/scripts/verify-pin.ts b/scripts/verify-pin.ts new file mode 100644 index 0000000..3ce5ade --- /dev/null +++ b/scripts/verify-pin.ts @@ -0,0 +1,36 @@ +/** + * 验证「把入站固定到某条订阅线路」生成的配置内核真的接受。 + * + * 这个功能会往用户的配置里塞一个自动生成的策略组,光靠单元测试只能证明字符串拼对了, + * 证明不了内核认。所以这里走一遍真实的写回路径,再交给 mihomo -t 校验。 + * + * 用法:bun run scripts/verify-pin.ts <源配置> <输出路径> <线路名> + */ +import { readFileSync, writeFileSync } from 'node:fs' +import { openDocument } from '../vendor/config-editor/index.ts' +import type { ConfigObject } from '../vendor/config-editor/index.ts' +import { rewriteYaml } from '../server/config-store.ts' +import { generatedGroupName, makeFilterGroup, pruneGeneratedGroups } from '../shared/filter-groups.ts' + +const [src, out, line] = process.argv.slice(2) +if (!src || !out || !line) { + console.error('用法:bun run scripts/verify-pin.ts <源配置> <输出路径> <线路名>') + process.exit(1) +} + +const source = readFileSync(src, 'utf8') +const data = JSON.parse(JSON.stringify(openDocument(source).data)) as ConfigObject + +// 模拟界面上的两个动作:新增一路入站,把它固定到某条订阅线路 +data.listeners = [{ name: '容器A', type: 'mixed', port: 17895 }] +const group = makeFilterGroup(line, 'subs') +if (!group) { + console.error(`${line} 的名字里含反引号,无法生成筛选条件`) + process.exit(1) +} +data['proxy-groups'] = [...(data['proxy-groups'] as ConfigObject[]), group] +;(data.listeners as ConfigObject[])[0]!.proxy = generatedGroupName(line) + +writeFileSync(out, rewriteYaml(source, pruneGeneratedGroups(data)), 'utf8') +console.log('生成的组:') +console.log(JSON.stringify(group, null, 2)) diff --git a/shared/filter-groups.test.ts b/shared/filter-groups.test.ts new file mode 100644 index 0000000..572d27c --- /dev/null +++ b/shared/filter-groups.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'bun:test' +import { + generatedGroupName, + isGeneratedGroup, + lineFilter, + makeFilterGroup, + pinnedLineOf, + pruneGeneratedGroups, +} from './filter-groups.ts' +import type { ConfigObject } from '../vendor/config-editor/index.ts' + +const LINE = '[vless]Gomami.SIN Secret' + +describe('筛选条件', () => { + it('转义正则元字符', () => { + // 线路名里的 [ ] . 不转义会当成正则语法,匹配到别的线路上去 + expect(lineFilter(LINE)).toBe('^\\[vless\\]Gomami\\.SIN Secret$') + }) + + it('前后加锚点,避免匹配到名字更长的线路', () => { + // 内核的筛选是部分匹配:不加锚点时「香港01」会把「香港011」也捞进来 + expect(lineFilter('香港01')).toBe('^香港01$') + }) + + it('线路名含反引号时表达不了,返回空让调用方说明原因', () => { + // 反引号在内核里是多个筛选条件的分隔符,塞进一条正则会被切成两半 + expect(lineFilter('怪`名字')).toBeNull() + }) + + it('转义后的正则确实只匹配它自己', () => { + const re = new RegExp(lineFilter(LINE)!) + expect(re.test(LINE)).toBe(true) + expect(re.test('[vless]GomamiXSIN Secret')).toBe(false) + expect(re.test(`${LINE} 备用`)).toBe(false) + }) +}) + +describe('生成的组', () => { + it('名字带前缀,这是识别它的唯一可靠标记', () => { + // 注释靠不住——被碰到的段落重写时会丢,见决策记录 0002 + const name = generatedGroupName(LINE) + expect(isGeneratedGroup(name)).toBe(true) + expect(pinnedLineOf(name)).toBe(LINE) + }) + + it('用户自己建的组不会被认成生成的', () => { + expect(isGeneratedGroup('PROXY')).toBe(false) + expect(pinnedLineOf('PROXY')).toBeNull() + }) + + it('组里只有那一条线路,类型是手动选', () => { + expect(makeFilterGroup(LINE, 'subs')).toEqual({ + name: `patchbay:${LINE}`, + type: 'select', + use: ['subs'], + filter: '^\\[vless\\]Gomami\\.SIN Secret$', + }) + }) + + it('表达不了的线路名不生成组', () => { + expect(makeFilterGroup('怪`名字', 'subs')).toBeNull() + }) +}) + +describe('清理没人用的生成组', () => { + const generated = (line: string) => ({ name: generatedGroupName(line), type: 'select' }) + + it('入站还指着它就留着', () => { + const data: ConfigObject = { + listeners: [{ name: 'A', type: 'mixed', port: 1, proxy: generatedGroupName(LINE) }], + 'proxy-groups': [generated(LINE)], + } + expect(pruneGeneratedGroups(data)['proxy-groups']).toHaveLength(1) + }) + + it('没人再指它就删掉', () => { + const data: ConfigObject = { + listeners: [{ name: 'A', type: 'mixed', port: 1 }], + 'proxy-groups': [generated(LINE)], + } + // 不清理的话,改几次出站文件里就攒下一堆用户从没主动建过的组 + expect(pruneGeneratedGroups(data)['proxy-groups']).toBeUndefined() + }) + + it('被别的策略组当成员引用也算在用', () => { + const data: ConfigObject = { + 'proxy-groups': [generated(LINE), { name: 'PROXY', proxies: [generatedGroupName(LINE)] }], + } + expect(pruneGeneratedGroups(data)['proxy-groups']).toHaveLength(2) + }) + + it('被规则引用也算在用', () => { + const data: ConfigObject = { + 'proxy-groups': [generated(LINE)], + rules: [`MATCH,${generatedGroupName(LINE)}`], + } + expect(pruneGeneratedGroups(data)['proxy-groups']).toHaveLength(1) + }) + + it('用户自己建的组一律不碰,哪怕真的没人引用', () => { + const data: ConfigObject = { 'proxy-groups': [{ name: '我建的组', type: 'select' }] } + expect(pruneGeneratedGroups(data)['proxy-groups']).toHaveLength(1) + }) + + it('没有可删的时候原样返回,不制造无谓的改动', () => { + // 返回同一个对象,落盘链路才看得出「内容没变」而跳过备份和重载 + const data: ConfigObject = { 'proxy-groups': [{ name: 'PROXY' }] } + expect(pruneGeneratedGroups(data)).toBe(data) + }) +}) diff --git a/shared/filter-groups.ts b/shared/filter-groups.ts new file mode 100644 index 0000000..9a71287 --- /dev/null +++ b/shared/filter-groups.ts @@ -0,0 +1,118 @@ +/** + * 把一路入站固定到某一条订阅线路。 + * + * 内核不允许直接这么写:入站的 `proxy:` 只认全局名字表里的名字,而订阅线路不在表上 + * (见决策记录 0005)。绕法是给那条线路单独建一个只装它的策略组,入站指向这个组。 + * + * 这些组是 Patchbay 自己造的,用户不该看到也不该管。名字带前缀是唯一可靠的标记—— + * 注释靠不住,被碰到的段落重写时会丢。 + */ +import type { ConfigObject } from '../vendor/config-editor/index.ts' + +/** 自动生成的筛选组的名字前缀。改它会让已有配置里的组失去标记,别改。 */ +export const GENERATED_PREFIX = 'patchbay:' + +export function isGeneratedGroup(name: string): boolean { + return name.startsWith(GENERATED_PREFIX) +} + +/** 固定到某条线路时,生成的组叫什么。名字里带上线路名,翻文件时一眼看得懂。 */ +export function generatedGroupName(line: string): string { + return `${GENERATED_PREFIX}${line}` +} + +/** 这个组固定的是哪条线路。不是生成的组返回 null。 */ +export function pinnedLineOf(groupName: string): string | null { + return isGeneratedGroup(groupName) ? groupName.slice(GENERATED_PREFIX.length) : null +} + +/** + * 出站在界面上怎么念。 + * + * 生成的组显示成它固定的那条线路——用户从没建过这个组,让他在界面上读到 + * `patchbay:[vless]Gomami` 只会莫名其妙。 + */ +export function displayOutboundName(name: string): string { + return pinnedLineOf(name) ?? name +} + +/** + * 把线路名转成只匹配它自己的筛选条件。 + * + * 两件事必须做对: + * 转义正则元字符——线路名形如 `[vmess]DMIT.LAX.Pro.MTYQ`,`[`、`]`、`.` 不转义会匹配错 + * 前后加锚点——内核的筛选是部分匹配,不加锚点时 `香港01` 会把 `香港011` 也匹配进来 + * + * 反引号在内核里是多个筛选条件的分隔符(adapter/outboundgroup/groupbase.go:72-76), + * 线路名里含它就没法用一条正则表达,这种情况返回 null,调用方据此告诉用户做不到。 + */ +export function lineFilter(line: string): string | null { + if (line.includes('`')) return null + const escaped = line.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return `^${escaped}$` +} + +/** + * 造一个只装指定线路的策略组。 + * + * 用 `select` 而不是 `url-test`:组里只有一条线路,没什么可测的,而 `select` 的语义 + * 就是「用这一个」。 + */ +export function makeFilterGroup(line: string, provider: string): ConfigObject | null { + const filter = lineFilter(line) + if (filter === null) return null + return { + name: generatedGroupName(line), + type: 'select', + use: [provider], + filter, + } +} + +/** 配置里所有指向某个名字的位置。用来判断一个生成的组还有没有人在用。 */ +function referencesTo(data: ConfigObject, name: string): number { + let count = 0 + + for (const entry of Array.isArray(data.listeners) ? data.listeners : []) { + if (typeof entry === 'object' && entry !== null) { + if ((entry as Record).proxy === name) count += 1 + } + } + + for (const group of Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : []) { + if (typeof group !== 'object' || group === null) continue + const members = (group as Record).proxies + if (Array.isArray(members) && members.includes(name)) count += 1 + } + + for (const rule of Array.isArray(data.rules) ? data.rules : []) { + // 规则的目标是逗号分隔的最后一段之前那个位置,形如 MATCH,目标 或 DOMAIN,x,目标 + if (typeof rule === 'string' && rule.split(',').some((part) => part.trim() === name)) count += 1 + } + + return count +} + +/** + * 删掉没人再引用的生成组。 + * + * 每次改完配置都跑一遍。不清理的话,改几次出站文件里就攒下一堆没用的组,而用户 + * 并不知道它们是干嘛的——他从来没主动建过它们。 + * + * 只动带前缀的组,用户自己建的一律不碰,哪怕它确实没人引用。 + */ +export function pruneGeneratedGroups(data: ConfigObject): ConfigObject { + const groups = Array.isArray(data['proxy-groups']) ? data['proxy-groups'] : [] + const kept = groups.filter((group) => { + if (typeof group !== 'object' || group === null) return true + const name = (group as Record).name + if (typeof name !== 'string' || !isGeneratedGroup(name)) return true + return referencesTo(data, name) > 0 + }) + + if (kept.length === groups.length) return data + const next = { ...data } + if (kept.length === 0) delete next['proxy-groups'] + else next['proxy-groups'] = kept + return next +} diff --git a/web/components/InboundList.vue b/web/components/InboundList.vue index 20d3b39..82480d2 100644 --- a/web/components/InboundList.vue +++ b/web/components/InboundList.vue @@ -5,6 +5,7 @@ * 只管显示和选中,不碰任何数据。 */ import { inboundTypeLabel, isUnsetOutbound, outboundLabel } from '@shared/inbounds.ts' +import { displayOutboundName } from '@shared/filter-groups.ts' import type { Inbound } from '@shared/inbounds.ts' defineProps<{ inbounds: Inbound[] }>() @@ -26,7 +27,7 @@ const selected = defineModel('selected', { required: true }) 默认 - {{ outboundLabel(inbound.outbound) }} + {{ displayOutboundName(outboundLabel(inbound.outbound)) }} diff --git a/web/views/InboundDetail.vue b/web/views/InboundDetail.vue index 76e57a8..eea9b24 100644 --- a/web/views/InboundDetail.vue +++ b/web/views/InboundDetail.vue @@ -9,6 +9,7 @@ import { computed } from 'vue' import type { Inbound, OutboundMode } from '@shared/inbounds.ts' import { lastSwitchableGroup, resolveChain } from '@shared/outbound-chain.ts' +import { generatedGroupName } from '@shared/filter-groups.ts' import { latestDelay } from '../api' import { kernel, providerLines, runtime } from '../runtime' import OutboundCard from '../components/OutboundCard.vue' @@ -34,6 +35,8 @@ const emit = defineEmits<{ remove: [] select: [group: string, member: string] test: [name: string] + /** 把这一路入站固定到某条订阅线路 */ + pin: [line: string] }>() /** @@ -67,7 +70,11 @@ const BUILTINS: Array<{ mode: OutboundMode; label: string; sub: string }> = [ { mode: { kind: 'global' }, label: 'GLOBAL', sub: '跟随 GLOBAL 组' }, ] -const LINE_LOCKED = '订阅线路无法被入站直接引用,需要通过筛选组间接引入。该功能尚未实现。' +/** 这一路入站是不是已经固定在某条订阅线路上。 */ +function isPinnedTo(line: string): boolean { + const mode = props.inbound.outbound + return mode.kind === 'named' && mode.name === generatedGroupName(line) +}