默认入站:显示可选出站,并给一条转成命名入站的路
上一版把默认入站的整个出站块都藏了,结果策略组也看不见。改成只藏 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:
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user