默认入站:显示可选出站,并给一条转成命名入站的路

上一版把默认入站的整个出站块都藏了,结果策略组也看不见。改成只藏 RULE/DIRECT/GLOBAL
那三张卡(它固定是 RULE),策略组和线路照常列出来当上下文,只是不可点。

顺带修一个显示错误:老式顶层端口没有 listen 字段,实际地址是内核从 allow-lan 和
bind-address 推出来的(listener/listener.go:709-718)——allow-lan 关着时绑的是
127.0.0.1,而界面一律显示 0.0.0.0,会让人以为局域网连得上。

转换时会把老式端口字段删掉并把推导出的地址显式写进 listen:不删会导致同一个端口被声明
两次内核起不来,不写 listen 则以后改 allow-lan 会连带影响这个端口。
This commit is contained in:
im0o
2026-08-10 12:37:33 +08:00
parent cf24755375
commit f97981b146
4 changed files with 126 additions and 14 deletions
+22
View File
@@ -39,6 +39,28 @@ describe('合并两套写法的入站', () => {
})
})
describe('老式端口实际绑在哪', () => {
// 内核源码 listener/listener.go:709-718。界面上一律显示 0.0.0.0 会骗人。
it('allow-lan 没开时只绑本机', () => {
expect(collectInbounds({ 'mixed-port': 7777 })[0]!.listen).toBe('127.0.0.1')
expect(collectInbounds({ 'mixed-port': 7777, 'allow-lan': false })[0]!.listen).toBe('127.0.0.1')
})
it('allow-lan 开了但没指定地址时绑所有网卡', () => {
expect(collectInbounds({ 'mixed-port': 7777, 'allow-lan': true })[0]!.listen).toBe('0.0.0.0')
expect(
collectInbounds({ 'mixed-port': 7777, 'allow-lan': true, 'bind-address': '*' })[0]!.listen,
).toBe('0.0.0.0')
})
it('指定了地址就用那个地址', () => {
expect(
collectInbounds({ 'mixed-port': 7777, 'allow-lan': true, 'bind-address': '192.168.1.5' })[0]!
.listen,
).toBe('192.168.1.5')
})
})
describe('出站去向的四种形态', () => {
const of = (proxy?: string) => {
// 用扩展而不是直接写 proxy: undefined —— 配置里「没有这个键」和「这个键是空」
+18 -1
View File
@@ -74,8 +74,25 @@ export function outboundToProxyField(mode: OutboundMode): string | undefined {
return mode.name
}
/**
* 老式顶层端口实际绑在哪个地址上。
*
* 它没有自己的 listen 字段,地址是内核从两个全局开关推出来的
* (内核源码 `listener/listener.go:709-718`):
* allow-lan 关 → 127.0.0.1,只有本机连得上
* allow-lan 开 → bind-address 指定的地址,写 * 或没写就是所有网卡
* 界面上不能一律显示 0.0.0.0,那会让人以为局域网连得上而实际连不上。
*/
export function legacyListenAddress(data: ConfigObject): string {
if (data['allow-lan'] !== true) return '127.0.0.1'
const bind = data['bind-address']
if (typeof bind !== 'string' || bind === '' || bind === '*') return '0.0.0.0'
return bind
}
export function collectInbounds(data: ConfigObject): Inbound[] {
const result: Inbound[] = []
const legacyListen = legacyListenAddress(data)
for (const { key, type } of LEGACY_PORTS) {
const raw = data[key]
@@ -89,7 +106,7 @@ export function collectInbounds(data: ConfigObject): Inbound[] {
name: key,
type,
port: String(port),
listen: null,
listen: legacyListen,
outbound: { kind: 'rules' },
ruleSet: null,
isDefault: true,
+54 -12
View File
@@ -6,6 +6,7 @@ import type { ProxyInfo } from '../api'
import IconOffline from '~icons/ri/cloud-off-line'
import IconEdit from '~icons/ri/edit-line'
import IconDelete from '~icons/ri/delete-bin-line'
import IconConvert from '~icons/ri/swap-line'
const props = defineProps<{
inbound: Inbound
@@ -18,7 +19,7 @@ const props = defineProps<{
saving: boolean
}>()
const emit = defineEmits<{ pick: [mode: OutboundMode]; edit: []; remove: [] }>()
const emit = defineEmits<{ pick: [mode: OutboundMode]; edit: []; remove: []; convert: [] }>()
interface Option {
key: string
@@ -129,14 +130,20 @@ function delayClass(ms: number | null): string {
</dl>
</header>
<p v-if="locked" class="aside-note locked-note">
由 <code>{{ inbound.legacyKey }}</code> 声明,这种写法没有 proxy 字段,出站固定是 RULE
</p>
<section v-else class="pick">
<section class="pick">
<h3>出站</h3>
<div class="grid">
<div v-if="locked" class="convert">
<p>
由 <code>{{ inbound.legacyKey }}</code> 声明,这种写法没有 proxy 字段,出站固定是
<span class="mono">RULE</span>,下面这些都不能选。
</p>
<button type="button" :disabled="saving" @click="emit('convert')">
<IconConvert /> 转成命名入站
</button>
</div>
<div v-if="!locked" class="grid">
<button
v-for="opt in builtins"
:key="opt.key"
@@ -151,7 +158,7 @@ function delayClass(ms: number | null): string {
</div>
<template v-if="groupOptions.length">
<h4>策略组</h4>
<h4 :class="locked && 'is-dim'">策略组</h4>
<div class="grid">
<button
v-for="opt in groupOptions"
@@ -171,7 +178,7 @@ function delayClass(ms: number | null): string {
</template>
<template v-if="ownOptions.length">
<h4>自有线路</h4>
<h4 :class="locked && 'is-dim'">自有线路</h4>
<div class="grid">
<button
v-for="opt in ownOptions"
@@ -190,7 +197,7 @@ function delayClass(ms: number | null): string {
</div>
</template>
<h4>订阅线路</h4>
<h4 :class="locked && 'is-dim'">订阅线路</h4>
<p v-if="!kernelOnline" class="aside-note">
<IconOffline />
内核未连接。订阅里有哪些线路只有运行中的内核知道
@@ -301,8 +308,43 @@ function delayClass(ms: number | null): string {
cursor: not-allowed;
}
.locked-note {
margin: var(--s-4) 0 0;
.convert {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s-3);
padding: var(--s-3);
border: 1px solid var(--c-border);
border-radius: var(--r-sm);
background: var(--c-surface-sunken);
margin-bottom: var(--s-4);
}
.convert p {
color: var(--c-text-soft);
font-size: var(--t-sm);
line-height: 1.6;
}
.convert button {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-2) var(--s-3);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--c-surface);
font-size: var(--t-sm);
cursor: pointer;
}
.convert button:hover:not(:disabled) {
border-color: var(--c-accent);
color: var(--c-accent-text);
}
.is-dim {
opacity: 0.6;
}
.pick {
+32 -1
View File
@@ -30,6 +30,8 @@ const done = ref('')
const fields = ref<ListenerFields | null>(null)
const formOpen = ref(false)
const formEditing = ref<Inbound | null>(null)
/** 正在把哪个老式端口字段转成命名入站。null 表示不是在做转换 */
const convertingKey = ref<string | null>(null)
onMounted(async () => {
try {
@@ -109,12 +111,30 @@ async function pickOutbound(mode: OutboundMode) {
function openAdd() {
formEditing.value = null
convertingKey.value = null
formOpen.value = true
}
/**
* 把一个老式顶层端口转成命名入站。
*
* 转完它才有 proxy 字段,才能单独指定出站。表单预填端口、类型和它当前实际绑定的地址
* (老式端口的地址是内核从 allow-lan 和 bind-address 推出来的,转换后要显式写进 listen,
* 否则改 allow-lan 会连带影响这个端口)。名字留空,让用户自己起一个——沿用 mixed-port
* 当名字很难看。
*/
function openConvert() {
const target = selected.value
if (!target?.legacyKey) return
formEditing.value = { ...target, name: '' }
convertingKey.value = target.legacyKey
formOpen.value = true
}
function openEdit() {
if (!selected.value || selected.value.isDefault) return
formEditing.value = selected.value
convertingKey.value = null
formOpen.value = true
}
@@ -122,13 +142,23 @@ async function submitForm(entry: ConfigObject) {
const next = clone()
const listeners = Array.isArray(next.listeners) ? (next.listeners as ConfigObject[]) : []
const editingIndex = formEditing.value?.listenerIndex ?? null
const converting = convertingKey.value
if (editingIndex === null) listeners.push(entry)
else listeners[editingIndex] = entry
next.listeners = listeners
// 转换时把老式端口字段去掉,否则同一个端口会被声明两次,内核起不来
if (converting) delete next[converting]
formOpen.value = false
await commit(next, editingIndex === null ? `新增了 ${String(entry.name)}` : '改动已保存')
convertingKey.value = null
const what = converting
? `${converting} 转成了命名入站 ${String(entry.name)}`
: editingIndex === null
? `新增了 ${String(entry.name)}`
: '改动已保存'
await commit(next, what)
if (editingIndex === null) selectedId.value = `listener:${String(listeners.length - 1)}`
}
@@ -211,6 +241,7 @@ async function removeSelected() {
@pick="pickOutbound"
@edit="openEdit"
@remove="removeSelected"
@convert="openConvert"
/>
</div>