legacy 入站可升级为命名入站
release / release (push) Successful in 26s

顶层端口字段没有 proxy 可写(listener/inbound/base.go:97),界面上
切不了订阅。升级删掉顶层键、建同端口的 listener:listen 按 allow-lan
推导后写死,全局规则里 MATCH 之前的部分复制成附加规则,来源照抄
MATCH 目标组的 use:,让流量去向尽量不变。udp 不写,命名入站解析时
默认就是开的(listener/parse.go:52)。
「默认」tag 随术语表改成 legacy,旧叫法进 avoid 列表。
This commit is contained in:
2026-08-12 17:30:47 +08:00
parent d6942c5702
commit b1b251b897
10 changed files with 193 additions and 20 deletions
+6 -6
View File
@@ -10,9 +10,9 @@
mihomo 上的一个监听端口,客户端连上它把流量交进来。 mihomo 上的一个监听端口,客户端连上它把流量交进来。
_Avoid_: inbound、监听器、listener、进站 _Avoid_: inbound、监听器、listener、进站
**默认入站**: **legacy 入站**:
由配置文件顶层端口字段声明的入站(`mixed-port`、`port`、`socks-port`、`redir-port`、`tproxy-port`)。每种类型只能有一个,且无法单独指定出站。界面上不可删除。 由配置文件顶层端口字段声明的入站(`mixed-port`、`port`、`socks-port`、`redir-port`、`tproxy-port`)。每种类型只能有一个,且无法单独指定出站。界面上不可删除,可升级为命名入站。
_Avoid_: 主端口、老式端口、默认端口 _Avoid_: 默认入站、主端口、老式端口、默认端口
**命名入站**: **命名入站**:
由 `listeners:` 声明的入站。数量不限,每个可以有自己的出站和附加规则。 由 `listeners:` 声明的入站。数量不限,每个可以有自己的出站和附加规则。
@@ -102,6 +102,6 @@ _Avoid_: sub-rules、子规则、私有规则
Patchbay 负责读写的配置段落。其余段落原样保留、界面上也不呈现。 Patchbay 负责读写的配置段落。其余段落原样保留、界面上也不呈现。
_Avoid_: 托管段落、受控段落 _Avoid_: 托管段落、受控段落
**默认路由**: **legacy 路由**:
默认入站在界面上的呈现。它对应配置文件顶层的端口字段而非 `listeners:`,因此不可删除。 legacy 入站在界面上的呈现。它对应配置文件顶层的端口字段而非 `listeners:`,因此不可删除。
_Avoid_: 主路由、根路由 _Avoid_: 默认路由、主路由、根路由
+1 -1
View File
@@ -159,7 +159,7 @@ describe('按入站聚合', () => {
]) ])
}) })
it('默认入站用内核给的内部名,附上端口', () => { it('legacy 入站用内核给的内部名,附上端口', () => {
const list = [conn({ metadata: meta({ inboundName: 'DEFAULT-MIXED', inboundPort: '17890' }) })] const list = [conn({ metadata: meta({ inboundName: 'DEFAULT-MIXED', inboundPort: '17890' }) })]
expect(groupByInbound(list)[0]?.label).toBe('DEFAULT-MIXED(17890)') expect(groupByInbound(list)[0]?.label).toBe('DEFAULT-MIXED(17890)')
}) })
+2 -2
View File
@@ -10,7 +10,7 @@ export interface ConnectionMeta {
host: string host: string
destinationIP: string destinationIP: string
destinationPort: string destinationPort: string
/** 从哪一路入站进来:命名入站是它的名字,默认入站是内核造的 DEFAULT-* */ /** 从哪一路入站进来:命名入站是它的名字,legacy 入站是内核造的 DEFAULT-* */
inboundName: string inboundName: string
inboundPort: string inboundPort: string
} }
@@ -100,7 +100,7 @@ export function filterConnections(list: ConnectionItem[], query: string): Connec
/** 一路入站当前有多少条连接。 */ /** 一路入站当前有多少条连接。 */
export interface InboundUse { export interface InboundUse {
/** 命名入站显示名字;默认入站内核只给 DEFAULT-* 这类内部名,附上端口好认 */ /** 命名入站显示名字;legacy 入站内核只给 DEFAULT-* 这类内部名,附上端口好认 */
label: string label: string
count: number count: number
upload: number upload: number
+71
View File
@@ -15,6 +15,7 @@ import {
proxyGroupName, proxyGroupName,
pruneGenerated, pruneGenerated,
readSetup, readSetup,
upgradeLegacyInbound,
} from './inbound-groups.ts' } from './inbound-groups.ts'
import type { OutboundSetup } from './inbound-groups.ts' import type { OutboundSetup } from './inbound-groups.ts'
import { collectInbounds } from './inbounds.ts' import { collectInbounds } from './inbounds.ts'
@@ -427,3 +428,73 @@ describe('附加规则', () => {
expect((pruneGenerated(data)['sub-rules'] as ConfigObject).mine).toBeDefined() expect((pruneGenerated(data)['sub-rules'] as ConfigObject).mine).toBeDefined()
}) })
}) })
describe('legacy 入站升级', () => {
/** 形状仿照目标机器:一个 legacy 混合端口,全局规则是内网直连加 MATCH 兜底。 */
function legacyBase(): ConfigObject {
return {
'mixed-port': 7890,
'allow-lan': true,
'proxy-providers': {
subs: { type: 'http', url: URL, path: './providers/subs.yaml' },
other: { type: 'http', url: `${URL}2`, path: './providers/other.yaml' },
},
'proxy-groups': [{ name: 'PROXY', type: 'select', proxies: ['DIRECT'], use: ['subs'] }],
rules: [
'IP-CIDR,192.168.0.0/16,DIRECT,no-resolve',
'MATCH,PROXY',
'DOMAIN,dead.example,DIRECT',
],
}
}
const legacyInbound = (data: ConfigObject) => collectInbounds(data).find((i) => i.isDefault)!
it('删掉顶层键,建同端口的命名入站,listen 按 allow-lan 推导写死', () => {
const data = legacyBase()
const next = upgradeLegacyInbound(data, legacyInbound(data))!
expect(next['mixed-port']).toBeUndefined()
expect((next.listeners as ConfigObject[]).at(-1)).toMatchObject({
name: 'mixed-port',
type: 'mixed',
port: 7890,
listen: '0.0.0.0',
})
})
it('allow-lan 关着时 listen 写 127.0.0.1', () => {
const data = { ...legacyBase(), 'allow-lan': false }
const next = upgradeLegacyInbound(data, legacyInbound(data))!
expect(((next.listeners as ConfigObject[]).at(-1) as ConfigObject).listen).toBe('127.0.0.1')
})
it('全局规则里第一条 MATCH 之前的部分复制成附加规则,全局规则原文不动', () => {
const data = legacyBase()
const next = upgradeLegacyInbound(data, legacyInbound(data))!
expect(readSubRules(next, 'mixed-port')).toEqual(['IP-CIDR,192.168.0.0/16,DIRECT,no-resolve'])
expect(next.rules).toEqual(legacyBase().rules)
})
it('升级完就在专属组模式下,来源照抄 MATCH 目标组的 use', () => {
const data = legacyBase()
const next = upgradeLegacyInbound(data, legacyInbound(data))!
expect(readSetup(next, 'mixed-port')).toEqual({
proxy: { sources: ['subs'], filter: null },
chain: null,
})
expect(managedWiring(next, collectInbounds(next).find((i) => i.name === 'mixed-port')!)).toBe(
true,
)
})
it('MATCH 目标组没有 use 时退回全部订阅', () => {
const data = legacyBase()
;(data['proxy-groups'] as ConfigObject[])[0] = { name: 'PROXY', type: 'select', proxies: ['DIRECT'] }
const next = upgradeLegacyInbound(data, legacyInbound(data))!
expect(readSetup(next, 'mixed-port')?.proxy.sources).toEqual(['subs', 'other'])
})
it('已有同名入站时返回 null,不写出重名的 listener', () => {
const data = { ...legacyBase(), listeners: [{ name: 'mixed-port', type: 'mixed', port: 1 }] }
expect(upgradeLegacyInbound(data, legacyInbound(data))).toBeNull()
})
})
+51 -1
View File
@@ -18,7 +18,7 @@
* 副本的来源 path 和哪个用户订阅相同(读同一个缓存文件) * 副本的来源 path 和哪个用户订阅相同(读同一个缓存文件)
*/ */
import type { ConfigObject } from '../vendor/config-editor/index.ts' import type { ConfigObject } from '../vendor/config-editor/index.ts'
import { resolveDestination } from './inbounds.ts' import { fallbackTarget, legacyListenAddress, resolveDestination } from './inbounds.ts'
import type { Inbound } from './inbounds.ts' import type { Inbound } from './inbounds.ts'
import { lastSwitchableGroup, resolveChain } from './outbound-chain.ts' import { lastSwitchableGroup, resolveChain } from './outbound-chain.ts'
import type { RuntimeNode } from './outbound-chain.ts' import type { RuntimeNode } from './outbound-chain.ts'
@@ -433,6 +433,56 @@ export function applySetup(data: ConfigObject, inbound: string, setup: OutboundS
return next return next
} }
/**
* 把一路 legacy 入站升级成命名入站,并直接进专属组模式。
*
* legacy 入站(顶层端口字段)没有 `proxy` 可写,只能顺全局规则走,在界面上
* 切不了订阅。升级 = 删掉顶层端口键,在 `listeners:` 里建一个行为等价的入站:
* 端口 原样
* listen 按 allow-lan 推导后写死。升级后改 allow-lan 不再影响它,这是升级的
* 语义变化之一,界面文案要说
* udp 不写。命名入站解析时默认就是开的(内核源码 listener/parse.go:52)
*
* 行为等价还差分流:它原来查全局规则,升级后整表改查自己的附加规则,所以把
* 全局规则里第一条 MATCH 之前的部分复制过来(MATCH 之后的行本来就永远轮不到)。
* 全局规则原文不动——那是用户手写的段落,何况可能还有别的 legacy 入站在用。
* 专属组的来源照抄原 MATCH 目标组的 `use:`,流量的实际去向尽量不变;
* 目标组没有 `use:` 时退回全部订阅。
*
* 同名入站已存在时返回 null,调用方据此报错而不是写出重名的 listener。
*/
export function upgradeLegacyInbound(data: ConfigObject, inbound: Inbound): ConfigObject | null {
if (inbound.legacyKey === null) return null
const port = Number(inbound.port)
if (!Number.isFinite(port) || port <= 0) return null
const listeners = Array.isArray(data.listeners) ? data.listeners : []
if (listeners.some((entry) => isPlainObject(entry) && entry.name === inbound.name)) return null
const next = { ...data }
delete next[inbound.legacyKey]
next.listeners = [
...listeners,
{ name: inbound.name, type: inbound.type, port, listen: legacyListenAddress(data) },
]
const rules: string[] = []
for (const rule of Array.isArray(data.rules) ? data.rules : []) {
if (typeof rule !== 'string') continue
if (rule.split(',')[0]?.trim().toUpperCase() === 'MATCH') break
rules.push(rule)
}
const target = fallbackTarget(data)
const providers = new Set(userProviders(data))
const uses = (target ? (groupUses(data, target) ?? []) : []).filter((name) => providers.has(name))
const sources = uses.length > 0 ? uses : userProviders(data)
return applySetup(applySubRules(next, inbound.name, rules), inbound.name, {
proxy: { sources, filter: null },
chain: null,
})
}
/** 配置里指向某个名字的地方有几处。用来判断生成的东西还有没有人在用。 */ /** 配置里指向某个名字的地方有几处。用来判断生成的东西还有没有人在用。 */
function referencesTo(data: ConfigObject, name: string): number { function referencesTo(data: ConfigObject, name: string): number {
let count = 0 let count = 0
+1 -1
View File
@@ -42,7 +42,7 @@ describe('合并两套写法的入站', () => {
expect(list[1]!.listenerIndex).toBe(0) expect(list[1]!.listenerIndex).toBe(0)
}) })
it('默认入站的出站永远是「跟着规则走」,因为旧写法没处写', () => { it('legacy 入站的出站永远是「跟着规则走」,因为旧写法没处写', () => {
expect(collectInbounds({ 'mixed-port': 7777 })[0]!.outbound).toEqual({ kind: 'rules' }) expect(collectInbounds({ 'mixed-port': 7777 })[0]!.outbound).toEqual({ kind: 'rules' })
}) })
}) })
+1 -1
View File
@@ -45,7 +45,7 @@ function outboundOf(inbound: Inbound): { text: string; chained: boolean } {
<span class="type mono">{{ inboundTypeLabel(inbound.type) }}</span> <span class="type mono">{{ inboundTypeLabel(inbound.type) }}</span>
<span class="name"> <span class="name">
{{ inbound.name }} {{ inbound.name }}
<span v-if="inbound.isDefault" class="tag">默认</span> <span v-if="inbound.isDefault" class="tag">legacy</span>
</span> </span>
<span :class="['out mono', isUnsetOutbound(inbound.outbound) && 'is-muted']"> <span :class="['out mono', isUnsetOutbound(inbound.outbound) && 'is-muted']">
<span v-if="outboundOf(inbound).chained" class="chain" title="经过链式代理"> <span v-if="outboundOf(inbound).chained" class="chain" title="经过链式代理">
+1 -1
View File
@@ -20,7 +20,7 @@ export function modeLabel(mode: unknown): string {
} }
} }
/** 一个监听中的代理端口。name 用配置键原词,和路由页默认入站的命名一致。 */ /** 一个监听中的代理端口。name 用配置键原词,和路由页legacy 入站的命名一致。 */
export interface KernelPort { export interface KernelPort {
name: string name: string
port: number port: number
+20 -7
View File
@@ -32,6 +32,7 @@ import OutboundCard from '../components/OutboundCard.vue'
import SourcePicker from '../components/SourcePicker.vue' import SourcePicker from '../components/SourcePicker.vue'
import IconEdit from '~icons/ri/edit-line' import IconEdit from '~icons/ri/edit-line'
import IconDelete from '~icons/ri/delete-bin-line' import IconDelete from '~icons/ri/delete-bin-line'
import IconUpgrade from '~icons/ri/arrow-up-circle-line'
import IconRight from '~icons/ri/arrow-right-s-line' import IconRight from '~icons/ri/arrow-right-s-line'
import IconOffline from '~icons/ri/cloud-off-line' import IconOffline from '~icons/ri/cloud-off-line'
import IconAdd from '~icons/ri/add-line' import IconAdd from '~icons/ri/add-line'
@@ -61,6 +62,8 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
edit: [] edit: []
remove: [] remove: []
/** 把 legacy 入站升级成命名入站。写配置文件,由父组件先弹确认 */
upgrade: []
/** 切换某个策略组当前选中谁 */ /** 切换某个策略组当前选中谁 */
select: [group: string, member: string] select: [group: string, member: string]
test: [name: string, provider: string] test: [name: string, provider: string]
@@ -90,7 +93,7 @@ const lanMissing = computed(() => missingLanRules(props.subRules).length)
*/ */
const subMatchAt = computed(() => firstMatchIndex(props.subRules)) const subMatchAt = computed(() => firstMatchIndex(props.subRules))
/** 默认入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删。 */ /** legacy 入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删,只能升级。 */
const locked = computed(() => props.inbound.isDefault) const locked = computed(() => props.inbound.isDefault)
const activeTab = ref('') const activeTab = ref('')
@@ -448,10 +451,15 @@ const complete = computed(
<div class="title"> <div class="title">
<span class="port mono">{{ inbound.port }}</span> <span class="port mono">{{ inbound.port }}</span>
<span class="name">{{ inbound.name }}</span> <span class="name">{{ inbound.name }}</span>
<div v-if="!locked" class="acts"> <div class="acts">
<button type="button" @click="emit('edit')"><IconEdit /> 编辑</button> <template v-if="!locked">
<button type="button" class="danger" @click="emit('remove')"> <button type="button" @click="emit('edit')"><IconEdit /> 编辑</button>
<IconDelete /> 删除 <button type="button" class="danger" @click="emit('remove')">
<IconDelete /> 删除
</button>
</template>
<button v-else type="button" :disabled="saving" @click="emit('upgrade')">
<IconUpgrade /> 改为命名入站
</button> </button>
</div> </div>
</div> </div>
@@ -842,16 +850,21 @@ const complete = computed(
cursor: pointer; cursor: pointer;
} }
.acts button:hover { .acts button:hover:not(:disabled) {
border-color: var(--c-accent); border-color: var(--c-accent);
color: var(--c-accent-text); color: var(--c-accent-text);
} }
.acts button.danger:hover { .acts button.danger:hover:not(:disabled) {
border-color: var(--c-danger); border-color: var(--c-danger);
color: var(--c-danger); color: var(--c-danger);
} }
.acts button:disabled {
color: var(--c-text-faint);
cursor: not-allowed;
}
.facts { .facts {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
+39
View File
@@ -22,6 +22,7 @@ import {
sharedPeers, sharedPeers,
syncGenerated, syncGenerated,
readSetup, readSetup,
upgradeLegacyInbound,
userProviders, userProviders,
} from '@shared/inbound-groups.ts' } from '@shared/inbound-groups.ts'
import { missingLanRules, moveRule, ruleProvidersOf } from '@shared/rules.ts' import { missingLanRules, moveRule, ruleProvidersOf } from '@shared/rules.ts'
@@ -177,6 +178,30 @@ async function adopt() {
await commit(applySetup(clone(), target.name, defaultSetup()), `${target.name} 已改为逐条选线路`) await commit(applySetup(clone(), target.name, defaultSetup()), `${target.name} 已改为逐条选线路`)
} }
/** 待确认升级的那一路 legacy 入站。非 null 时确认对话框打开着。 */
const upgrading = ref<Inbound | null>(null)
function askUpgrade() {
const target = selected.value
if (!target || target.legacyKey === null || saving.value) return
upgrading.value = target
}
async function upgradeConfirmed() {
const target = upgrading.value
upgrading.value = null
if (!target || saving.value) return
const next = upgradeLegacyInbound(clone(), target)
if (!next) {
toast(`已有名为 ${target.name} 的入站,无法升级`, { kind: 'error' })
return
}
// 新入站追加在 listeners 末尾,升级成功后把选中跟过去,否则选中会被重置到第一行
const index = Array.isArray(props.data.listeners) ? props.data.listeners.length : 0
const ok = await commit(next, `${target.name} 已改为命名入站`)
if (ok) selectedId.value = `listener:${String(index)}`
}
function openAdd() { function openAdd() {
formEditing.value = null formEditing.value = null
formOpen.value = true formOpen.value = true
@@ -368,6 +393,7 @@ async function removeConfirmed() {
:saving="saving" :saving="saving"
@edit="openEdit" @edit="openEdit"
@remove="askRemove" @remove="askRemove"
@upgrade="askUpgrade"
@select="selectMember" @select="selectMember"
@test="test" @test="test"
@save="saveSetup" @save="saveSetup"
@@ -400,6 +426,19 @@ async function removeConfirmed() {
@confirm="removeConfirmed" @confirm="removeConfirmed"
/> />
<ConfirmDialog
:open="upgrading !== null"
:message="
upgrading
? `将「${upgrading.name}」改为命名入站?端口不变,此后它的流量改查自己的附加规则` +
`(全局规则中 MATCH 之前的部分会复制过来),可以单独选订阅和线路。`
: ''
"
action="升级"
@close="upgrading = null"
@confirm="upgradeConfirmed"
/>
<RuleForm <RuleForm
:open="subFormOpen" :open="subFormOpen"
:editing="subEditing === null ? null : { index: subEditing, raw: subRules[subEditing]! }" :editing="subEditing === null ? null : { index: subEditing, raw: subRules[subEditing]! }"