diff --git a/CONTEXT.md b/CONTEXT.md index 3ea236a..4308c44 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -10,9 +10,9 @@ mihomo 上的一个监听端口,客户端连上它把流量交进来。 _Avoid_: inbound、监听器、listener、进站 -**默认入站**: -由配置文件顶层端口字段声明的入站(`mixed-port`、`port`、`socks-port`、`redir-port`、`tproxy-port`)。每种类型只能有一个,且无法单独指定出站。界面上不可删除。 -_Avoid_: 主端口、老式端口、默认端口 +**legacy 入站**: +由配置文件顶层端口字段声明的入站(`mixed-port`、`port`、`socks-port`、`redir-port`、`tproxy-port`)。每种类型只能有一个,且无法单独指定出站。界面上不可删除,可升级为命名入站。 +_Avoid_: 默认入站、主端口、老式端口、默认端口 **命名入站**: 由 `listeners:` 声明的入站。数量不限,每个可以有自己的出站和附加规则。 @@ -102,6 +102,6 @@ _Avoid_: sub-rules、子规则、私有规则 Patchbay 负责读写的配置段落。其余段落原样保留、界面上也不呈现。 _Avoid_: 托管段落、受控段落 -**默认路由**: -默认入站在界面上的呈现。它对应配置文件顶层的端口字段而非 `listeners:`,因此不可删除。 -_Avoid_: 主路由、根路由 +**legacy 路由**: +legacy 入站在界面上的呈现。它对应配置文件顶层的端口字段而非 `listeners:`,因此不可删除。 +_Avoid_: 默认路由、主路由、根路由 diff --git a/shared/connections.test.ts b/shared/connections.test.ts index 7acb1fd..4666089 100644 --- a/shared/connections.test.ts +++ b/shared/connections.test.ts @@ -159,7 +159,7 @@ describe('按入站聚合', () => { ]) }) - it('默认入站用内核给的内部名,附上端口', () => { + it('legacy 入站用内核给的内部名,附上端口', () => { const list = [conn({ metadata: meta({ inboundName: 'DEFAULT-MIXED', inboundPort: '17890' }) })] expect(groupByInbound(list)[0]?.label).toBe('DEFAULT-MIXED(17890)') }) diff --git a/shared/connections.ts b/shared/connections.ts index 6aa0ffd..aca3e8a 100644 --- a/shared/connections.ts +++ b/shared/connections.ts @@ -10,7 +10,7 @@ export interface ConnectionMeta { host: string destinationIP: string destinationPort: string - /** 从哪一路入站进来:命名入站是它的名字,默认入站是内核造的 DEFAULT-* */ + /** 从哪一路入站进来:命名入站是它的名字,legacy 入站是内核造的 DEFAULT-* */ inboundName: string inboundPort: string } @@ -100,7 +100,7 @@ export function filterConnections(list: ConnectionItem[], query: string): Connec /** 一路入站当前有多少条连接。 */ export interface InboundUse { - /** 命名入站显示名字;默认入站内核只给 DEFAULT-* 这类内部名,附上端口好认 */ + /** 命名入站显示名字;legacy 入站内核只给 DEFAULT-* 这类内部名,附上端口好认 */ label: string count: number upload: number diff --git a/shared/inbound-groups.test.ts b/shared/inbound-groups.test.ts index 5e5851c..740302d 100644 --- a/shared/inbound-groups.test.ts +++ b/shared/inbound-groups.test.ts @@ -15,6 +15,7 @@ import { proxyGroupName, pruneGenerated, readSetup, + upgradeLegacyInbound, } from './inbound-groups.ts' import type { OutboundSetup } from './inbound-groups.ts' import { collectInbounds } from './inbounds.ts' @@ -427,3 +428,73 @@ describe('附加规则', () => { 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() + }) +}) diff --git a/shared/inbound-groups.ts b/shared/inbound-groups.ts index abeacfc..ef91b77 100644 --- a/shared/inbound-groups.ts +++ b/shared/inbound-groups.ts @@ -18,7 +18,7 @@ * 副本的来源 path 和哪个用户订阅相同(读同一个缓存文件) */ 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 { lastSwitchableGroup, resolveChain } 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 } +/** + * 把一路 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 { let count = 0 diff --git a/shared/inbounds.test.ts b/shared/inbounds.test.ts index edcad8b..4d575d6 100644 --- a/shared/inbounds.test.ts +++ b/shared/inbounds.test.ts @@ -42,7 +42,7 @@ describe('合并两套写法的入站', () => { expect(list[1]!.listenerIndex).toBe(0) }) - it('默认入站的出站永远是「跟着规则走」,因为旧写法没处写', () => { + it('legacy 入站的出站永远是「跟着规则走」,因为旧写法没处写', () => { expect(collectInbounds({ 'mixed-port': 7777 })[0]!.outbound).toEqual({ kind: 'rules' }) }) }) diff --git a/web/components/InboundList.vue b/web/components/InboundList.vue index bbaa40a..5dc9cfe 100644 --- a/web/components/InboundList.vue +++ b/web/components/InboundList.vue @@ -45,7 +45,7 @@ function outboundOf(inbound: Inbound): { text: string; chained: boolean } { {{ inboundTypeLabel(inbound.type) }} {{ inbound.name }} - 默认 + legacy diff --git a/web/dashboard.ts b/web/dashboard.ts index b678012..84a1267 100644 --- a/web/dashboard.ts +++ b/web/dashboard.ts @@ -20,7 +20,7 @@ export function modeLabel(mode: unknown): string { } } -/** 一个监听中的代理端口。name 用配置键原词,和路由页默认入站的命名一致。 */ +/** 一个监听中的代理端口。name 用配置键原词,和路由页legacy 入站的命名一致。 */ export interface KernelPort { name: string port: number diff --git a/web/views/InboundDetail.vue b/web/views/InboundDetail.vue index 6a4a295..1ea716a 100644 --- a/web/views/InboundDetail.vue +++ b/web/views/InboundDetail.vue @@ -32,6 +32,7 @@ import OutboundCard from '../components/OutboundCard.vue' import SourcePicker from '../components/SourcePicker.vue' import IconEdit from '~icons/ri/edit-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 IconOffline from '~icons/ri/cloud-off-line' import IconAdd from '~icons/ri/add-line' @@ -61,6 +62,8 @@ const props = defineProps<{ const emit = defineEmits<{ edit: [] remove: [] + /** 把 legacy 入站升级成命名入站。写配置文件,由父组件先弹确认 */ + upgrade: [] /** 切换某个策略组当前选中谁 */ select: [group: string, member: string] test: [name: string, provider: string] @@ -90,7 +93,7 @@ const lanMissing = computed(() => missingLanRules(props.subRules).length) */ const subMatchAt = computed(() => firstMatchIndex(props.subRules)) -/** 默认入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删。 */ +/** legacy 入站由配置顶层的端口字段声明,没有 proxy 字段可写,也不能删,只能升级。 */ const locked = computed(() => props.inbound.isDefault) const activeTab = ref('') @@ -448,10 +451,15 @@ const complete = computed(
{{ inbound.port }} {{ inbound.name }} -
- -
@@ -842,16 +850,21 @@ const complete = computed( cursor: pointer; } -.acts button:hover { +.acts button:hover:not(:disabled) { border-color: var(--c-accent); color: var(--c-accent-text); } -.acts button.danger:hover { +.acts button.danger:hover:not(:disabled) { border-color: var(--c-danger); color: var(--c-danger); } +.acts button:disabled { + color: var(--c-text-faint); + cursor: not-allowed; +} + .facts { display: grid; grid-template-columns: auto 1fr; diff --git a/web/views/RouterPage.vue b/web/views/RouterPage.vue index 7205093..84be55c 100644 --- a/web/views/RouterPage.vue +++ b/web/views/RouterPage.vue @@ -22,6 +22,7 @@ import { sharedPeers, syncGenerated, readSetup, + upgradeLegacyInbound, userProviders, } from '@shared/inbound-groups.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} 已改为逐条选线路`) } +/** 待确认升级的那一路 legacy 入站。非 null 时确认对话框打开着。 */ +const upgrading = ref(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() { formEditing.value = null formOpen.value = true @@ -368,6 +393,7 @@ async function removeConfirmed() { :saving="saving" @edit="openEdit" @remove="askRemove" + @upgrade="askUpgrade" @select="selectMember" @test="test" @save="saveSetup" @@ -400,6 +426,19 @@ async function removeConfirmed() { @confirm="removeConfirmed" /> + +