feat(vpn): multi-client WireGuard portal with attached peers (#2502)

* feat(peer): support protocol-agnostic attached peers

Add locally attached peers backed by independent, peer-level portable
managers and authenticated in-process ring connections. Carry trusted
connection provenance through packet admission so attached relay
privileges cannot be forged through packet headers.

Let every peer manager own ACL loading, sanitized policy updates, route
refresh, and runtime cleanup. In Secure Mode, grant attached identities
ephemeral credentials instead of sharing administrator and group secrets.

* feat(vpn): add reusable attached-peer portal runtime

Add a protocol-neutral portal runtime that converts authenticated client
sessions into attached EasyTier peers. Own per-client generations,
status, packet forwarding, address translation, and peer cleanup without
knowing the transport protocol.

Add transactional IPv4 source and destination rewriting with correct
IPv4, TCP, UDP, ICMP, and quoted-packet checksum updates. Keep the old
production portal path temporarily active until the WireGuard adapter is
migrated in the next change.

* feat(wireguard): attach named clients through peer portal

Replace the monolithic WireGuard portal with a native adapter that owns
key derivation, UDP demultiplexing, reauthentication, roaming, and
bounded per-client packet queues. Hand authenticated sessions to the
generic portal runtime for peer lifecycle and IPv4 translation.

Move portal configuration into the core instance model, require a
dedicated server key, and preserve existing listener, CLI, and runtime
configuration behavior. Reject runtime address conflicts before
publishing shared configuration.

* feat(vpn): expose per-client portal status

Project configured clients and their runtime state through the portal
RPC, including generated client configuration, listener, peer identity,
endpoint, tunnel address, ACL groups, and errors. Keep private client
configuration out of the broad instance-info response and expose the
explicit RPC through the CLI and Tauri bridge.

* feat(vpn): add portal configuration to web clients

Expose WireGuard portal listener, key, client, ACL group, and runtime
status fields in the shared frontend library, Web dashboard, and Tauri
client. Preserve UUID and uint64 values across protobuf JSON
boundaries, keep dynamic client editor rows stable, and document the
portal workflow.

* test(vpn): cover multi-client and roaming WireGuard portals

Add two three-node integration tests for the WireGuard VPN portal.

The multi-client test connects two kernel WireGuard clients from
separate network namespaces, verifies per-client connectivity to mesh
nodes, and exercises cross-client traffic that runs the IPv4 source
and destination translation in both directions. A TCP echo exchange
through the portal additionally covers the TCP pseudo-header checksum
rewrite path that ICMP-only ping tests miss, and portal status
snapshots must report both clients online with distinct peer ids and
correctly learned tunnel addresses.

The roaming test swaps the client namespace address (delete the old
address, then add the new one) so the kernel WireGuard source cache is
invalidated and the client keeps sending under the same session from
the new source, exactly like a real network change. The portal must
update the client endpoint on the same peer id via the data path
(same generation, no re-handshake, no detach/reconnect) while
connectivity to mesh nodes is preserved.

Supporting changes: run_wireguard_client now takes an interface name,
and the shared namespace topology gains net_f (10.1.2.5) on the portal
bridge for the second client.
This commit is contained in:
KKRainbow
2026-08-21 10:59:05 +08:00
committed by GitHub
parent 57eb6908f4
commit 62e4fd15e9
63 changed files with 7759 additions and 1216 deletions
@@ -20,12 +20,12 @@ const {
DEFAULT_NETWORK_CONFIG,
NetworkingMethod,
normalizeNetworkConfig,
normalizeVpnPortalInfo,
toBackendNetworkConfig,
} = NetworkTypes
const BOOLEAN_CONFIG_FIELDS = [
'dhcp',
'enable_vpn_portal',
'advanced_settings',
'latency_first',
'use_smoltcp',
@@ -60,6 +60,13 @@ const BOOLEAN_CONFIG_FIELDS = [
'disable_tcp_hole_punching',
]
const LEGACY_VPN_PORTAL_FIELDS = [
'enable_vpn_portal',
'vpn_portal_listen_port',
'vpn_portal_client_network_addr',
'vpn_portal_client_network_len',
]
function readGeneratedNetworkConfigFields() {
const source = ts.createSourceFile(
generatedApiManagePath,
@@ -121,10 +128,15 @@ function allFieldFixture() {
},
],
proxy_cidrs: ['10.10.0.0/16', '192.168.2.0/24->10.99.0.0/24'],
enable_vpn_portal: true,
vpn_portal_listen_port: 23000,
vpn_portal_client_network_addr: '10.88.0.0',
vpn_portal_client_network_len: 24,
vpn_portal_config: {
wireguard_listen: '0.0.0.0:23000',
wireguard_private_key: 'portal-private-key',
clients: [{
name: 'phone-a',
virtual_ip: '10.9.8.10',
groups: ['ops'],
}],
},
advanced_settings: true,
listener_urls: ['tcp://0.0.0.0:12010', 'udp://0.0.0.0:12010'],
latency_first: true,
@@ -239,6 +251,7 @@ function allFieldFixture() {
function assertFixtureCoversGeneratedFields() {
const generatedFields = readGeneratedNetworkConfigFields()
.filter((field) => !LEGACY_VPN_PORTAL_FIELDS.includes(field))
const fixtureFields = new Set(Object.keys(allFieldFixture()))
const missing = generatedFields.filter((field) => !fixtureFields.has(field))
@@ -258,7 +271,8 @@ function assertFullFieldRoundTrip() {
const backend = toBackendNetworkConfig(normalized)
expectNoCamelCaseKeys(backend)
for (const field of readGeneratedNetworkConfigFields()) {
for (const field of readGeneratedNetworkConfigFields()
.filter((field) => !LEGACY_VPN_PORTAL_FIELDS.includes(field))) {
assert.ok(field in backend, `backend JSON should include fixture field ${field}`)
}
@@ -269,6 +283,9 @@ function assertFullFieldRoundTrip() {
assert.deepEqual(backend.peers[1], { uri: 'udp://peer-b:11010' })
assert.equal(backend.data_compress_algo, 'Zstd')
assert.equal(backend.instance_recv_bps_limit, '9007199254740993')
assert.equal(backend.vpn_portal_config.wireguard_listen, '0.0.0.0:23000')
assert.equal(backend.vpn_portal_config.clients[0].name, 'phone-a')
assert.deepEqual(backend.vpn_portal_config.clients[0].groups, ['ops'])
assert.equal(backend.secure_mode.enabled, true)
assert.equal(backend.secure_mode.local_private_key, 'private-key')
assert.equal(backend.acl.acl_v1.chains[0].chain_type, 'Forward')
@@ -279,6 +296,38 @@ function assertFullFieldRoundTrip() {
assert.equal(backend.socket_mark, 1234)
}
function assertLegacyVpnPortalFieldsReachBackendValidation() {
const backend = toBackendNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
enable_vpn_portal: true,
vpn_portal_listen_port: 22022,
vpn_portal_client_network_addr: '10.88.0.0',
vpn_portal_client_network_len: 24,
})
assert.equal(backend.enable_vpn_portal, true)
assert.equal(backend.vpn_portal_listen_port, 22022)
assert.equal(backend.vpn_portal_client_network_addr, '10.88.0.0')
assert.equal(backend.vpn_portal_client_network_len, 24)
}
function assertVpnPortalRpcJsonNormalization() {
const info = normalizeVpnPortalInfo({
vpn_type: 'wireguard',
listener: '0.0.0.0:22022',
clients: [{
name: 'phone-a',
virtual_ip: '10.9.8.10',
groups: ['ops'],
state: 'VPN_PORTAL_CLIENT_STATE_ONLINE',
client_config: '[Interface]',
}],
})
assert.equal(info.clients[0].state, 3)
assert.deepEqual(info.connected_clients, [])
}
function assertBooleanFieldValuesPreserved() {
const input = allFieldFixture()
const normalized = normalizeNetworkConfig(input)
@@ -526,6 +575,8 @@ function assertNumberBoundaries() {
const tests = [
assertFixtureCoversGeneratedFields,
assertFullFieldRoundTrip,
assertLegacyVpnPortalFieldsReachBackendValidation,
assertVpnPortalRpcJsonNormalization,
assertBooleanFieldValuesPreserved,
assertEnumCompatibility,
assertAclDefaultsAndExplicitZero,
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { AutoComplete, Button, Checkbox, Dialog, Divider, InputNumber, InputText, Panel, Password, SelectButton, ToggleButton } from 'primevue'
import { v4 as uuidv4 } from 'uuid'
import { AutoComplete, Button, Checkbox, Dialog, Divider, InputNumber, InputText, MultiSelect, Panel, Password, SelectButton, ToggleButton } from 'primevue'
import InputGroup from 'primevue/inputgroup'
import InputGroupAddon from 'primevue/inputgroupaddon'
import {
@@ -7,7 +8,9 @@ import {
DEFAULT_NETWORK_CONFIG,
NetworkConfig,
normalizeNetworkConfig,
removeRow
removeRow,
type VpnPortalClientConfig,
type VpnPortalConfig,
} from '../types/network'
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -196,6 +199,52 @@ const instanceRecvBpsLimitInput = computed<string>({
}
},
})
function defaultVpnPortalConfig(): VpnPortalConfig {
return {
wireguard_listen: '0.0.0.0:22022',
clients: [],
}
}
const vpnPortalEnabled = computed({
get: () => curNetwork.value.vpn_portal_config !== undefined,
set: (enabled: boolean) => {
curNetwork.value.vpn_portal_config = enabled ? defaultVpnPortalConfig() : undefined
},
})
const vpnPortalConfig = computed(() => curNetwork.value.vpn_portal_config ?? defaultVpnPortalConfig())
const vpnPortalPrivateKey = computed({
get: () => vpnPortalConfig.value.wireguard_private_key ?? '',
set: (value: string | null | undefined) => {
vpnPortalConfig.value.wireguard_private_key = value && value.length > 0 ? value : undefined
},
})
const vpnPortalGroupOptions = computed(() => (
curNetwork.value.acl?.acl_v1?.group?.declares ?? []
).map((group) => group.group_name))
const vpnPortalClientViewKeys = new WeakMap<VpnPortalClientConfig, string>()
function vpnPortalClientViewKey(client: VpnPortalClientConfig): string {
let key = vpnPortalClientViewKeys.get(client)
if (!key) {
key = uuidv4()
vpnPortalClientViewKeys.set(client, key)
}
return key
}
function addVpnPortalClient() {
vpnPortalConfig.value.clients.push({ name: '', virtual_ip: '', groups: [] })
}
function removeVpnPortalClient(index: number) {
vpnPortalConfig.value.clients.splice(index, 1)
}
</script>
<template>
@@ -295,25 +344,57 @@ const instanceRecvBpsLimitInput = computed<string>({
<div class="flex flex-row gap-x-9 flex-wrap ">
<div class="flex flex-col gap-2 grow">
<label for="username">VPN Portal</label>
<ToggleButton v-model="curNetwork.enable_vpn_portal" on-icon="pi pi-check" off-icon="pi pi-times"
<label>VPN Portal</label>
<ToggleButton v-model="vpnPortalEnabled" on-icon="pi pi-check" off-icon="pi pi-times"
:on-label="t('off_text')" :off-label="t('on_text')" class="w-48" />
<div v-if="curNetwork.enable_vpn_portal" class="items-center flex flex-row gap-x-4">
<div class="flex flex-row gap-x-9 flex-wrap w-full">
<div class="flex flex-col gap-2 basis-8/12 grow">
<InputGroup>
<InputText v-model="curNetwork.vpn_portal_client_network_addr"
:placeholder="t('vpn_portal_client_network')" />
<InputGroupAddon>
<span>/{{ curNetwork.vpn_portal_client_network_len }}</span>
</InputGroupAddon>
</InputGroup>
<div v-if="vpnPortalEnabled" class="flex flex-col gap-3 w-full">
<div class="flex flex-row gap-x-9 gap-y-3 flex-wrap w-full">
<div class="flex flex-col gap-2 basis-5/12 grow">
<label for="vpn_portal_wireguard_listen">{{ t('vpn_portal_wireguard_listen') }}</label>
<InputText id="vpn_portal_wireguard_listen" v-model="vpnPortalConfig.wireguard_listen"
:placeholder="t('vpn_portal_wireguard_listen_placeholder')" />
</div>
<div class="flex flex-col gap-2 basis-3/12 grow">
<InputNumber v-model="curNetwork.vpn_portal_listen_port" :allow-empty="false" :format="false"
:min="0" :max="65535" fluid />
<div class="flex flex-col gap-2 basis-5/12 grow">
<label for="vpn_portal_wireguard_private_key">{{ t('vpn_portal_wireguard_private_key') }}</label>
<Password id="vpn_portal_wireguard_private_key"
v-model="vpnPortalPrivateKey"
:placeholder="t('vpn_portal_wireguard_private_key_placeholder')"
toggleMask :feedback="false" fluid />
</div>
</div>
<div class="flex items-center justify-between gap-3">
<label>{{ t('vpn_portal_clients') }}</label>
<Button icon="pi pi-plus" :label="t('vpn_portal_add_client')" severity="secondary" size="small"
:disabled="vpnPortalConfig.clients.length >= 64"
@click="addVpnPortalClient" />
</div>
<div v-if="vpnPortalConfig.clients.length === 0"
class="text-sm text-surface-500 dark:text-surface-400">
{{ t('vpn_portal_no_clients') }}
</div>
<div v-for="(client, index) in vpnPortalConfig.clients" :key="vpnPortalClientViewKey(client)"
class="flex flex-row gap-3 flex-wrap items-end rounded border border-surface-200 dark:border-surface-700 p-3">
<div class="flex flex-col gap-2 grow basis-3/12">
<label :for="`vpn_portal_client_name_${index}`">{{ t('vpn_portal_client_name') }}</label>
<InputText :id="`vpn_portal_client_name_${index}`" v-model="client.name"
:placeholder="t('vpn_portal_client_name_placeholder')" />
</div>
<div class="flex flex-col gap-2 grow basis-3/12">
<label :for="`vpn_portal_client_virtual_ip_${index}`">{{ t('vpn_portal_client_virtual_ip') }}</label>
<InputText :id="`vpn_portal_client_virtual_ip_${index}`" v-model="client.virtual_ip"
:placeholder="t('vpn_portal_client_virtual_ip_placeholder')" />
</div>
<div class="flex flex-col gap-2 grow basis-4/12">
<label :for="`vpn_portal_client_groups_${index}`">{{ t('vpn_portal_client_groups') }}</label>
<MultiSelect :input-id="`vpn_portal_client_groups_${index}`" v-model="client.groups"
:options="vpnPortalGroupOptions" appendTo="self" filter fluid
:placeholder="t('vpn_portal_client_groups_placeholder')" />
</div>
<Button icon="pi pi-trash" severity="danger" text rounded
:aria-label="t('vpn_portal_remove_client')" @click="removeVpnPortalClient(index)" />
</div>
</div>
</div>
</div>
@@ -588,6 +588,7 @@ onUnmounted(() => {
</div>
<Status v-if="curNetworkInfo && curNetworkInfo.error_msg === ''" v-bind:cur-network-inst="curNetworkInfo"
:api="api"
class="mb-4">
</Status>
<Message v-else-if="curNetworkInfo?.error_msg" severity="error" class="mb-4">{{
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useTimeAgo } from '@vueuse/core'
import { NetworkInstance, type TunnelInfo, type NodeInfo, type PeerRoutePair } from '../types/network'
import { NetworkInstance, VpnPortalClientState, type TunnelInfo, type NodeInfo, type PeerRoutePair, type VpnPortalClientInfo, type VpnPortalInfo } from '../types/network'
import type { RemoteClient } from '../modules/api'
import { useI18n } from 'vue-i18n';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ipv4InetToString, ipv4ToString, ipv6ToString } from '../modules/utils';
@@ -10,6 +11,7 @@ import NetworkChart from './NetworkChart.vue';
const props = defineProps<{
curNetworkInst: NetworkInstance | null,
api: RemoteClient,
}>()
const { t } = useI18n()
@@ -327,16 +329,66 @@ onUnmounted(() => {
const dialogVisible = ref(false)
const dialogContent = ref<any>('')
const dialogHeader = ref('event_log')
const vpnPortalInfo = ref<VpnPortalInfo>()
const vpnPortalClients = computed(() => vpnPortalInfo.value?.clients ?? [])
const vpnPortalLoading = ref(false)
const vpnPortalError = ref('')
const copiedVpnPortalClient = ref('')
function showVpnPortalConfig() {
const my_node_info = myNodeInfo.value
if (!my_node_info)
async function showVpnPortalConfig() {
const instanceId = props.curNetworkInst?.instance_id
if (!instanceId)
return
const url = 'https://www.wireguardconfig.com/qrcode'
dialogContent.value = `${my_node_info.vpn_portal_cfg}\n\n # can generate QR code: ${url}`
dialogHeader.value = 'vpn_portal_config'
dialogVisible.value = true
vpnPortalInfo.value = undefined
vpnPortalError.value = ''
copiedVpnPortalClient.value = ''
vpnPortalLoading.value = true
try {
vpnPortalInfo.value = await props.api.get_vpn_portal_info(instanceId)
} catch (error) {
console.error('Failed to load VPN Portal information', error)
vpnPortalError.value = t('vpn_portal_load_failed')
} finally {
vpnPortalLoading.value = false
}
}
function vpnPortalStateKey(state: VpnPortalClientState | string): string {
const normalized = typeof state === 'string'
? state.toLowerCase().replace('vpn_portal_client_state_', '')
: VpnPortalClientState[state]?.toLowerCase()
return `vpn_portal_state_${normalized ?? 'unspecified'}`
}
function vpnPortalStateSeverity(state: VpnPortalClientState | string): 'success' | 'warn' | 'danger' | 'secondary' {
const key = vpnPortalStateKey(state)
if (key.endsWith('online')) return 'success'
if (key.endsWith('connecting')) return 'warn'
if (key.endsWith('error')) return 'danger'
return 'secondary'
}
async function copyVpnPortalClientConfig(client: VpnPortalClientInfo) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(client.client_config)
} else {
const textarea = document.createElement('textarea')
textarea.value = client.client_config
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
textarea.remove()
}
copiedVpnPortalClient.value = client.name
} catch (error) {
console.error('Failed to copy VPN Portal client config', error)
}
}
function showEventLogs() {
@@ -354,8 +406,52 @@ function showEventLogs() {
<div class="frontend-lib">
<Dialog v-model:visible="dialogVisible" modal :header="t(dialogHeader)" class="w-full h-auto max-h-full"
:baseZIndex="2000">
<ScrollPanel v-if="dialogHeader === 'vpn_portal_config'">
<pre>{{ dialogContent }}</pre>
<ScrollPanel v-if="dialogHeader === 'vpn_portal_config'" class="max-h-[75vh] pr-3">
<div v-if="vpnPortalLoading" class="py-8 text-center text-surface-500">
{{ t('web.device_management.loading_network_status') }}
</div>
<div v-else-if="vpnPortalError" class="py-4 text-red-500">
{{ vpnPortalError }}
</div>
<div v-else-if="!vpnPortalInfo || ((!vpnPortalInfo.vpn_type || vpnPortalInfo.vpn_type === 'null') && vpnPortalClients.length === 0)"
class="py-4 text-surface-500">
{{ t('vpn_portal_not_configured') }}
</div>
<div v-else class="flex flex-col gap-4">
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm">
<span v-if="vpnPortalInfo.vpn_type"><strong>{{ t('vpn_portal_type') }}:</strong>
{{ vpnPortalInfo.vpn_type }}</span>
<span v-if="vpnPortalInfo.listener"><strong>{{ t('vpn_portal_listener') }}:</strong>
{{ vpnPortalInfo.listener }}</span>
</div>
<div v-for="client in vpnPortalClients" :key="client.name"
class="rounded border border-surface-200 dark:border-surface-700 p-4">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<div class="font-semibold">{{ client.name }} · {{ client.virtual_ip }}</div>
<Tag :severity="vpnPortalStateSeverity(client.state)"
:value="t(vpnPortalStateKey(client.state))" />
</div>
<div class="mb-3 grid gap-x-6 gap-y-1 text-sm sm:grid-cols-2">
<span v-if="client.groups.length"><strong>{{ t('vpn_portal_client_groups') }}:</strong>
{{ client.groups.join(', ') }}</span>
<span v-if="client.peer_id !== undefined"><strong>{{ t('vpn_portal_peer_id') }}:</strong>
{{ client.peer_id }}</span>
<span v-if="client.endpoint"><strong>{{ t('vpn_portal_endpoint') }}:</strong>
{{ client.endpoint }}</span>
<span v-if="client.tunnel_ip"><strong>{{ t('vpn_portal_tunnel_ip') }}:</strong>
{{ client.tunnel_ip }}</span>
<span v-if="client.error" class="text-red-500 sm:col-span-2">{{ client.error }}</span>
</div>
<div class="mb-2 flex items-center justify-between gap-3">
<label class="font-medium">{{ t('vpn_portal_client_config') }}</label>
<Button size="small" severity="secondary" icon="pi pi-copy"
:label="copiedVpnPortalClient === client.name ? t('config_copied') : t('vpn_portal_copy_client_config')"
@click="copyVpnPortalClientConfig(client)" />
</div>
<pre class="max-w-full overflow-x-auto whitespace-pre-wrap break-all rounded bg-surface-100 p-3 text-xs dark:bg-surface-800">{{ client.client_config }}</pre>
</div>
</div>
</ScrollPanel>
<Timeline v-else :value="dialogContent">
<template #opposite="slotProps">
+29 -3
View File
@@ -18,9 +18,20 @@ network_secret: 网络密码
public_server_url: 公共服务器地址
peer_urls: 对等节点地址
proxy_cidrs: 子网代理CIDR
enable_vpn_portal: 启用VPN门户
vpn_portal_listen_port: 监听端口
vpn_portal_client_network: 客户端子网
vpn_portal_wireguard_listen: WireGuard 监听地址
vpn_portal_wireguard_listen_placeholder: 例如:0.0.0.0:22022
vpn_portal_wireguard_private_key: WireGuard 服务端私钥
vpn_portal_wireguard_private_key_placeholder: 必填 Base64 密钥(可用 wg genkey 生成)
vpn_portal_clients: WireGuard 客户端
vpn_portal_add_client: 添加客户端
vpn_portal_no_clients: 尚未配置客户端
vpn_portal_client_name: 客户端名称
vpn_portal_client_name_placeholder: 例如:alice-phone
vpn_portal_client_virtual_ip: 虚拟网地址
vpn_portal_client_virtual_ip_placeholder: 例如:10.126.126.10
vpn_portal_client_groups: ACL 组
vpn_portal_client_groups_placeholder: 选择 ACL 组
vpn_portal_remove_client: 删除客户端
dev_name: TUN接口名称
advanced_settings: 高级设置
basic_settings: 基础设置
@@ -87,6 +98,21 @@ upload: 上传
download: 下载
show_vpn_portal_config: 显示VPN门户配置
vpn_portal_config: VPN门户配置
vpn_portal_not_configured: 当前节点未配置 VPN 门户
vpn_portal_load_failed: VPN 门户信息加载失败
vpn_portal_listener: 监听地址
vpn_portal_type: 类型
vpn_portal_state: 状态
vpn_portal_peer_id: 节点 ID
vpn_portal_endpoint: 客户端端点
vpn_portal_tunnel_ip: 客户端隧道地址
vpn_portal_client_config: 客户端配置
vpn_portal_copy_client_config: 复制客户端配置
vpn_portal_state_unspecified: 未知
vpn_portal_state_offline: 离线
vpn_portal_state_connecting: 连接中
vpn_portal_state_online: 在线
vpn_portal_state_error: 错误
show_event_log: 显示事件日志
event_log: 事件日志
peer_info: 节点信息
+29 -3
View File
@@ -18,9 +18,20 @@ network_secret: Network Secret
public_server_url: Public Server URL
peer_urls: Peer URLs
proxy_cidrs: Subnet Proxy CIDRs
enable_vpn_portal: Enable VPN Portal
vpn_portal_listen_port: VPN Portal Listen Port
vpn_portal_client_network: Client Sub Network
vpn_portal_wireguard_listen: WireGuard Listen Address
vpn_portal_wireguard_listen_placeholder: "Example: 0.0.0.0:22022"
vpn_portal_wireguard_private_key: WireGuard Server Private Key
vpn_portal_wireguard_private_key_placeholder: Required base64 key (generate with wg genkey)
vpn_portal_clients: WireGuard Clients
vpn_portal_add_client: Add Client
vpn_portal_no_clients: No clients configured
vpn_portal_client_name: Client Name
vpn_portal_client_name_placeholder: "Example: alice-phone"
vpn_portal_client_virtual_ip: Virtual Network Address
vpn_portal_client_virtual_ip_placeholder: "Example: 10.126.126.10"
vpn_portal_client_groups: ACL Groups
vpn_portal_client_groups_placeholder: Select ACL groups
vpn_portal_remove_client: Remove Client
dev_name: TUN interface name
advanced_settings: Advanced Settings
basic_settings: Basic Settings
@@ -86,6 +97,21 @@ upload: Upload
download: Download
show_vpn_portal_config: Show VPN Portal Config
vpn_portal_config: VPN Portal Config
vpn_portal_not_configured: VPN Portal is not configured on this node
vpn_portal_load_failed: Failed to load VPN Portal information
vpn_portal_listener: Listener
vpn_portal_type: Type
vpn_portal_state: State
vpn_portal_peer_id: Peer ID
vpn_portal_endpoint: Client Endpoint
vpn_portal_tunnel_ip: Client Tunnel Address
vpn_portal_client_config: Client Config
vpn_portal_copy_client_config: Copy Client Config
vpn_portal_state_unspecified: Unknown
vpn_portal_state_offline: Offline
vpn_portal_state_connecting: Connecting
vpn_portal_state_online: Online
vpn_portal_state_error: Error
show_event_log: Show Event Log
event_log: Event Log
peer_info: Peer Info
+3 -2
View File
@@ -1,5 +1,5 @@
import { UUID } from './utils';
import { NetworkConfig, NetworkInstanceRunningInfo } from '../types/network';
import { NetworkConfig, NetworkInstanceRunningInfo, VpnPortalInfo } from '../types/network';
export interface ValidateConfigResponse {
toml_config: string;
@@ -57,6 +57,7 @@ export interface RemoteClient {
validate_config(config: NetworkConfig): Promise<ValidateConfigResponse>;
run_network(config: NetworkConfig, save: boolean): Promise<undefined>;
get_network_info(inst_id: string): Promise<NetworkInstanceRunningInfo | undefined>;
get_vpn_portal_info(inst_id: string): Promise<VpnPortalInfo | undefined>;
list_network_instance_ids(): Promise<ListNetworkInstanceIdResponse>;
delete_network(inst_id: string): Promise<undefined>;
update_network_instance_state(inst_id: string, disabled: boolean): Promise<undefined>;
@@ -65,4 +66,4 @@ export interface RemoteClient {
generate_config(config: NetworkConfig): Promise<GenerateConfigResponse>;
parse_config(toml_config: string): Promise<ParseConfigResponse>;
get_network_metas(instance_ids: string[]): Promise<GetNetworkMetasResponse>;
}
}
@@ -62,6 +62,20 @@ export function UuidToStr(uuid: UUID | null | undefined): string {
return uint32ToUuid(uuid.part1 ?? 0, uuid.part2 ?? 0, uuid.part3 ?? 0, uuid.part4 ?? 0);
}
export function StrToUuid(uuid: string): UUID {
const hex = uuid.replace(/-/g, '');
if (!/^[0-9a-fA-F]{32}$/.test(hex)) {
throw new Error(`Invalid UUID: ${uuid}`);
}
return {
part1: Number.parseInt(hex.slice(0, 8), 16),
part2: Number.parseInt(hex.slice(8, 16), 16),
part3: Number.parseInt(hex.slice(16, 24), 16),
part4: Number.parseInt(hex.slice(24, 32), 16),
};
}
export interface Location {
country: string | undefined;
city: string | undefined;
+24 -8
View File
@@ -5,7 +5,15 @@ import {
type NetworkPeerConfig,
type NetworkConfig as ProtoNetworkConfig,
type PortForwardConfig,
type VpnPortalClientConfig,
type VpnPortalConfig,
} from '../generated/proto/api_manage'
import {
VpnPortalClientState,
VpnPortalInfo as VpnPortalInfoPb,
type VpnPortalClientInfo,
type VpnPortalInfo,
} from '../generated/proto/api_instance'
import {
Action as AclAction,
ChainType as AclChainType,
@@ -26,11 +34,15 @@ import {
import { prepareNetworkConfigForProtoJson } from './networkCompat'
export { AclAction, AclChainType, AclProtocol, CompressionAlgoPb, NatType, NetworkingMethod }
export type { Acl, AclChain, AclRule, AclV1, GroupIdentity, GroupInfo, NetworkPeerConfig, PeerFeatureFlag, PortForwardConfig, SecureModeConfig }
export { VpnPortalClientState }
export type { Acl, AclChain, AclRule, AclV1, GroupIdentity, GroupInfo, NetworkPeerConfig, PeerFeatureFlag, PortForwardConfig, SecureModeConfig, VpnPortalClientConfig, VpnPortalClientInfo, VpnPortalConfig, VpnPortalInfo }
export type NetworkConfig = Omit<
ProtoNetworkConfig,
'instance_id' | 'instance_recv_bps_limit' | 'mtu' | 'networking_method'
| 'instance_id'
| 'instance_recv_bps_limit'
| 'mtu'
| 'networking_method'
> & {
instance_id: string
mtu: number | null
@@ -90,11 +102,6 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
proxy_cidrs: [],
enable_vpn_portal: false,
vpn_portal_listen_port: 22022,
vpn_portal_client_network_addr: '',
vpn_portal_client_network_len: 24,
advanced_settings: false,
listener_urls: [
@@ -308,6 +315,12 @@ export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
normalized.exit_nodes ??= []
normalized.mapped_listeners ??= []
normalized.port_forwards ??= []
if (normalized.vpn_portal_config) {
normalized.vpn_portal_config.clients ??= []
normalized.vpn_portal_config.clients.forEach((client) => {
client.groups ??= []
})
}
normalized.acl = config.acl === undefined ? undefined : normalizeAcl(normalized.acl)
return normalized
@@ -330,6 +343,10 @@ export function toBackendNetworkConfig(config: NetworkConfig): NetworkConfig {
}) as unknown as NetworkConfig
}
export function normalizeVpnPortalInfo(info: unknown): VpnPortalInfo {
return VpnPortalInfoPb.fromJson(info as any, { ignoreUnknownFields: true })
}
export interface NetworkInstance {
instance_id: string
@@ -394,7 +411,6 @@ export interface NodeInfo {
}
stun_info: StunInfo
listeners: Url[]
vpn_portal_cfg?: string
peer_id: number
}
@@ -11,7 +11,8 @@ type JsonRecord = Record<string, unknown>
export function prepareNetworkConfigForProtoJson(config: NetworkConfig): NetworkConfig {
const prepared = dropUnsupportedJsonValues(applyLegacyAclDefaults(config)) as NetworkConfig
normalizeLegacyOptionalUint64(prepared as JsonRecord, 'instance_recv_bps_limit')
const record = prepared as JsonRecord
normalizeLegacyOptionalUint64(record, 'instance_recv_bps_limit')
return prepared
}
+111 -10
View File
@@ -43,7 +43,6 @@ const CONFIG_CHECKBOX_FIELDS = [
] as const satisfies readonly (readonly [keyof NetworkConfig, string])[]
const CONFIG_TOGGLE_FIELDS = [
'enable_vpn_portal',
'enable_relay_network_whitelist',
'enable_manual_routes',
'enable_socks5',
@@ -212,6 +211,26 @@ const AutoCompleteStub = defineComponent({
},
})
const MultiSelectStub = defineComponent({
name: 'MultiSelect',
props: {
modelValue: Array,
inputId: String,
appendTo: String,
},
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
id: props.inputId,
'data-append-to': props.appendTo,
value: (props.modelValue ?? []).join(','),
'data-stub': 'multi-select',
onInput: (event: Event) => emit('update:modelValue', splitList((event.target as HTMLInputElement).value)),
})
},
})
const UrlListInputStub = defineComponent({
name: 'UrlListInput',
props: {
@@ -307,9 +326,15 @@ function makeConfig(): NetworkConfig {
no_tun: true,
hostname: 'host-a',
proxy_cidrs: ['10.10.0.0/16', '172.16.1.0/24'],
enable_vpn_portal: true,
vpn_portal_client_network_addr: '10.144.0.0',
vpn_portal_listen_port: 22023,
vpn_portal_config: {
wireguard_listen: '0.0.0.0:22023',
wireguard_private_key: 'portal-private-key',
clients: [{
name: 'phone-a',
virtual_ip: '10.1.2.10',
groups: ['ops'],
}],
},
listener_urls: ['tcp://0.0.0.0:12010'],
dev_name: 'tun-test',
mtu: 1280,
@@ -354,6 +379,7 @@ function mountConfig(config: NetworkConfig = makeConfig()) {
InputGroupAddon: PassThrough,
InputNumber: InputNumberStub,
InputText: InputTextStub,
MultiSelect: MultiSelectStub,
Panel: PanelStub,
Password: PasswordStub,
SelectButton: SelectButtonStub,
@@ -392,7 +418,11 @@ describe('Config.vue network config projection', () => {
expect(input(wrapper, '#hostname').value).toBe('host-a')
expect(input(wrapper, '#subnet-proxy').value).toBe('10.10.0.0/16,172.16.1.0/24')
expect(input(wrapper, 'input[placeholder="vpn_portal_client_network"]').value).toBe('10.144.0.0')
expect(input(wrapper, '#vpn_portal_wireguard_listen').value).toBe('0.0.0.0:22023')
expect(input(wrapper, '#vpn_portal_wireguard_private_key').value).toBe('portal-private-key')
expect(input(wrapper, '#vpn_portal_client_name_0').value).toBe('phone-a')
expect(input(wrapper, '#vpn_portal_client_virtual_ip_0').value).toBe('10.1.2.10')
expect(input(wrapper, '#vpn_portal_client_groups_0').value).toBe('ops')
expect(input(wrapper, '#dev_name').value).toBe('tun-test')
expect(input(wrapper, '#mtu').value).toBe('1280')
expect(input(wrapper, '#instance_recv_bps_limit').value).toBe('9007199254740993')
@@ -422,7 +452,11 @@ describe('Config.vue network config projection', () => {
await wrapper.find('#disable_ipv6').setValue(false)
await setInput(wrapper, '#hostname', 'host-edited')
await setInput(wrapper, '#subnet-proxy', '10.7.0.0/16,172.17.0.0/16')
await setInput(wrapper, 'input[placeholder="vpn_portal_client_network"]', '10.200.0.0')
await setInput(wrapper, '#vpn_portal_wireguard_listen', '[::]:23000')
await setInput(wrapper, '#vpn_portal_wireguard_private_key', 'edited-private-key')
await setInput(wrapper, '#vpn_portal_client_name_0', 'laptop-a')
await setInput(wrapper, '#vpn_portal_client_virtual_ip_0', '10.1.2.20')
await setInput(wrapper, '#vpn_portal_client_groups_0', 'ops,admin')
await setInput(wrapper, 'input[data-add-label="add_listener_url"]', 'tcp://0.0.0.0:13010')
await setInput(wrapper, '#dev_name', 'tun-edited')
await setInput(wrapper, '#mtu', '1260')
@@ -450,7 +484,15 @@ describe('Config.vue network config projection', () => {
disable_ipv6: false,
hostname: 'host-edited',
proxy_cidrs: ['10.7.0.0/16', '172.17.0.0/16'],
vpn_portal_client_network_addr: '10.200.0.0',
vpn_portal_config: {
wireguard_listen: '[::]:23000',
wireguard_private_key: 'edited-private-key',
clients: [{
name: 'laptop-a',
virtual_ip: '10.1.2.20',
groups: ['ops', 'admin'],
}],
},
listener_urls: ['tcp://0.0.0.0:13010'],
dev_name: 'tun-edited',
mtu: 1260,
@@ -478,6 +520,15 @@ describe('Config.vue network config projection', () => {
listener_urls: ['tcp://0.0.0.0:13010'],
mtu: 1260,
instance_recv_bps_limit: '9007199254740993',
vpn_portal_config: {
wireguard_listen: '[::]:23000',
wireguard_private_key: 'edited-private-key',
clients: [{
name: 'laptop-a',
virtual_ip: '10.1.2.20',
groups: ['ops', 'admin'],
}],
},
port_forwards: [{
proto: 'tcp',
bind_ip: '127.0.0.1',
@@ -509,12 +560,13 @@ describe('Config.vue network config projection', () => {
}
const toggleButtons = wrapper.findAll('button[data-stub="toggle-button"]')
expect(toggleButtons).toHaveLength(CONFIG_TOGGLE_FIELDS.length)
expect(toggleButtons).toHaveLength(CONFIG_TOGGLE_FIELDS.length + 1)
for (const [index, field] of CONFIG_TOGGLE_FIELDS.entries()) {
const value = originalFlagValues.get(field)
expect(toggleButtons[index].attributes('aria-pressed'), `${field} should project into UI`)
const toggle = toggleButtons[index + 1]
expect(toggle.attributes('aria-pressed'), `${field} should project into UI`)
.toBe(String(value))
await toggleButtons[index].trigger('click')
await toggle.trigger('click')
await nextTick()
}
@@ -526,6 +578,55 @@ describe('Config.vue network config projection', () => {
}
})
it('uses VPN Portal config presence as the enable switch', async () => {
const config = DEFAULT_NETWORK_CONFIG()
const { curNetwork, wrapper } = mountConfig(config)
await nextTick()
const portalToggle = wrapper.findAll('button[data-stub="toggle-button"]')[0]
expect(portalToggle.attributes('aria-pressed')).toBe('false')
await portalToggle.trigger('click')
await nextTick()
expect(curNetwork.vpn_portal_config).toEqual({
wireguard_listen: '0.0.0.0:22022',
clients: [],
})
await portalToggle.trigger('click')
await nextTick()
expect(curNetwork.vpn_portal_config).toBeUndefined()
})
it('keeps each VPN Portal client row bound to the same client when reordered', async () => {
const config = makeConfig()
config.vpn_portal_config!.clients.push({
name: 'phone-b',
virtual_ip: '10.1.2.11',
groups: ['guests'],
})
const { curNetwork, wrapper } = mountConfig(config)
await nextTick()
const firstClient = curNetwork.vpn_portal_config!.clients[0]
const secondClient = curNetwork.vpn_portal_config!.clients[1]
const firstClientInput = input(wrapper, '#vpn_portal_client_name_0')
curNetwork.vpn_portal_config!.clients = [secondClient, firstClient]
await nextTick()
expect(input(wrapper, '#vpn_portal_client_name_1')).toBe(firstClientInput)
await setInput(wrapper, '#vpn_portal_client_name_1', 'phone-a-edited')
expect(firstClient.name).toBe('phone-a-edited')
expect(secondClient.name).toBe('phone-b')
})
it('keeps VPN Portal ACL group menus inside the management drawer', async () => {
const { wrapper } = mountConfig()
await nextTick()
expect(wrapper.find('#vpn_portal_client_groups_0').attributes('data-append-to')).toBe('self')
})
it('keeps uint64 input editable without losing large values', async () => {
const { curNetwork, wrapper } = mountConfig()
await nextTick()
@@ -9,7 +9,6 @@ import {
const BOOLEAN_CONFIG_FIELDS = [
'dhcp',
'enable_vpn_portal',
'advanced_settings',
'latency_first',
'use_smoltcp',
@@ -171,6 +170,7 @@ describe('RemoteManagement config save', () => {
generate_config: vi.fn(),
get_network_config: vi.fn(async () => cloneConfig(config)),
get_network_info: vi.fn(),
get_vpn_portal_info: vi.fn(),
get_network_metas: vi.fn(async (instanceIds: string[]) => ({
metas: Object.fromEntries(instanceIds.map((id) => [id, {
config_permission: 0xffffffff,
@@ -0,0 +1,160 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import Status from '../src/components/Status.vue'
import { VpnPortalClientState, type NetworkInstance } from '../src/types/network'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@vueuse/core', () => ({
useTimeAgo: () => '',
}))
vi.mock('../src/components/NetworkChart.vue', () => ({
default: defineComponent({ render: () => h('div') }),
}))
vi.mock('primevue', () => {
const PassThrough = defineComponent({
setup(_, { slots }) {
return () => h('div', slots.default?.())
},
})
const CardStub = defineComponent({
setup(_, { slots }) {
return () => h('div', [slots.title?.(), slots.content?.()])
},
})
const ButtonStub = defineComponent({
props: { label: String },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', {
'data-label': props.label,
onClick: (event: MouseEvent) => emit('click', event),
}, props.label)
},
})
return {
Badge: PassThrough,
Button: ButtonStub,
Card: CardStub,
Chip: PassThrough,
Column: PassThrough,
DataTable: PassThrough,
Dialog: PassThrough,
Divider: PassThrough,
ScrollPanel: PassThrough,
Tag: PassThrough,
Timeline: PassThrough,
}
})
function runningInstance(): NetworkInstance {
return {
instance_id: '12345678-9abc-def0-fedc-ba9876543210',
running: true,
error_msg: '',
detail: {
dev_name: 'tun0',
running: true,
events: [],
routes: [],
peers: [],
peer_route_pairs: [],
my_node_info: {
virtual_ipv4: { address: { addr: 0x0a000001 }, network_length: 24 },
hostname: 'portal-node',
version: 'test',
ips: {
public_ipv4: { addr: 0 },
interface_ipv4s: [],
public_ipv6: { part1: 0, part2: 0, part3: 0, part4: 0 },
interface_ipv6s: [],
listeners: [],
},
stun_info: { udp_nat_type: 0, tcp_nat_type: 0, last_update_time: 0 },
listeners: [],
peer_id: 1,
},
},
}
}
describe('Status VPN Portal details', () => {
it('fetches client configs only when the user opens the dialog', async () => {
const getVpnPortalInfo = vi.fn(async () => ({
vpn_type: 'wireguard',
client_config: '',
connected_clients: [],
listener: '0.0.0.0:22022',
clients: [{
name: 'phone-a',
virtual_ip: '10.0.0.10',
groups: ['ops'],
state: VpnPortalClientState.ONLINE,
peer_id: 42,
endpoint: '203.0.113.5:51820',
tunnel_ip: '192.0.2.1',
client_config: '[Interface]\nPrivateKey = secret',
}],
}))
const wrapper = mount(Status, {
props: {
curNetworkInst: runningInstance(),
api: { get_vpn_portal_info: getVpnPortalInfo } as any,
},
global: {
directives: { tooltip: () => {} },
stubs: { HumanEvent: true },
},
})
try {
expect(getVpnPortalInfo).not.toHaveBeenCalled()
await wrapper.find('button[data-label="show_vpn_portal_config"]').trigger('click')
await flushPromises()
expect(getVpnPortalInfo).toHaveBeenCalledOnce()
expect(getVpnPortalInfo).toHaveBeenCalledWith('12345678-9abc-def0-fedc-ba9876543210')
expect(wrapper.text()).toContain('phone-a · 10.0.0.10')
expect(wrapper.text()).toContain('203.0.113.5:51820')
expect(wrapper.text()).toContain('PrivateKey = secret')
} finally {
wrapper.unmount()
}
})
it('renders the unconfigured portal sentinel as an empty state', async () => {
const getVpnPortalInfo = vi.fn(async () => ({
vpn_type: 'null',
client_config: '',
connected_clients: [],
clients: [],
}))
const wrapper = mount(Status, {
props: {
curNetworkInst: runningInstance(),
api: { get_vpn_portal_info: getVpnPortalInfo } as any,
},
global: {
directives: { tooltip: () => {} },
stubs: { HumanEvent: true },
},
})
try {
await wrapper.find('button[data-label="show_vpn_portal_config"]').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('vpn_portal_not_configured')
expect(wrapper.text()).not.toContain('vpn_portal_type: null')
} finally {
wrapper.unmount()
}
})
})
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { StrToUuid, UuidToStr } from '../src/modules/utils'
describe('UUID protobuf conversion', () => {
it('round-trips all four uint32 parts', () => {
const value = '12345678-9abc-def0-fedc-ba9876543210'
const protobuf = StrToUuid(value)
expect(protobuf).toEqual({
part1: 0x12345678,
part2: 0x9abcdef0,
part3: 0xfedcba98,
part4: 0x76543210,
})
expect(UuidToStr(protobuf)).toBe(value)
})
it('rejects malformed UUIDs', () => {
expect(() => StrToUuid('not-a-uuid')).toThrow('Invalid UUID')
})
})