SELECT 组的点名成员 AUTO 排第一,不再静默回退到直连

内核给没有选中记录的组默认用第一个成员,选中的线路被订阅刷新删掉时也
静默回退到第一个成员(selector.go:103-112)。之前第一位是 DIRECT,这两种
情况都会让一路入站在无人察觉时变成直连,对按端口定向出站的场景是泄露。

另加 sharedPeers():「共用」标记只在真有别的入站落在同一个组上时才该出现,
只有自己在用时它是噪音。
This commit is contained in:
2026-08-11 14:39:26 +08:00
parent 17f8b3f3fa
commit 6bedce215c
2 changed files with 73 additions and 9 deletions
+42 -5
View File
@@ -4,6 +4,7 @@ import {
chainGroupName,
chainProviderName,
panelGroups,
sharedPeers,
syncGenerated,
isGenerated,
linesMatching,
@@ -84,13 +85,14 @@ describe('筛选预览', () => {
})
describe('一路入站的三个组', () => {
it('SELECT 的成员是 DIRECT 加另外两个组,加上订阅', () => {
// 这三种挑法是所有 clash 配置里固定的那几种,让用户从零拼一遍没有意义
it('SELECT 的成员是另外两个组加 DIRECT,再加上订阅,AUTO 排第一', () => {
// AUTO 必须排第一:内核给没有选中记录的组默认用第一个成员,选中的线路被订阅删掉时
// 也回退到第一个成员。这两种情况落到 DIRECT 上就是静默直连(selector.go:103-112)
const groups = applySetup(base(), 'test1', plain)['proxy-groups'] as ConfigObject[]
expect(groups[0]).toEqual({
name: 'patchbay:test1',
type: 'select',
proxies: ['DIRECT', 'patchbay:test1 AUTO', 'patchbay:test1 FALLBACK'],
proxies: ['patchbay:test1 AUTO', 'patchbay:test1 FALLBACK', 'DIRECT'],
use: ['subs'],
})
})
@@ -147,6 +149,41 @@ describe('一路入站的三个组', () => {
])
})
it('两路没接管的入站落到同一个组时互为共用者', () => {
// 「共用」标记只该出现在这种情况:在其中一路上换选中会连带改掉另一路的去向
const data: ConfigObject = {
...base(),
'mixed-port': 7890,
rules: ['MATCH,PROXY'],
'proxy-groups': [{ name: 'PROXY', type: 'select', use: ['subs'] }],
}
const runtime = { PROXY: { type: 'Selector', now: 'A', all: ['A'] } }
const inbounds = collectInbounds(data)
const legacy = inbounds.find((i) => i.isDefault)!
expect(sharedPeers(data, runtime, inbounds, legacy)).toEqual(['test1'])
})
it('只有自己落在那个组上时没有共用者', () => {
const data: ConfigObject = {
'mixed-port': 7890,
rules: ['MATCH,PROXY'],
'proxy-groups': [{ name: 'PROXY', type: 'select', proxies: ['DIRECT'] }],
}
const runtime = { PROXY: { type: 'Selector', now: 'DIRECT', all: ['DIRECT'] } }
const inbounds = collectInbounds(data)
expect(sharedPeers(data, runtime, inbounds, inbounds[0]!)).toEqual([])
})
it('接管过的入站用的是自己的组,谈不上共用', () => {
const data = applySetup(base(), 'test1', plain)
const runtime = { 'patchbay:test1': { type: 'Selector' } }
const inbounds = collectInbounds(data)
expect(sharedPeers(data, runtime, inbounds, inbounds[0]!)).toEqual([])
})
it('筛选条件三个组共用一份', () => {
const groups = applySetup(base(), 'test1', {
proxy: { sources: ['subs'], filter: '(?i)hk' },
@@ -186,8 +223,8 @@ describe('读取设置', () => {
expect(readSetup(applySetup(base(), 'test1', setup), 'test1')).toEqual(setup)
})
it('开了链式代理时,来源从副本的 url 还原', () => {
// 代理组的 use 这时指向副本,用户选的订阅只能靠 url 相同认回来
it('开了链式代理时,来源从副本的 path 还原', () => {
// 代理组的 use 这时指向副本,用户选的订阅只能靠读同一个缓存文件认回来
const data = applySetup(base(), 'test1', chained)
expect(readSetup(data, 'test1')).toEqual(chained)
})
+31 -4
View File
@@ -3,7 +3,7 @@
*
* 一路入站配三个组,入站的 `proxy:` 指向其中的 SELECT,换线是换那个组的当前选中,
* 不改配置文件(决策记录 0007):
* SELECT 手动选。它的成员是 DIRECT、下面两个组,加上订阅里的线路
* SELECT 手动选。成员是 AUTO、FALLBACK、DIRECT 加订阅里的线路,顺序见 applySetup
* AUTO url-test,自动挑延迟最低的
* FALLBACK fallback,按顺序挑第一个测得通的
* 界面上这三个是并排的 tab。做成三个而不是让用户自己配,是因为它们就是所有 clash 配置
@@ -15,7 +15,7 @@
* 配置里的东西属于谁全靠三条线索还原,不靠解析名字:
* 四个组 名字由入站名推出来
* 订阅副本 override.dialer-proxy 指向哪一路的入口组
* 副本的来源 url 和哪个用户订阅相同
* 副本的来源 path 和哪个用户订阅相同(读同一个缓存文件)
*/
import type { ConfigObject } from '../vendor/config-editor/index.ts'
import { resolveDestination } from './inbounds.ts'
@@ -272,7 +272,7 @@ function makeChainProvider(source: ConfigObject, inbound: string, cachePath: str
* 把一路入站的出站设置写回配置。
*
* 开了链式代理时,三个组的成员都改从订阅副本里取,因为只有副本挂着 `dialer-proxy`。
* 用户选的来源订阅仍然可以从副本的 `url` 还原出来,所以配置文件之外不需要另存状态。
* 用户选的来源订阅仍然可以从副本的 `path` 还原出来,所以配置文件之外不需要另存状态。
*/
export function applySetup(data: ConfigObject, inbound: string, setup: OutboundSetup): ConfigObject {
const next = { ...data }
@@ -305,12 +305,15 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
const { filter } = setup.proxy
let groups = groupsOf(next)
// AUTO 排第一位:内核给没有选中记录的组默认用第一个成员,选中的线路被订阅刷新删掉时
// 也静默回退到第一个成员(内核源码 adapter/outboundgroup/selector.go:103-112)。这两种
// 情况都该落到自动测速上;排 DIRECT 就是静默直连,对按端口定向出站的场景是泄露。
groups = upsert(
groups,
makeGroup(proxyGroupName(inbound), 'select', use, filter, [
'DIRECT',
autoGroupName(inbound),
fallbackGroupName(inbound),
'DIRECT',
]),
)
groups = upsert(groups, makeGroup(autoGroupName(inbound), 'url-test', use, filter))
@@ -480,6 +483,30 @@ export function panelGroups(
}
}
/**
* 还有哪些入站和这一路落在同一个共用组上。
*
* 「共用」这个提醒只在真有人共用时才成立:几路没接管的入站顺着规则表落到同一个手动
* 选择组时,在其中一路上换选中会连带改掉其它几路的去向。只有自己在用就没什么可提醒的。
*/
export function sharedPeers(
data: ConfigObject,
runtime: Record<string, RuntimeNode>,
inbounds: Inbound[],
inbound: Inbound,
): string[] {
const own = panelGroups(data, runtime, inbound)
if (!own.shared) return []
const group = own.groups[0]?.name
return inbounds
.filter((other) => other.id !== inbound.id)
.filter((other) => {
const panel = panelGroups(data, runtime, other)
return panel.shared && panel.groups[0]?.name === group
})
.map((other) => other.name)
}
/**
* 筛选条件会留下哪些线路。界面上用来在保存前当场告诉用户「匹配到几条」。
*