fix(web): preserve managed config and status compatibility (#2389)

Fix web/frontend compat bugs in managed config & runtime status

- Preserve [[peer]].peer_public_key when TOML configs round-trip
  through the web/managed NetworkConfig path
- Keep old peer_urls clients working while adding structured peer
  metadata for new clients
- Make frontend protobuf JSON normalization preserve omitted-field
  semantics instead of turning missing data into misleading defaults
- Harden runtime status rendering against omitted or string-encoded
  backend fields
- Expose peer-route feature flags in the web status UI
This commit is contained in:
KKRainbow
2026-06-29 12:40:04 +08:00
committed by GitHub
parent 15e5d89f70
commit 4e61612944
18 changed files with 665 additions and 164 deletions
+11 -14
View File
@@ -163,14 +163,10 @@ async function registerVpnServiceListener() {
) )
} }
function getRoutesForVpn(routes: Route[], node_config: NetworkTypes.NetworkConfig): string[] { function getRoutesForVpn(routes: Route[] | undefined, node_config: NetworkTypes.NetworkConfig): string[] {
if (!routes) {
return []
}
const ret = [] const ret = []
for (const r of routes) { for (const r of routes ?? []) {
for (let cidr of r.proxy_cidrs) { for (let cidr of r.proxy_cidrs ?? []) {
if (!cidr.includes('/')) { if (!cidr.includes('/')) {
cidr += '/32' cidr += '/32'
} }
@@ -178,9 +174,9 @@ function getRoutesForVpn(routes: Route[], node_config: NetworkTypes.NetworkConfi
} }
} }
node_config.routes.forEach(r => { for (const route of node_config.routes ?? []) {
ret.push(r) ret.push(route)
}) }
if (node_config.enable_magic_dns) { if (node_config.enable_magic_dns) {
ret.push('100.100.100.101/32') ret.push('100.100.100.101/32')
@@ -215,14 +211,15 @@ export async function onNetworkInstanceChange(instanceId: string) {
console.log('vpn service skipped because no_tun is enabled', instanceId) console.log('vpn service skipped because no_tun is enabled', instanceId)
return return
} }
const curNetworkInfo = (await collectNetworkInfo(instanceId)).info.map[instanceId] const curNetworkInfo = (await collectNetworkInfo(instanceId))?.info?.map?.[instanceId]
if (!curNetworkInfo || curNetworkInfo?.error_msg?.length) { if (!curNetworkInfo || curNetworkInfo?.error_msg?.length) {
console.warn('vpn service skipped because network info is unavailable', instanceId, curNetworkInfo?.error_msg) console.warn('vpn service skipped because network info is unavailable', instanceId, curNetworkInfo?.error_msg)
await doStopVpn() await doStopVpn()
return return
} }
const virtual_ip = Utils.ipv4ToString(curNetworkInfo?.my_node_info?.virtual_ipv4.address) const virtualIpv4 = curNetworkInfo.my_node_info?.virtual_ipv4
const virtual_ip = virtualIpv4?.address?.addr ? Utils.ipv4ToString(virtualIpv4.address) : undefined
if (config.dhcp && (!virtual_ip || !virtual_ip.length)) { if (config.dhcp && (!virtual_ip || !virtual_ip.length)) {
console.log('DHCP enabled but no IP yet, will retry in', DHCP_POLLING_INTERVAL, 'ms') console.log('DHCP enabled but no IP yet, will retry in', DHCP_POLLING_INTERVAL, 'ms')
@@ -237,7 +234,7 @@ export async function onNetworkInstanceChange(instanceId: string) {
return return
} }
let network_length = curNetworkInfo?.my_node_info?.virtual_ipv4.network_length let network_length = virtualIpv4?.network_length
if (!network_length) { if (!network_length) {
network_length = 24 network_length = 24
} }
@@ -290,7 +287,7 @@ async function isNoTunEnabled(instanceId: string | undefined) {
async function findRunningTunInstanceId() { async function findRunningTunInstanceId() {
const instanceIds = await listNetworkInstanceIds() const instanceIds = await listNetworkInstanceIds()
const runningIds = instanceIds.running_inst_ids.map(Utils.UuidToStr) const runningIds = (instanceIds.running_inst_ids ?? []).map(Utils.UuidToStr)
console.log('vpn service sync running instances', JSON.stringify(runningIds)) console.log('vpn service sync running instances', JSON.stringify(runningIds))
for (const instanceId of runningIds) { for (const instanceId of runningIds) {
+2 -2
View File
@@ -9,7 +9,7 @@ export class GUIRemoteClient implements Api.RemoteClient {
await backend.runNetworkInstance(config, save); await backend.runNetworkInstance(config, save);
} }
async get_network_info(inst_id: string): Promise<NetworkTypes.NetworkInstanceRunningInfo | undefined> { async get_network_info(inst_id: string): Promise<NetworkTypes.NetworkInstanceRunningInfo | undefined> {
return backend.collectNetworkInfo(inst_id).then(infos => infos.info.map[inst_id]); return backend.collectNetworkInfo(inst_id).then(infos => infos.info?.map?.[inst_id]);
} }
async list_network_instance_ids(): Promise<Api.ListNetworkInstanceIdResponse> { async list_network_instance_ids(): Promise<Api.ListNetworkInstanceIdResponse> {
return backend.listNetworkInstanceIds(); return backend.listNetworkInstanceIds();
@@ -44,4 +44,4 @@ export class GUIRemoteClient implements Api.RemoteClient {
return await backend.getNetworkMetas(instance_ids); return await backend.getNetworkMetas(instance_ids);
} }
} }
@@ -114,6 +114,12 @@ function allFieldFixture() {
networking_method: NetworkingMethod.Manual, networking_method: NetworkingMethod.Manual,
public_server_url: 'tcp://public.example:11010', public_server_url: 'tcp://public.example:11010',
peer_urls: [' tcp://peer-a:11010 ', '', 'udp://peer-b:11010'], peer_urls: [' tcp://peer-a:11010 ', '', 'udp://peer-b:11010'],
peers: [
{
uri: 'tcp://peer-a:11010',
peer_public_key: 'peer-a-public-key',
},
],
proxy_cidrs: ['10.10.0.0/16', '192.168.2.0/24->10.99.0.0/24'], proxy_cidrs: ['10.10.0.0/16', '192.168.2.0/24->10.99.0.0/24'],
enable_vpn_portal: true, enable_vpn_portal: true,
vpn_portal_listen_port: 23000, vpn_portal_listen_port: 23000,
@@ -259,6 +265,8 @@ function assertFullFieldRoundTrip() {
assert.equal(backend.networking_method, 'Manual') assert.equal(backend.networking_method, 'Manual')
assert.equal(backend.public_server_url, '') assert.equal(backend.public_server_url, '')
assert.deepEqual(backend.peer_urls, ['tcp://peer-a:11010', 'udp://peer-b:11010']) assert.deepEqual(backend.peer_urls, ['tcp://peer-a:11010', 'udp://peer-b:11010'])
assert.equal(backend.peers[0].peer_public_key, 'peer-a-public-key')
assert.deepEqual(backend.peers[1], { uri: 'udp://peer-b:11010' })
assert.equal(backend.data_compress_algo, 'Zstd') assert.equal(backend.data_compress_algo, 'Zstd')
assert.equal(backend.instance_recv_bps_limit, '9007199254740993') assert.equal(backend.instance_recv_bps_limit, '9007199254740993')
assert.equal(backend.secure_mode.enabled, true) assert.equal(backend.secure_mode.enabled, true)
@@ -415,6 +423,59 @@ function assertNetworkingMethodNormalization() {
}) })
assert.deepEqual(missing.peer_urls, ['tcp://one', 'udp://two']) assert.deepEqual(missing.peer_urls, ['tcp://one', 'udp://two'])
const publicServerMissingUrl = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
networking_method: 'PublicServer',
public_server_url: '',
peer_urls: ['tcp://manual.example:11010'],
})
assert.deepEqual(publicServerMissingUrl.peer_urls, [])
}
function assertPeerPublicKeysPreserved() {
const normalized = normalizeNetworkConfig({
...DEFAULT_NETWORK_CONFIG(),
peer_urls: [],
peers: [
{
uri: ' tcp://peer-a:11010 ',
peer_public_key: 'peer-a-public-key',
},
],
})
assert.deepEqual(normalized.peer_urls, ['tcp://peer-a:11010'])
assert.deepEqual(normalized.peers, [
{
uri: 'tcp://peer-a:11010',
peer_public_key: 'peer-a-public-key',
},
])
const unchangedUrl = toBackendNetworkConfig({
...normalized,
peer_urls: ['tcp://peer-a:11010', 'tcp://peer-b:11010'],
})
assert.equal(unchangedUrl.peers[0].peer_public_key, 'peer-a-public-key')
assert.deepEqual(unchangedUrl.peers[1], { uri: 'tcp://peer-b:11010' })
const changedUrl = toBackendNetworkConfig({
...normalized,
peer_urls: ['tcp://peer-c:11010'],
})
assert.deepEqual(changedUrl.peers, [{ uri: 'tcp://peer-c:11010' }])
const clearedUrls = toBackendNetworkConfig({
...normalized,
peer_urls: [],
})
assert.deepEqual(clearedUrls.peer_urls ?? [], [])
assert.deepEqual(clearedUrls.peers ?? [], [])
} }
function assertNumberBoundaries() { function assertNumberBoundaries() {
@@ -469,6 +530,7 @@ const tests = [
assertEnumCompatibility, assertEnumCompatibility,
assertAclDefaultsAndExplicitZero, assertAclDefaultsAndExplicitZero,
assertNetworkingMethodNormalization, assertNetworkingMethodNormalization,
assertPeerPublicKeysPreserved,
assertNumberBoundaries, assertNumberBoundaries,
] ]
@@ -35,7 +35,7 @@ const currentNetworkConfig = ref<NetworkTypes.NetworkConfig | undefined>(undefin
const listInstanceIdResponse = ref<Api.ListNetworkInstanceIdResponse | undefined>(undefined); const listInstanceIdResponse = ref<Api.ListNetworkInstanceIdResponse | undefined>(undefined);
const isRunning = (instanceId: string) => { const isRunning = (instanceId: string) => {
return listInstanceIdResponse.value?.running_inst_ids.map(Utils.UuidToStr).includes(instanceId); return (listInstanceIdResponse.value?.running_inst_ids ?? []).map(Utils.UuidToStr).includes(instanceId);
} }
const networkMetaCache = ref<Record<string, Api.NetworkMeta>>({}); const networkMetaCache = ref<Record<string, Api.NetworkMeta>>({});
@@ -46,7 +46,7 @@ const loadNetworkMetas = async (instanceIds: string[]) => {
try { try {
const response = await props.api.get_network_metas(missingIds); const response = await props.api.get_network_metas(missingIds);
Object.assign(networkMetaCache.value, response.metas); Object.assign(networkMetaCache.value, response.metas ?? {});
} catch (e) { } catch (e) {
console.error("Failed to load network metas", e); console.error("Failed to load network metas", e);
} }
@@ -80,8 +80,8 @@ const updateInstanceList = () => {
let insts = new Set<string>(); let insts = new Set<string>();
let t = listInstanceIdResponse.value; let t = listInstanceIdResponse.value;
if (t) { if (t) {
t.running_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u))); (t.running_inst_ids ?? []).forEach((u) => insts.add(Utils.UuidToStr(u)));
t.disabled_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u))); (t.disabled_inst_ids ?? []).forEach((u) => insts.add(Utils.UuidToStr(u)));
} }
const newList = Array.from(insts).map((instance: string) => { const newList = Array.from(insts).map((instance: string) => {
@@ -149,7 +149,7 @@ const networkIsDisabled = computed(() => {
if (!selectedInstanceId.value) { if (!selectedInstanceId.value) {
return false; return false;
} }
return listInstanceIdResponse.value?.disabled_inst_ids.map(Utils.UuidToStr).includes(selectedInstanceId.value?.uuid); return (listInstanceIdResponse.value?.disabled_inst_ids ?? []).map(Utils.UuidToStr).includes(selectedInstanceId.value?.uuid);
}); });
watch(networkIsDisabled, async (newVal, oldVal) => { watch(networkIsDisabled, async (newVal, oldVal) => {
if (newVal !== oldVal && newVal === true) { if (newVal !== oldVal && newVal === true) {
@@ -287,17 +287,35 @@ const loadNetworkInstanceIds = async () => {
} }
const loadCurrentNetworkInfo = async () => { const loadCurrentNetworkInfo = async () => {
if (!selectedInstanceId.value) { const selected = selectedInstanceId.value?.uuid;
if (!selected) {
curNetworkInfo.value = null;
return; return;
} }
if (!needShowNetworkStatus.value) { if (!needShowNetworkStatus.value) {
curNetworkInfo.value = null;
return;
}
if (curNetworkInfo.value?.instance_id !== selected) {
curNetworkInfo.value = null;
}
let network_info = await props.api.get_network_info(selected);
if (selectedInstanceId.value?.uuid !== selected) {
return; return;
} }
let network_info = await props.api.get_network_info(selectedInstanceId.value.uuid); if (!network_info) {
curNetworkInfo.value = {
instance_id: selected,
running: false,
error_msg: t('web.device_management.network_info_unavailable'),
} as NetworkTypes.NetworkInstance;
return;
}
curNetworkInfo.value = { curNetworkInfo.value = {
instance_id: selectedInstanceId.value.uuid, instance_id: selected,
running: network_info?.running ?? false, running: network_info?.running ?? false,
error_msg: network_info?.error_msg ?? '', error_msg: network_info?.error_msg ?? '',
detail: network_info, detail: network_info,
@@ -492,7 +510,7 @@ onUnmounted(() => {
<div class="flex items-center min-w-0"> <div class="flex items-center min-w-0">
<div class="mr-4 min-w-0 flex-1"> <div class="mr-4 min-w-0 flex-1">
<span class="truncate block">{{ t('network_name') }}: {{ <span class="truncate block">{{ t('network_name') }}: {{
slotProps.option.meta.network_name }}</span> slotProps.option.meta?.network_name ?? slotProps.option.uuid }}</span>
</div> </div>
<Tag class="my-auto leading-3 shrink-0" <Tag class="my-auto leading-3 shrink-0"
:severity="isRunning(slotProps.option.uuid) ? 'success' : 'info'" :severity="isRunning(slotProps.option.uuid) ? 'success' : 'info'"
@@ -569,10 +587,13 @@ onUnmounted(() => {
<h2 class="text-xl font-medium">{{ t('web.device_management.network_status') }}</h2> <h2 class="text-xl font-medium">{{ t('web.device_management.network_status') }}</h2>
</div> </div>
<Status v-if="(curNetworkInfo?.error_msg ?? '') === ''" v-bind:cur-network-inst="curNetworkInfo" <Status v-if="curNetworkInfo && curNetworkInfo.error_msg === ''" v-bind:cur-network-inst="curNetworkInfo"
class="mb-4"> class="mb-4">
</Status> </Status>
<Message v-else severity="error" class="mb-4">{{ curNetworkInfo?.error_msg }}</Message> <Message v-else-if="curNetworkInfo?.error_msg" severity="error" class="mb-4">{{
curNetworkInfo.error_msg }}</Message>
<Message v-else severity="info" class="mb-4">{{ t('web.device_management.loading_network_status') }}
</Message>
<div class="text-center mt-4"> <div class="text-center mt-4">
<Button @click="stopNetwork" :disabled="!currentNetworkControl.deletable.value" <Button @click="stopNetwork" :disabled="!currentNetworkControl.deletable.value"
@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTimeAgo } from '@vueuse/core' import { useTimeAgo } from '@vueuse/core'
import { IPv4 } from 'ip-num/IPNumber'
import { NetworkInstance, type TunnelInfo, type NodeInfo, type PeerRoutePair } from '../types/network' import { NetworkInstance, type TunnelInfo, type NodeInfo, type PeerRoutePair } from '../types/network'
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { computed, onMounted, onUnmounted, ref } from 'vue'; import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ipv4InetToString, ipv4ToString, ipv6ToString } from '../modules/utils'; import { ipv4InetToString, ipv4ToString, ipv6ToString } from '../modules/utils';
import { latencyMs, lossRate, numericValue, peerConns } from '../modules/statusDisplay';
import { Badge, DataTable, Column, Tag, Chip, Button, Dialog, ScrollPanel, Timeline, Divider, Card, } from 'primevue'; import { Badge, DataTable, Column, Tag, Chip, Button, Dialog, ScrollPanel, Timeline, Divider, Card, } from 'primevue';
import NetworkChart from './NetworkChart.vue'; import NetworkChart from './NetworkChart.vue';
@@ -39,8 +39,8 @@ function routeCost(info: any) {
return '?' return '?'
} }
function resolveObjPath(path: string, obj = globalThis, separator = '.') { function resolveObjPath(path: string, obj: any = globalThis, separator = '.') {
const properties = Array.isArray(path) ? path : path.split(separator) const properties = path.split(separator)
return properties.reduce((prev, curr) => prev?.[curr], obj) return properties.reduce((prev, curr) => prev?.[curr], obj)
} }
@@ -48,10 +48,17 @@ function statsCommon(info: any, field: string): number | undefined {
if (!info.peer) if (!info.peer)
return undefined return undefined
const conns = info.peer.conns let sum = 0
return conns.reduce((acc: number, conn: any) => { let hasValue = false
return acc + resolveObjPath(field, conn) for (const conn of peerConns(info)) {
}, 0) const value = numericValue(resolveObjPath(field, conn))
if (value === undefined)
continue
sum += value
hasValue = true
}
return hasValue ? sum : undefined
} }
function humanFileSize(bytes: number, si = false, dp = 1) { function humanFileSize(bytes: number, si = false, dp = 1) {
@@ -74,14 +81,6 @@ function humanFileSize(bytes: number, si = false, dp = 1) {
return `${bytes.toFixed(dp)} ${units[u]}` return `${bytes.toFixed(dp)} ${units[u]}`
} }
function latencyMs(info: PeerRoutePair) {
let lat_us_sum = statsCommon(info, 'stats.latency_us')
if (lat_us_sum === undefined)
return ''
lat_us_sum = lat_us_sum / 1000 / info.peer!.conns.length
return `${lat_us_sum % 1 > 0 ? Math.round(lat_us_sum) + 1 : Math.round(lat_us_sum)}ms`
}
function txBytes(info: PeerRoutePair) { function txBytes(info: PeerRoutePair) {
const tx = statsCommon(info, 'stats.tx_bytes') const tx = statsCommon(info, 'stats.tx_bytes')
return tx ? humanFileSize(tx) : '' return tx ? humanFileSize(tx) : ''
@@ -92,11 +91,6 @@ function rxBytes(info: PeerRoutePair) {
return rx ? humanFileSize(rx) : '' return rx ? humanFileSize(rx) : ''
} }
function lossRate(info: PeerRoutePair) {
const lossRate = statsCommon(info, 'loss_rate')
return lossRate !== undefined ? `${Math.round(lossRate * 100)}%` : ''
}
function version(info: PeerRoutePair) { function version(info: PeerRoutePair) {
return info.route.version === '' ? 'unknown' : info.route.version return info.route.version === '' ? 'unknown' : info.route.version
} }
@@ -105,7 +99,7 @@ function ipFormat(info: PeerRoutePair) {
const ip = info.route.ipv4_addr const ip = info.route.ipv4_addr
if (typeof ip === 'string') if (typeof ip === 'string')
return ip return ip
return ip ? `${IPv4.fromNumber(ip.address.addr)}/${ip.network_length}` : '' return ip ? ipv4InetToString(ip) : ''
} }
function oneTunnelProto(tunnel?: TunnelInfo): string { function oneTunnelProto(tunnel?: TunnelInfo): string {
@@ -131,7 +125,7 @@ function oneTunnelProto(tunnel?: TunnelInfo): string {
} }
function tunnelProto(info: PeerRoutePair) { function tunnelProto(info: PeerRoutePair) {
return [...new Set(info.peer?.conns.map(c => oneTunnelProto(c.tunnel)))].join(',') return [...new Set(peerConns(info).map(c => oneTunnelProto(c.tunnel)))].join(',')
} }
const myNodeInfo = computed(() => { const myNodeInfo = computed(() => {
@@ -206,7 +200,7 @@ const myNodeInfoChips = computed(() => {
// local ipv4s // local ipv4s
const local_ipv4s = my_node_info.ips?.interface_ipv4s const local_ipv4s = my_node_info.ips?.interface_ipv4s
for (const [idx, ip] of local_ipv4s?.entries()) { for (const [idx, ip] of local_ipv4s?.entries() ?? []) {
chips.push({ chips.push({
label: `Local IPv4 ${idx}: ${ipv4ToString(ip)}`, label: `Local IPv4 ${idx}: ${ipv4ToString(ip)}`,
icon: '', icon: '',
@@ -215,7 +209,7 @@ const myNodeInfoChips = computed(() => {
// local ipv6s // local ipv6s
const local_ipv6s = my_node_info.ips?.interface_ipv6s const local_ipv6s = my_node_info.ips?.interface_ipv6s
for (const [idx, ip] of local_ipv6s?.entries()) { for (const [idx, ip] of local_ipv6s?.entries() ?? []) {
chips.push({ chips.push({
label: `Local IPv6 ${idx}: ${ipv6ToString(ip)}`, label: `Local IPv6 ${idx}: ${ipv6ToString(ip)}`,
icon: '', icon: '',
@@ -226,7 +220,7 @@ const myNodeInfoChips = computed(() => {
const public_ip = my_node_info.ips?.public_ipv4 const public_ip = my_node_info.ips?.public_ipv4
if (public_ip) { if (public_ip) {
chips.push({ chips.push({
label: `Public IP: ${IPv4.fromNumber(public_ip.addr)}`, label: `Public IP: ${ipv4ToString(public_ip)}`,
icon: '', icon: '',
} as Chip) } as Chip)
} }
@@ -241,7 +235,7 @@ const myNodeInfoChips = computed(() => {
// listeners: // listeners:
const listeners = my_node_info.listeners const listeners = my_node_info.listeners
for (const [idx, listener] of listeners?.entries()) { for (const [idx, listener] of listeners?.entries() ?? []) {
chips.push({ chips.push({
label: `Listener ${idx}: ${listener.url}`, label: `Listener ${idx}: ${listener.url}`,
icon: '', icon: '',
@@ -288,6 +282,14 @@ function natType(info: PeerRoutePair): string {
return '' return ''
} }
function isPublicServerRoute(info: PeerRoutePair): boolean {
return info.route?.feature_flag?.is_public_server ?? false
}
function shouldAvoidRelayData(info: PeerRoutePair): boolean {
return info.route?.feature_flag?.avoid_relay_data ?? false
}
const peerCount = computed(() => { const peerCount = computed(() => {
if (!peerRouteInfos.value) if (!peerRouteInfos.value)
return 0 return 0
@@ -342,7 +344,7 @@ function showEventLogs() {
if (!detail) if (!detail)
return return
dialogContent.value = detail.events.map((event: string) => JSON.parse(event)) dialogContent.value = detail.events?.map((event: string) => JSON.parse(event)) ?? []
dialogHeader.value = 'event_log' dialogHeader.value = 'event_log'
dialogVisible.value = true dialogVisible.value = true
} }
@@ -434,16 +436,16 @@ function showEventLogs() {
<Column :field="ipFormat" :header="t('virtual_ipv4')" /> <Column :field="ipFormat" :header="t('virtual_ipv4')" />
<Column :header="t('hostname')"> <Column :header="t('hostname')">
<template #body="slotProps"> <template #body="slotProps">
<div v-if="!slotProps.data.route.cost || !slotProps.data.route.feature_flag.is_public_server" <div v-if="!slotProps.data.route.cost || !isPublicServerRoute(slotProps.data)"
v-tooltip="slotProps.data.route.hostname"> v-tooltip="slotProps.data.route.hostname">
{{ {{
slotProps.data.route.hostname }} slotProps.data.route.hostname }}
</div> </div>
<div v-else v-tooltip="slotProps.data.route.hostname" class="space-x-1"> <div v-else v-tooltip="slotProps.data.route.hostname" class="space-x-1">
<Tag v-if="slotProps.data.route.feature_flag.is_public_server" severity="info" value="Info"> <Tag v-if="isPublicServerRoute(slotProps.data)" severity="info" value="Info">
{{ t('status.server') }} {{ t('status.server') }}
</Tag> </Tag>
<Tag v-if="slotProps.data.route.feature_flag.avoid_relay_data" severity="warn" value="Warn"> <Tag v-if="shouldAvoidRelayData(slotProps.data)" severity="warn" value="Warn">
{{ t('status.relay') }} {{ t('status.relay') }}
</Tag> </Tag>
</div> </div>
@@ -2,7 +2,7 @@
import { Button, Column, DataTable, Divider, InputText, Select, SelectButton, ToggleButton } from 'primevue' import { Button, Column, DataTable, Divider, InputText, Select, SelectButton, ToggleButton } from 'primevue'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { AclAction, AclChain, AclChainType, AclProtocol, AclRule } from '../../types/network' import { AclAction, AclChain, AclChainType, AclProtocol, AclRule, ensureAclChain, ensureAclRuleLists } from '../../types/network'
import AclRuleDialog from './AclRuleDialog.vue' import AclRuleDialog from './AclRuleDialog.vue'
const props = defineProps<{ const props = defineProps<{
@@ -13,7 +13,11 @@ const chain = defineModel<AclChain>({ required: true })
const { t } = useI18n() const { t } = useI18n()
watch(() => chain.value.rules, (newRules) => { function rules() {
return ensureAclChain(chain.value).rules
}
watch(() => rules(), (newRules) => {
if (!newRules) return if (!newRules) return
const isSorted = newRules.every((rule, i) => i === 0 || (rule.priority || 0) <= (newRules[i - 1].priority || 0)) const isSorted = newRules.every((rule, i) => i === 0 || (rule.priority || 0) <= (newRules[i - 1].priority || 0))
if (!isSorted) { if (!isSorted) {
@@ -60,7 +64,7 @@ function addRule() {
editingRule.value = { editingRule.value = {
name: '', name: '',
description: '', description: '',
priority: chain.value.rules.length, priority: rules().length,
enabled: true, enabled: true,
protocol: AclProtocol.Any, protocol: AclProtocol.Any,
ports: [], ports: [],
@@ -79,28 +83,31 @@ function addRule() {
function editRule(index: number) { function editRule(index: number) {
editingRuleIndex.value = index editingRuleIndex.value = index
editingRule.value = JSON.parse(JSON.stringify(chain.value.rules[index])) editingRule.value = ensureAclRuleLists(JSON.parse(JSON.stringify(rules()[index])))
showRuleDialog.value = true showRuleDialog.value = true
} }
function deleteRule(index: number) { function deleteRule(index: number) {
chain.value.rules.splice(index, 1) rules().splice(index, 1)
} }
function saveRule(rule: AclRule) { function saveRule(rule: AclRule) {
const chainRules = rules()
ensureAclRuleLists(rule)
if (editingRuleIndex.value === -1) { if (editingRuleIndex.value === -1) {
chain.value.rules.push(rule) chainRules.push(rule)
} else { } else {
chain.value.rules[editingRuleIndex.value] = rule chainRules[editingRuleIndex.value] = rule
} }
chain.value.rules.sort((a, b) => (b.priority || 0) - (a.priority || 0)) chainRules.sort((a, b) => (b.priority || 0) - (a.priority || 0))
} }
function onRowReorder(event: any) { function onRowReorder(event: any) {
chain.value.rules = event.value chain.value.rules = event.value ?? []
const chainRules = rules()
// Update priorities based on new order (higher priority at top) // Update priorities based on new order (higher priority at top)
chain.value.rules.forEach((rule, index) => { chainRules.forEach((rule, index) => {
rule.priority = chain.value.rules.length - index - 1 rule.priority = chainRules.length - index - 1
}) })
} }
</script> </script>
@@ -143,7 +150,7 @@ function onRowReorder(event: any) {
<Button icon="pi pi-plus" :label="t('acl.add_rule')" severity="success" size="small" @click="addRule" /> <Button icon="pi pi-plus" :label="t('acl.add_rule')" severity="success" size="small" @click="addRule" />
</div> </div>
<DataTable :value="chain.rules" @row-reorder="onRowReorder" responsiveLayout="scroll"> <DataTable :value="rules()" @row-reorder="onRowReorder" responsiveLayout="scroll">
<Column rowReorder headerStyle="width: 3rem" /> <Column rowReorder headerStyle="width: 3rem" />
<Column field="enabled" :header="t('acl.rule.enabled')"> <Column field="enabled" :header="t('acl.rule.enabled')">
<template #body="{ data }"> <template #body="{ data }">
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Button, Column, DataTable, Dialog, InputText, MultiSelect, Password } from 'primevue'; import { Button, Column, DataTable, Dialog, InputText, MultiSelect, Password } from 'primevue';
import { ref } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { GroupIdentity, GroupInfo } from '../../types/network'; import { GroupIdentity, GroupInfo, ensureGroupInfo } from '../../types/network';
const props = defineProps<{ const props = defineProps<{
groupNames?: string[] groupNames?: string[]
@@ -18,6 +18,17 @@ const editingGroupIndex = ref(-1)
const showGroupDialog = ref(false) const showGroupDialog = ref(false)
const oldGroupName = ref('') const oldGroupName = ref('')
function groupInfo() {
return ensureGroupInfo(group.value)
}
const members = computed({
get: () => groupInfo().members,
set: value => {
groupInfo().members = value
},
})
function addGroup() { function addGroup() {
editingGroupIndex.value = -1 editingGroupIndex.value = -1
editingGroup.value = { editingGroup.value = {
@@ -30,13 +41,13 @@ function addGroup() {
function editGroup(index: number) { function editGroup(index: number) {
editingGroupIndex.value = index editingGroupIndex.value = index
editingGroup.value = JSON.parse(JSON.stringify(group.value.declares[index])) editingGroup.value = JSON.parse(JSON.stringify(groupInfo().declares[index]))
oldGroupName.value = editingGroup.value?.group_name || '' oldGroupName.value = editingGroup.value?.group_name || ''
showGroupDialog.value = true showGroupDialog.value = true
} }
function deleteGroup(index: number) { function deleteGroup(index: number) {
group.value.declares.splice(index, 1) groupInfo().declares.splice(index, 1)
} }
function saveGroup() { function saveGroup() {
@@ -44,15 +55,15 @@ function saveGroup() {
const newName = editingGroup.value.group_name const newName = editingGroup.value.group_name
if (editingGroupIndex.value === -1) { if (editingGroupIndex.value === -1) {
group.value.declares.push(editingGroup.value) groupInfo().declares.push(editingGroup.value)
} else { } else {
if (oldGroupName.value && oldGroupName.value !== newName) { if (oldGroupName.value && oldGroupName.value !== newName) {
// Sync in members // Sync in members
group.value.members = group.value.members.map(m => m === oldGroupName.value ? newName : m) groupInfo().members = groupInfo().members.map(m => m === oldGroupName.value ? newName : m)
// Notify parent to sync in rules // Notify parent to sync in rules
emit('rename-group', { oldName: oldGroupName.value, newName }) emit('rename-group', { oldName: oldGroupName.value, newName })
} }
group.value.declares[editingGroupIndex.value] = editingGroup.value groupInfo().declares[editingGroupIndex.value] = editingGroup.value
} }
showGroupDialog.value = false showGroupDialog.value = false
} }
@@ -70,7 +81,7 @@ function saveGroup() {
<Button icon="pi pi-plus" :label="t('web.common.add')" severity="success" @click="addGroup" /> <Button icon="pi pi-plus" :label="t('web.common.add')" severity="success" @click="addGroup" />
</div> </div>
<DataTable :value="group.declares" responsiveLayout="scroll"> <DataTable :value="groupInfo().declares" responsiveLayout="scroll">
<Column field="group_name" :header="t('acl.group.name')" /> <Column field="group_name" :header="t('acl.group.name')" />
<Column field="group_secret" :header="t('acl.group.secret')"> <Column field="group_secret" :header="t('acl.group.secret')">
<template #body="{ data }"> <template #body="{ data }">
@@ -90,7 +101,7 @@ function saveGroup() {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="font-bold text-lg">{{ t('acl.group.members') }}</label> <label class="font-bold text-lg">{{ t('acl.group.members') }}</label>
<MultiSelect v-model="group.members" :options="props.groupNames" multiple fluid filter <MultiSelect v-model="members" :options="props.groupNames" multiple fluid filter
:placeholder="t('acl.group.members')" /> :placeholder="t('acl.group.members')" />
</div> </div>
@@ -2,7 +2,7 @@
import { Button, Menu, Tab, TabList, TabPanel, TabPanels, Tabs } from 'primevue' import { Button, Menu, Tab, TabList, TabPanel, TabPanels, Tabs } from 'primevue'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Acl, AclAction, AclChainType } from '../../types/network' import { Acl, AclAction, AclChainType, ensureAclV1 } from '../../types/network'
import AclChainEditor from './AclChainEditor.vue' import AclChainEditor from './AclChainEditor.vue'
import AclGroupEditor from './AclGroupEditor.vue' import AclGroupEditor from './AclGroupEditor.vue'
@@ -12,6 +12,7 @@ const { t } = useI18n()
const activeTab = ref(0) const activeTab = ref(0)
const menu = ref() const menu = ref()
const aclV1 = computed(() => ensureAclV1(acl.value))
const addMenuModel = ref([ const addMenuModel = ref([
{ label: () => t('acl.inbound'), command: () => addChain(AclChainType.Inbound) }, { label: () => t('acl.inbound'), command: () => addChain(AclChainType.Inbound) },
@@ -20,10 +21,6 @@ const addMenuModel = ref([
]) ])
function addChain(type: AclChainType) { function addChain(type: AclChainType) {
if (!acl.value.acl_v1) {
acl.value.acl_v1 = { chains: [], group: { declares: [], members: [] } }
}
let defaultName = '' let defaultName = ''
switch (type) { switch (type) {
case AclChainType.Inbound: defaultName = 'Inbound'; break; case AclChainType.Inbound: defaultName = 'Inbound'; break;
@@ -31,7 +28,7 @@ function addChain(type: AclChainType) {
case AclChainType.Forward: defaultName = 'Forward'; break; case AclChainType.Forward: defaultName = 'Forward'; break;
} }
acl.value.acl_v1.chains.push({ aclV1.value.chains.push({
name: defaultName, name: defaultName,
chain_type: type, chain_type: type,
description: '', description: '',
@@ -40,21 +37,20 @@ function addChain(type: AclChainType) {
default_action: AclAction.Allow default_action: AclAction.Allow
}) })
activeTab.value = acl.value.acl_v1.chains.length - 1 activeTab.value = aclV1.value.chains.length - 1
} }
function removeChain(index: number) { function removeChain(index: number) {
if (confirm(t('acl.delete_chain_confirm'))) { if (confirm(t('acl.delete_chain_confirm'))) {
acl.value.acl_v1?.chains.splice(index, 1) aclV1.value.chains.splice(index, 1)
if (activeTab.value >= (acl.value.acl_v1?.chains.length || 0)) { if (activeTab.value >= aclV1.value.chains.length) {
activeTab.value = Math.max(0, (acl.value.acl_v1?.chains.length || 0)) activeTab.value = Math.max(0, aclV1.value.chains.length)
} }
} }
} }
function handleRenameGroup({ oldName, newName }: { oldName: string, newName: string }) { function handleRenameGroup({ oldName, newName }: { oldName: string, newName: string }) {
if (!acl.value.acl_v1) return aclV1.value.chains.forEach(chain => {
acl.value.acl_v1.chains.forEach(chain => {
chain.rules.forEach(rule => { chain.rules.forEach(rule => {
rule.source_groups = rule.source_groups.map(g => g === oldName ? newName : g) rule.source_groups = rule.source_groups.map(g => g === oldName ? newName : g)
rule.destination_groups = rule.destination_groups.map(g => g === oldName ? newName : g) rule.destination_groups = rule.destination_groups.map(g => g === oldName ? newName : g)
@@ -63,11 +59,11 @@ function handleRenameGroup({ oldName, newName }: { oldName: string, newName: str
} }
const groupNames = computed(() => { const groupNames = computed(() => {
return acl.value.acl_v1?.group?.declares.map(g => g.group_name) || [] return aclV1.value.group?.declares.map(g => g.group_name) || []
}) })
const tabs = computed(() => { const tabs = computed(() => {
const chains = acl.value.acl_v1?.chains || [] const chains = aclV1.value.chains
const result: { type: string, label: string, index: number }[] = [] const result: { type: string, label: string, index: number }[] = []
if (chains.length === 0) { if (chains.length === 0) {
@@ -124,24 +120,13 @@ const tabs = computed(() => {
</div> </div>
<!-- Rule Chains --> <!-- Rule Chains -->
<div v-if="tab.type === 'chain' && acl.acl_v1 && acl.acl_v1.chains[tab.index]" class="py-4"> <div v-if="tab.type === 'chain' && aclV1.chains[tab.index]" class="py-4">
<AclChainEditor v-model="acl.acl_v1.chains[tab.index]" :group-names="groupNames" /> <AclChainEditor v-model="aclV1.chains[tab.index]" :group-names="groupNames" />
</div> </div>
<!-- Group Management --> <!-- Group Management -->
<div v-if="tab.type === 'groups'" class="py-4"> <div v-if="tab.type === 'groups'" class="py-4">
<template v-if="acl.acl_v1"> <AclGroupEditor v-model="aclV1.group" :group-names="groupNames" @rename-group="handleRenameGroup" />
<AclGroupEditor v-if="acl.acl_v1.group" v-model="acl.acl_v1.group" :group-names="groupNames"
@rename-group="handleRenameGroup" />
<div v-else class="flex justify-center p-4">
<Button :label="t('web.common.add') + ' ' + t('acl.groups')"
@click="acl.acl_v1.group = { declares: [], members: [] }" />
</div>
</template>
<div v-else class="flex justify-center p-4">
<Button :label="t('acl.enabled')"
@click="acl.acl_v1 = { chains: [], group: { declares: [], members: [] } }" />
</div>
</div> </div>
</TabPanel> </TabPanel>
</TabPanels> </TabPanels>
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { AutoComplete, Button, Checkbox, Dialog, InputNumber, InputText, MultiSelect, Panel, SelectButton, ToggleButton } from 'primevue'; import { AutoComplete, Button, Checkbox, Dialog, InputNumber, InputText, MultiSelect, Panel, SelectButton, ToggleButton } from 'primevue';
import { computed, ref } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { AclAction, AclProtocol, AclRule } from '../../types/network'; import { AclAction, AclProtocol, AclRule, ensureAclRuleLists } from '../../types/network';
const props = defineProps<{ const props = defineProps<{
visible: boolean visible: boolean
@@ -32,6 +32,8 @@ const showPorts = computed(() => {
return rule.value.protocol === AclProtocol.TCP || rule.value.protocol === AclProtocol.UDP || rule.value.protocol === AclProtocol.Any return rule.value.protocol === AclProtocol.TCP || rule.value.protocol === AclProtocol.UDP || rule.value.protocol === AclProtocol.Any
}) })
watch(() => rule.value, ensureAclRuleLists, { immediate: true })
function close() { function close() {
emit('update:visible', false) emit('update:visible', false)
} }
@@ -341,6 +341,8 @@ web:
import_config: 导入配置 import_config: 导入配置
create_new: 创建新网络 create_new: 创建新网络
network_status: 网络状态 network_status: 网络状态
loading_network_status: 正在加载网络状态
network_info_unavailable: 网络状态不可用
network_configuration: 网络配置 network_configuration: 网络配置
loading_network_configuration: 加载网络配置 loading_network_configuration: 加载网络配置
no_network_selected: 未选择网络 no_network_selected: 未选择网络
@@ -341,6 +341,8 @@ web:
import_config: Import Config import_config: Import Config
create_new: Create New Network create_new: Create New Network
network_status: Network Status network_status: Network Status
loading_network_status: Loading Network Status
network_info_unavailable: Network status is unavailable
network_configuration: Network Configuration network_configuration: Network Configuration
loading_network_configuration: Loading Network Configuration loading_network_configuration: Loading Network Configuration
no_network_selected: No Network Selected no_network_selected: No Network Selected
@@ -0,0 +1,82 @@
import type { PeerRoutePair } from '../types/network'
export function numericValue(value: unknown): number | undefined {
if (typeof value === 'number')
return Number.isFinite(value) ? value : undefined
if (typeof value !== 'string' || value.trim() === '')
return undefined
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
}
export function peerConns(info: PeerRoutePair) {
return info.peer?.conns || []
}
function defaultConnId(info: PeerRoutePair) {
const defaultConn = info.peer?.default_conn_id
if (!defaultConn)
return undefined
const part1 = defaultConn.part1 ?? 0
const part2 = defaultConn.part2 ?? 0
const part3 = defaultConn.part3 ?? 0
const part4 = defaultConn.part4 ?? 0
if (part1 === 0 && part2 === 0 && part3 === 0 && part4 === 0)
return undefined
const toHex = (value: number) => value.toString(16).padStart(8, '0')
const part1Hex = toHex(part1)
const part2Hex = toHex(part2)
const part3Hex = toHex(part3)
const part4Hex = toHex(part4)
return `${part1Hex}-${part2Hex.slice(0, 4)}-${part2Hex.slice(4, 8)}-${part3Hex.slice(0, 4)}-${part3Hex.slice(4, 8)}${part4Hex}`
}
function defaultConnFirst(info: PeerRoutePair) {
const conns = peerConns(info)
const connId = defaultConnId(info)
if (!connId)
return conns
const defaultConn = conns.find(conn => conn.conn_id === connId)
return defaultConn ? [defaultConn, ...conns.filter(conn => conn !== defaultConn)] : conns
}
export function latencyMs(info: PeerRoutePair) {
const connId = defaultConnId(info)
let minLatencyUs: number | undefined
for (const conn of peerConns(info)) {
if (!conn.stats)
continue
const latencyUs = numericValue(conn.stats.latency_us)
if (latencyUs === undefined)
continue
if (connId === conn.conn_id)
return `${Math.ceil(latencyUs / 1000)}ms`
minLatencyUs = Math.min(minLatencyUs ?? latencyUs, latencyUs)
}
if (minLatencyUs === undefined)
return ''
return `${Math.ceil(minLatencyUs / 1000)}ms`
}
export function lossRate(info: PeerRoutePair) {
for (const conn of defaultConnFirst(info)) {
const loss = numericValue(conn.loss_rate)
if (loss === undefined)
continue
return `${Math.round(loss * 100)}%`
}
return ''
}
+27 -17
View File
@@ -1,24 +1,30 @@
import { IPv4, IPv6 } from 'ip-num/IPNumber' import { IPv4, IPv6 } from 'ip-num/IPNumber'
import { Ipv4Addr, Ipv4Inet, Ipv6Addr } from '../types/network' import { Ipv4Addr, Ipv4Inet, Ipv6Addr } from '../types/network'
export function ipv4ToString(ip: Ipv4Addr) { export function ipv4ToString(ip: Ipv4Addr | null | undefined) {
return IPv4.fromNumber(ip.addr).toString() if (!ip) {
return ''
}
return IPv4.fromNumber(ip.addr ?? 0).toString()
} }
export function ipv4InetToString(ip: Ipv4Inet | undefined) { export function ipv4InetToString(ip: Ipv4Inet | undefined) {
if (ip?.address === undefined) { if (ip?.address === undefined) {
return 'undefined' return 'undefined'
} }
return `${ipv4ToString(ip.address)}/${ip.network_length}` return `${ipv4ToString(ip.address)}/${ip.network_length ?? 0}`
} }
export function ipv6ToString(ip: Ipv6Addr) { export function ipv6ToString(ip: Ipv6Addr | null | undefined) {
if (!ip) {
return ''
}
return IPv6.fromBigInt( return IPv6.fromBigInt(
(BigInt(ip.part1) << BigInt(96)) (BigInt(ip.part1 ?? 0) << BigInt(96))
+ (BigInt(ip.part2) << BigInt(64)) + (BigInt(ip.part2 ?? 0) << BigInt(64))
+ (BigInt(ip.part3) << BigInt(32)) + (BigInt(ip.part3 ?? 0) << BigInt(32))
+ BigInt(ip.part4), + BigInt(ip.part4 ?? 0),
) ).toString()
} }
function toHexString(uint64: bigint, padding = 9): string { function toHexString(uint64: bigint, padding = 9): string {
@@ -43,14 +49,17 @@ function uint32ToUuid(part1: number, part2: number, part3: number, part4: number
} }
export interface UUID { export interface UUID {
part1: number; part1?: number;
part2: number; part2?: number;
part3: number; part3?: number;
part4: number; part4?: number;
} }
export function UuidToStr(uuid: UUID): string { export function UuidToStr(uuid: UUID | null | undefined): string {
return uint32ToUuid(uuid.part1, uuid.part2, uuid.part3, uuid.part4); if (!uuid) {
return '';
}
return uint32ToUuid(uuid.part1 ?? 0, uuid.part2 ?? 0, uuid.part3 ?? 0, uuid.part4 ?? 0);
} }
export interface Location { export interface Location {
@@ -71,11 +80,12 @@ export interface DeviceInfo {
} }
export function buildDeviceInfo(device: any): DeviceInfo { export function buildDeviceInfo(device: any): DeviceInfo {
const runningInstances = device.info?.running_network_instances ?? [];
let dev_info: DeviceInfo = { let dev_info: DeviceInfo = {
hostname: device.info?.hostname, hostname: device.info?.hostname,
public_ip: device.client_url, public_ip: device.client_url,
running_network_instances: device.info?.running_network_instances.map((instance: any) => UuidToStr(instance)), running_network_instances: runningInstances.map((instance: any) => UuidToStr(instance)),
running_network_count: device.info?.running_network_instances.length, running_network_count: runningInstances.length,
report_time: device.info?.report_time, report_time: device.info?.report_time,
easytier_version: device.info?.easytier_version, easytier_version: device.info?.easytier_version,
machine_id: UuidToStr(device.info?.machine_id), machine_id: UuidToStr(device.info?.machine_id),
+155 -20
View File
@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid'
import { import {
NetworkConfig as NetworkConfigPb, NetworkConfig as NetworkConfigPb,
NetworkingMethod, NetworkingMethod,
type NetworkPeerConfig,
type NetworkConfig as ProtoNetworkConfig, type NetworkConfig as ProtoNetworkConfig,
type PortForwardConfig, type PortForwardConfig,
} from '../generated/proto/api_manage' } from '../generated/proto/api_manage'
@@ -16,11 +17,16 @@ import {
type GroupInfo, type GroupInfo,
type Rule as AclRule, type Rule as AclRule,
} from '../generated/proto/acl' } from '../generated/proto/acl'
import { CompressionAlgoPb, NatType, type SecureModeConfig } from '../generated/proto/common' import {
CompressionAlgoPb,
NatType,
type PeerFeatureFlag,
type SecureModeConfig,
} from '../generated/proto/common'
import { prepareNetworkConfigForProtoJson } from './networkCompat' import { prepareNetworkConfigForProtoJson } from './networkCompat'
export { AclAction, AclChainType, AclProtocol, CompressionAlgoPb, NatType, NetworkingMethod } export { AclAction, AclChainType, AclProtocol, CompressionAlgoPb, NatType, NetworkingMethod }
export type { Acl, AclChain, AclRule, AclV1, GroupIdentity, GroupInfo, PortForwardConfig, SecureModeConfig } export type { Acl, AclChain, AclRule, AclV1, GroupIdentity, GroupInfo, NetworkPeerConfig, PeerFeatureFlag, PortForwardConfig, SecureModeConfig }
export type NetworkConfig = Omit< export type NetworkConfig = Omit<
ProtoNetworkConfig, ProtoNetworkConfig,
@@ -32,14 +38,39 @@ export type NetworkConfig = Omit<
networking_method: NetworkingMethod | string networking_method: NetworkingMethod | string
} }
export type NormalizedAclV1 = AclV1 & {
group: GroupInfo
}
const UINT64_MAX = (1n << 64n) - 1n const UINT64_MAX = (1n << 64n) - 1n
interface NetworkingConfigFields { interface NetworkingConfigFields {
peer_urls: string[] peer_urls: string[]
peers?: NetworkPeerConfig[]
public_server_url?: string public_server_url?: string
networking_method?: NetworkingMethod | string networking_method?: NetworkingMethod | string
} }
interface NetworkingMethodOptions {
fillPeerUrlsFromPeers?: boolean
}
function emptyGroupInfo(): GroupInfo {
return {
declares: [],
members: [],
}
}
function emptyAcl(): Acl {
return {
acl_v1: {
group: emptyGroupInfo(),
chains: [],
},
}
}
export function DEFAULT_NETWORK_CONFIG(): NetworkConfig { export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
return { return {
...NetworkConfigPb.create(), ...NetworkConfigPb.create(),
@@ -110,15 +141,7 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
enable_magic_dns: false, enable_magic_dns: false,
enable_private_mode: false, enable_private_mode: false,
port_forwards: [], port_forwards: [],
acl: { acl: emptyAcl(),
acl_v1: {
group: {
declares: [],
members: [],
},
chains: [],
},
},
} }
} }
@@ -126,6 +149,88 @@ function cleanPeerUrls(urls: string[] | undefined): string[] {
return (urls ?? []).map((url) => url.trim()).filter((url) => url.length > 0) return (urls ?? []).map((url) => url.trim()).filter((url) => url.length > 0)
} }
function cleanNetworkPeers(peers: NetworkPeerConfig[] | undefined): NetworkPeerConfig[] {
return (peers ?? [])
.map((peer) => ({
...peer,
uri: peer.uri.trim(),
}))
.filter((peer) => peer.uri.length > 0)
}
function peersFromUrls(urls: string[], existingPeers: NetworkPeerConfig[]): NetworkPeerConfig[] {
const peersByUri = new Map<string, NetworkPeerConfig>()
for (const peer of existingPeers) {
if (!peersByUri.has(peer.uri)) {
peersByUri.set(peer.uri, peer)
}
}
return urls.map((uri) => ({
...(peersByUri.get(uri) ?? {}),
uri,
}))
}
export function ensureAclRuleLists(rule: AclRule): AclRule {
rule.ports ??= []
rule.source_ips ??= []
rule.destination_ips ??= []
rule.source_ports ??= []
rule.source_groups ??= []
rule.destination_groups ??= []
return rule
}
export function ensureAclChain(chain: AclChain): AclChain {
chain.rules ??= []
chain.rules.forEach(ensureAclRuleLists)
return chain
}
export function ensureGroupInfo(group: GroupInfo): GroupInfo {
group.declares ??= []
group.members ??= []
return group
}
export function ensureAclV1(acl: Acl): NormalizedAclV1 {
acl.acl_v1 ??= { chains: [], group: emptyGroupInfo() }
acl.acl_v1.chains ??= []
acl.acl_v1.chains.forEach(ensureAclChain)
acl.acl_v1.group = ensureGroupInfo(acl.acl_v1.group ?? emptyGroupInfo())
return acl.acl_v1 as NormalizedAclV1
}
function normalizeAcl(acl: Acl | undefined): Acl {
const source = acl ?? emptyAcl()
const aclV1 = source.acl_v1 ?? { chains: [], group: emptyGroupInfo() }
return {
...source,
acl_v1: {
...aclV1,
chains: (aclV1.chains ?? []).map((chain) => ({
...chain,
rules: (chain.rules ?? []).map((rule) => ({ ...ensureAclRuleLists({ ...rule }) })),
})),
group: ensureGroupInfo({
...(aclV1.group ?? emptyGroupInfo()),
declares: aclV1.group?.declares ?? [],
members: aclV1.group?.members ?? [],
}),
},
}
}
function isGroupInfoEmpty(group: GroupInfo | undefined): boolean {
return (group?.declares?.length ?? 0) === 0 && (group?.members?.length ?? 0) === 0
}
function isAclEmpty(acl: Acl | undefined): boolean {
const aclV1 = acl?.acl_v1
return !aclV1 || ((aclV1.chains?.length ?? 0) === 0 && isGroupInfoEmpty(aclV1.group))
}
function normalizeUint64ForInput(v: bigint | number | string | null | undefined): number | string | null { function normalizeUint64ForInput(v: bigint | number | string | null | undefined): number | string | null {
if (v == null) return null if (v == null) return null
@@ -154,15 +259,24 @@ function toBackendUint64(v: number | bigint | string | null | undefined): bigint
} }
} }
function applyNetworkingMethod(config: NetworkingConfigFields): void { function applyNetworkingMethod(
config: NetworkingConfigFields,
options: NetworkingMethodOptions = {},
): void {
const existingPeers = cleanNetworkPeers(config.peers)
config.peer_urls = cleanPeerUrls(config.peer_urls) config.peer_urls = cleanPeerUrls(config.peer_urls)
if (options.fillPeerUrlsFromPeers && config.peer_urls.length === 0 && existingPeers.length > 0) {
config.peer_urls = existingPeers.map((peer) => peer.uri)
}
const publicServerUrl = config.public_server_url?.trim() ?? '' const publicServerUrl = config.public_server_url?.trim() ?? ''
const networkingMethod = config.networking_method ?? NetworkingMethod.Manual const networkingMethod = config.networking_method ?? NetworkingMethod.Manual
switch (networkingMethod) { switch (networkingMethod) {
case NetworkingMethod.PublicServer: case NetworkingMethod.PublicServer:
config.peer_urls = publicServerUrl ? [publicServerUrl] : [] config.peer_urls = publicServerUrl
? [publicServerUrl]
: (options.fillPeerUrlsFromPeers ? existingPeers.map((peer) => peer.uri) : [])
break break
case NetworkingMethod.Manual: case NetworkingMethod.Manual:
break break
@@ -174,6 +288,7 @@ function applyNetworkingMethod(config: NetworkingConfigFields): void {
config.networking_method = NetworkingMethod.Manual config.networking_method = NetworkingMethod.Manual
config.public_server_url = '' config.public_server_url = ''
config.peers = peersFromUrls(config.peer_urls, existingPeers)
} }
export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig { export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
@@ -181,11 +296,19 @@ export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
ignoreUnknownFields: true, ignoreUnknownFields: true,
}) as unknown as NetworkConfig }) as unknown as NetworkConfig
applyNetworkingMethod(normalized) applyNetworkingMethod(normalized, { fillPeerUrlsFromPeers: true })
normalized.mtu = normalizeNumberForInput(normalized.mtu) normalized.mtu = normalizeNumberForInput(normalized.mtu)
normalized.instance_recv_bps_limit = normalizeUint64ForInput( normalized.instance_recv_bps_limit = normalizeUint64ForInput(
normalized.instance_recv_bps_limit as any, normalized.instance_recv_bps_limit as any,
) )
normalized.proxy_cidrs ??= []
normalized.listener_urls ??= []
normalized.relay_network_whitelist ??= []
normalized.routes ??= []
normalized.exit_nodes ??= []
normalized.mapped_listeners ??= []
normalized.port_forwards ??= []
normalized.acl = config.acl === undefined ? undefined : normalizeAcl(normalized.acl)
return normalized return normalized
} }
@@ -198,6 +321,9 @@ export function toBackendNetworkConfig(config: NetworkConfig): NetworkConfig {
applyNetworkingMethod(backend) applyNetworkingMethod(backend)
backend.mtu = normalizeNumberForInput(config.mtu) ?? undefined backend.mtu = normalizeNumberForInput(config.mtu) ?? undefined
backend.instance_recv_bps_limit = toBackendUint64(config.instance_recv_bps_limit) backend.instance_recv_bps_limit = toBackendUint64(config.instance_recv_bps_limit)
if (config.acl === undefined || isAclEmpty(config.acl)) {
backend.acl = undefined
}
return NetworkConfigPb.toJson(backend, { return NetworkConfigPb.toJson(backend, {
useProtoFieldName: true, useProtoFieldName: true,
@@ -286,6 +412,7 @@ export interface Route {
proxy_cidrs: string[] proxy_cidrs: string[]
hostname: string hostname: string
stun_info?: StunInfo stun_info?: StunInfo
feature_flag?: PeerFeatureFlag
inst_id: string inst_id: string
version: string version: string
} }
@@ -293,6 +420,7 @@ export interface Route {
export interface PeerInfo { export interface PeerInfo {
peer_id: number peer_id: number
conns: PeerConnInfo[] conns: PeerConnInfo[]
default_conn_id?: CommonUuid
} }
export interface PeerConnInfo { export interface PeerConnInfo {
@@ -303,7 +431,7 @@ export interface PeerConnInfo {
features: string[] features: string[]
tunnel?: TunnelInfo tunnel?: TunnelInfo
stats?: PeerConnStats stats?: PeerConnStats
loss_rate: number loss_rate?: number | string
} }
export interface PeerRoutePair { export interface PeerRoutePair {
@@ -322,11 +450,18 @@ export interface TunnelInfo {
} }
export interface PeerConnStats { export interface PeerConnStats {
rx_bytes: number rx_bytes: number | string
tx_bytes: number tx_bytes: number | string
rx_packets: number rx_packets: number | string
tx_packets: number tx_packets: number | string
latency_us: number latency_us: number | string
}
export interface CommonUuid {
part1?: number
part2?: number
part3?: number
part4?: number
} }
// 添加新行 // 添加新行
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import { latencyMs, lossRate } from '../src/modules/statusDisplay'
import { ipv4ToString, ipv6ToString } from '../src/modules/utils'
function peerRoutePair(conns: any[]) {
return {
route: {
ipv4_addr: '10.0.0.2',
hostname: 'peer',
version: 'test',
},
peer: {
conns,
},
} as any
}
function peerRoutePairWithDefaultConn(conns: any[], defaultConnId: string) {
const [part1, part2, part3, part4] = defaultConnId
.replaceAll('-', '')
.match(/.{8}/g)!
.map((part) => Number.parseInt(part, 16))
return {
...peerRoutePair(conns),
peer: {
default_conn_id: {
part1,
part2,
part3,
part4,
},
conns,
},
} as any
}
describe('status display helpers', () => {
it('does not render missing IP values as zero addresses', () => {
expect(ipv4ToString(undefined)).toBe('')
expect(ipv4ToString(null)).toBe('')
expect(ipv4ToString({} as any)).toBe('0.0.0.0')
expect(ipv4ToString({ addr: 0 })).toBe('0.0.0.0')
expect(ipv6ToString(undefined)).toBe('')
expect(ipv6ToString(null)).toBe('')
expect(ipv6ToString({} as any)).toBe('::0')
expect(ipv6ToString({ part1: 0, part2: 0, part3: 0, part4: 0 })).toBe('::0')
expect(ipv6ToString({ part4: 1 } as any)).toBe('::1')
})
it('skips missing latency and loss values', () => {
expect(latencyMs(peerRoutePair([
{ conn_id: 'missing', stats: {} },
{ conn_id: 'valid', stats: { latency_us: '2500' } },
{ conn_id: 'invalid', stats: { latency_us: 'unknown' } },
]))).toBe('3ms')
expect(latencyMs(peerRoutePair([
{ conn_id: 'missing', stats: {} },
{ conn_id: 'invalid', stats: { latency_us: 'unknown' } },
]))).toBe('')
expect(lossRate(peerRoutePair([
{ conn_id: 'missing' },
{ conn_id: 'valid', loss_rate: '0.25' },
{ conn_id: 'invalid', loss_rate: 'unknown' },
]))).toBe('25%')
expect(lossRate(peerRoutePair([
{ conn_id: 'missing' },
{ conn_id: 'invalid', loss_rate: 'unknown' },
]))).toBe('')
})
it('prefers the default connection when its metric is valid', () => {
const defaultConnId = '00000001-0002-0003-0004-000000000005'
const conns = [
{ conn_id: 'fallback', stats: { latency_us: '1000' }, loss_rate: '0.01' },
{ conn_id: defaultConnId, stats: { latency_us: '9000' }, loss_rate: '0.5' },
]
expect(latencyMs(peerRoutePairWithDefaultConn(conns, defaultConnId))).toBe('9ms')
expect(lossRate(peerRoutePairWithDefaultConn(conns, defaultConnId))).toBe('50%')
})
})
+1 -1
View File
@@ -218,7 +218,7 @@ class WebRemoteClient implements Api.RemoteClient {
} }
async get_network_info(inst_id: string): Promise<NetworkTypes.NetworkInstanceRunningInfo | undefined> { async get_network_info(inst_id: string): Promise<NetworkTypes.NetworkInstanceRunningInfo | undefined> {
const response = await this.client.get<any, Api.CollectNetworkInfoResponse>('/machines/' + this.machine_id + '/networks/info/' + inst_id); const response = await this.client.get<any, Api.CollectNetworkInfoResponse>('/machines/' + this.machine_id + '/networks/info/' + inst_id);
return response.info.map[inst_id]; return response.info?.map?.[inst_id];
} }
async list_network_instance_ids(): Promise<Api.ListNetworkInstanceIdResponse> { async list_network_instance_ids(): Promise<Api.ListNetworkInstanceIdResponse> {
const response = await this.client.get<any, ListNetworkInstanceIdResponse>('/machines/' + this.machine_id + '/networks'); const response = await this.client.get<any, ListNetworkInstanceIdResponse>('/machines/' + this.machine_id + '/networks');
+111 -18
View File
@@ -626,6 +626,47 @@ pub type NetworkingMethod = crate::proto::api::manage::NetworkingMethod;
pub type NetworkConfig = crate::proto::api::manage::NetworkConfig; pub type NetworkConfig = crate::proto::api::manage::NetworkConfig;
impl NetworkConfig { impl NetworkConfig {
fn parse_peer(peer: &manage::NetworkPeerConfig) -> Result<Option<PeerConfig>, anyhow::Error> {
let uri = peer.uri.trim();
if uri.is_empty() {
return Ok(None);
}
Ok(Some(PeerConfig {
uri: uri
.parse()
.with_context(|| format!("failed to parse peer uri: {}", uri))?,
peer_public_key: peer.peer_public_key.clone(),
}))
}
fn parse_peers(peers: &[manage::NetworkPeerConfig]) -> Result<Vec<PeerConfig>, anyhow::Error> {
let mut ret = Vec::new();
for peer in peers {
if let Some(peer) = Self::parse_peer(peer)? {
ret.push(peer);
}
}
Ok(ret)
}
fn parse_peer_urls(peer_urls: &[String]) -> Result<Vec<PeerConfig>, anyhow::Error> {
let mut peers = vec![];
for peer_url in peer_urls.iter() {
let peer_url = peer_url.trim();
if peer_url.is_empty() {
continue;
}
peers.push(PeerConfig {
uri: peer_url
.parse()
.with_context(|| format!("failed to parse peer uri: {}", peer_url))?,
peer_public_key: None,
});
}
Ok(peers)
}
pub fn gen_config(&self) -> Result<TomlConfigLoader, anyhow::Error> { pub fn gen_config(&self) -> Result<TomlConfigLoader, anyhow::Error> {
let cfg = TomlConfigLoader::default(); let cfg = TomlConfigLoader::default();
cfg.set_id( cfg.set_id(
@@ -681,26 +722,23 @@ impl NetworkConfig {
.unwrap_or_default() .unwrap_or_default()
{ {
NetworkingMethod::PublicServer => { NetworkingMethod::PublicServer => {
let public_server_url = self.public_server_url.clone().unwrap_or_default(); let peers = Self::parse_peers(&self.peers)?;
cfg.set_peers(vec![PeerConfig { if peers.is_empty() {
uri: public_server_url.parse().with_context(|| { let public_server_url = self.public_server_url.clone().unwrap_or_default();
format!("failed to parse public server uri: {}", public_server_url) cfg.set_peers(vec![PeerConfig {
})?, uri: public_server_url.parse().with_context(|| {
peer_public_key: None, format!("failed to parse public server uri: {}", public_server_url)
}]); })?,
peer_public_key: None,
}]);
} else {
cfg.set_peers(peers);
}
} }
NetworkingMethod::Manual => { NetworkingMethod::Manual => {
let mut peers = vec![]; let mut peers = Self::parse_peers(&self.peers)?;
for peer_url in self.peer_urls.iter() { if peers.is_empty() {
if peer_url.is_empty() { peers = Self::parse_peer_urls(&self.peer_urls)?;
continue;
}
peers.push(PeerConfig {
uri: peer_url
.parse()
.with_context(|| format!("failed to parse peer uri: {}", peer_url))?,
peer_public_key: None,
});
} }
if !peers.is_empty() { if !peers.is_empty() {
cfg.set_peers(peers); cfg.set_peers(peers);
@@ -1044,6 +1082,13 @@ impl NetworkConfig {
result.networking_method = Some(NetworkingMethod::Manual as i32); result.networking_method = Some(NetworkingMethod::Manual as i32);
if !peers.is_empty() { if !peers.is_empty() {
result.peer_urls = peers.iter().map(|p| p.uri.to_string()).collect(); result.peer_urls = peers.iter().map(|p| p.uri.to_string()).collect();
result.peers = peers
.iter()
.map(|p| manage::NetworkPeerConfig {
uri: p.uri.to_string(),
peer_public_key: p.peer_public_key.clone(),
})
.collect();
} }
result.listener_urls = config result.listener_urls = config
@@ -1242,6 +1287,54 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn test_network_config_conversion_preserves_peer_public_key() -> Result<(), anyhow::Error> {
let peer_url = "tcp://1.2.3.4:11010";
let peer_public_key = BASE64_STANDARD.encode([9u8; 32]);
let config = gen_default_config();
config.set_peers(vec![crate::common::config::PeerConfig {
uri: peer_url.parse()?,
peer_public_key: Some(peer_public_key.clone()),
}]);
let network_config = super::NetworkConfig::new_from_config(&config)?;
assert_eq!(network_config.peer_urls, vec![peer_url.to_string()]);
assert_eq!(network_config.peers.len(), 1);
assert_eq!(network_config.peers[0].uri, peer_url);
assert_eq!(
network_config.peers[0].peer_public_key.as_deref(),
Some(peer_public_key.as_str())
);
let generated_config = network_config.gen_config()?;
assert_eq!(generated_config.get_peers(), config.get_peers());
Ok(())
}
#[test]
fn network_config_gen_config_trims_legacy_peer_urls() -> Result<(), anyhow::Error> {
let network_config = super::NetworkConfig {
instance_id: Some(uuid::Uuid::new_v4().to_string()),
dhcp: Some(true),
networking_method: Some(crate::proto::api::manage::NetworkingMethod::Manual as i32),
peer_urls: vec![
" tcp://1.2.3.4:11010 ".to_string(),
" ".to_string(),
"\tudp://5.6.7.8:11010\n".to_string(),
],
..Default::default()
};
let generated_config = network_config.gen_config()?;
let peers = generated_config.get_peers();
assert_eq!(peers.len(), 2);
assert_eq!(peers[0].uri.as_str(), "tcp://1.2.3.4:11010");
assert_eq!(peers[1].uri.as_str(), "udp://5.6.7.8:11010");
Ok(())
}
#[test] #[test]
fn test_network_config_conversion_random() -> Result<(), anyhow::Error> { fn test_network_config_conversion_random() -> Result<(), anyhow::Error> {
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
+6
View File
@@ -102,6 +102,12 @@ message NetworkConfig {
optional bool disable_relay_data = 65; optional bool disable_relay_data = 65;
optional bool enable_udp_broadcast_relay = 66; optional bool enable_udp_broadcast_relay = 66;
optional uint32 socket_mark = 67; optional uint32 socket_mark = 67;
repeated NetworkPeerConfig peers = 68;
}
message NetworkPeerConfig {
string uri = 1;
optional string peer_public_key = 2;
} }
message PortForwardConfig { message PortForwardConfig {