mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-01 00:39:24 +00:00
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:
@@ -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">
|
||||
|
||||
@@ -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: 节点信息
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user