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
@@ -114,6 +114,12 @@ function allFieldFixture() {
networking_method: NetworkingMethod.Manual,
public_server_url: 'tcp://public.example: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'],
enable_vpn_portal: true,
vpn_portal_listen_port: 23000,
@@ -259,6 +265,8 @@ function assertFullFieldRoundTrip() {
assert.equal(backend.networking_method, 'Manual')
assert.equal(backend.public_server_url, '')
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.instance_recv_bps_limit, '9007199254740993')
assert.equal(backend.secure_mode.enabled, true)
@@ -415,6 +423,59 @@ function assertNetworkingMethodNormalization() {
})
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() {
@@ -469,6 +530,7 @@ const tests = [
assertEnumCompatibility,
assertAclDefaultsAndExplicitZero,
assertNetworkingMethodNormalization,
assertPeerPublicKeysPreserved,
assertNumberBoundaries,
]
@@ -35,7 +35,7 @@ const currentNetworkConfig = ref<NetworkTypes.NetworkConfig | undefined>(undefin
const listInstanceIdResponse = ref<Api.ListNetworkInstanceIdResponse | undefined>(undefined);
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>>({});
@@ -46,7 +46,7 @@ const loadNetworkMetas = async (instanceIds: string[]) => {
try {
const response = await props.api.get_network_metas(missingIds);
Object.assign(networkMetaCache.value, response.metas);
Object.assign(networkMetaCache.value, response.metas ?? {});
} catch (e) {
console.error("Failed to load network metas", e);
}
@@ -80,8 +80,8 @@ const updateInstanceList = () => {
let insts = new Set<string>();
let t = listInstanceIdResponse.value;
if (t) {
t.running_inst_ids.forEach((u) => insts.add(Utils.UuidToStr(u)));
t.disabled_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)));
}
const newList = Array.from(insts).map((instance: string) => {
@@ -149,7 +149,7 @@ const networkIsDisabled = computed(() => {
if (!selectedInstanceId.value) {
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) => {
if (newVal !== oldVal && newVal === true) {
@@ -287,17 +287,35 @@ const loadNetworkInstanceIds = async () => {
}
const loadCurrentNetworkInfo = async () => {
if (!selectedInstanceId.value) {
const selected = selectedInstanceId.value?.uuid;
if (!selected) {
curNetworkInfo.value = null;
return;
}
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;
}
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 = {
instance_id: selectedInstanceId.value.uuid,
instance_id: selected,
running: network_info?.running ?? false,
error_msg: network_info?.error_msg ?? '',
detail: network_info,
@@ -492,7 +510,7 @@ onUnmounted(() => {
<div class="flex items-center min-w-0">
<div class="mr-4 min-w-0 flex-1">
<span class="truncate block">{{ t('network_name') }}: {{
slotProps.option.meta.network_name }}</span>
slotProps.option.meta?.network_name ?? slotProps.option.uuid }}</span>
</div>
<Tag class="my-auto leading-3 shrink-0"
: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>
</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">
</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">
<Button @click="stopNetwork" :disabled="!currentNetworkControl.deletable.value"
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { useTimeAgo } from '@vueuse/core'
import { IPv4 } from 'ip-num/IPNumber'
import { NetworkInstance, type TunnelInfo, type NodeInfo, type PeerRoutePair } from '../types/network'
import { useI18n } from 'vue-i18n';
import { computed, onMounted, onUnmounted, ref } from 'vue';
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 NetworkChart from './NetworkChart.vue';
@@ -39,8 +39,8 @@ function routeCost(info: any) {
return '?'
}
function resolveObjPath(path: string, obj = globalThis, separator = '.') {
const properties = Array.isArray(path) ? path : path.split(separator)
function resolveObjPath(path: string, obj: any = globalThis, separator = '.') {
const properties = path.split(separator)
return properties.reduce((prev, curr) => prev?.[curr], obj)
}
@@ -48,10 +48,17 @@ function statsCommon(info: any, field: string): number | undefined {
if (!info.peer)
return undefined
const conns = info.peer.conns
return conns.reduce((acc: number, conn: any) => {
return acc + resolveObjPath(field, conn)
}, 0)
let sum = 0
let hasValue = false
for (const conn of peerConns(info)) {
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) {
@@ -74,14 +81,6 @@ function humanFileSize(bytes: number, si = false, dp = 1) {
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) {
const tx = statsCommon(info, 'stats.tx_bytes')
return tx ? humanFileSize(tx) : ''
@@ -92,11 +91,6 @@ function rxBytes(info: PeerRoutePair) {
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) {
return info.route.version === '' ? 'unknown' : info.route.version
}
@@ -105,7 +99,7 @@ function ipFormat(info: PeerRoutePair) {
const ip = info.route.ipv4_addr
if (typeof ip === 'string')
return ip
return ip ? `${IPv4.fromNumber(ip.address.addr)}/${ip.network_length}` : ''
return ip ? ipv4InetToString(ip) : ''
}
function oneTunnelProto(tunnel?: TunnelInfo): string {
@@ -131,7 +125,7 @@ function oneTunnelProto(tunnel?: TunnelInfo): string {
}
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(() => {
@@ -206,7 +200,7 @@ const myNodeInfoChips = computed(() => {
// local 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({
label: `Local IPv4 ${idx}: ${ipv4ToString(ip)}`,
icon: '',
@@ -215,7 +209,7 @@ const myNodeInfoChips = computed(() => {
// local 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({
label: `Local IPv6 ${idx}: ${ipv6ToString(ip)}`,
icon: '',
@@ -226,7 +220,7 @@ const myNodeInfoChips = computed(() => {
const public_ip = my_node_info.ips?.public_ipv4
if (public_ip) {
chips.push({
label: `Public IP: ${IPv4.fromNumber(public_ip.addr)}`,
label: `Public IP: ${ipv4ToString(public_ip)}`,
icon: '',
} as Chip)
}
@@ -241,7 +235,7 @@ const myNodeInfoChips = computed(() => {
// listeners:
const listeners = my_node_info.listeners
for (const [idx, listener] of listeners?.entries()) {
for (const [idx, listener] of listeners?.entries() ?? []) {
chips.push({
label: `Listener ${idx}: ${listener.url}`,
icon: '',
@@ -288,6 +282,14 @@ function natType(info: PeerRoutePair): string {
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(() => {
if (!peerRouteInfos.value)
return 0
@@ -342,7 +344,7 @@ function showEventLogs() {
if (!detail)
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'
dialogVisible.value = true
}
@@ -434,16 +436,16 @@ function showEventLogs() {
<Column :field="ipFormat" :header="t('virtual_ipv4')" />
<Column :header="t('hostname')">
<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">
{{
slotProps.data.route.hostname }}
</div>
<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') }}
</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') }}
</Tag>
</div>
@@ -2,7 +2,7 @@
import { Button, Column, DataTable, Divider, InputText, Select, SelectButton, ToggleButton } from 'primevue'
import { ref, watch } from 'vue'
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'
const props = defineProps<{
@@ -13,7 +13,11 @@ const chain = defineModel<AclChain>({ required: true })
const { t } = useI18n()
watch(() => chain.value.rules, (newRules) => {
function rules() {
return ensureAclChain(chain.value).rules
}
watch(() => rules(), (newRules) => {
if (!newRules) return
const isSorted = newRules.every((rule, i) => i === 0 || (rule.priority || 0) <= (newRules[i - 1].priority || 0))
if (!isSorted) {
@@ -60,7 +64,7 @@ function addRule() {
editingRule.value = {
name: '',
description: '',
priority: chain.value.rules.length,
priority: rules().length,
enabled: true,
protocol: AclProtocol.Any,
ports: [],
@@ -79,28 +83,31 @@ function addRule() {
function editRule(index: number) {
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
}
function deleteRule(index: number) {
chain.value.rules.splice(index, 1)
rules().splice(index, 1)
}
function saveRule(rule: AclRule) {
const chainRules = rules()
ensureAclRuleLists(rule)
if (editingRuleIndex.value === -1) {
chain.value.rules.push(rule)
chainRules.push(rule)
} 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) {
chain.value.rules = event.value
chain.value.rules = event.value ?? []
const chainRules = rules()
// Update priorities based on new order (higher priority at top)
chain.value.rules.forEach((rule, index) => {
rule.priority = chain.value.rules.length - index - 1
chainRules.forEach((rule, index) => {
rule.priority = chainRules.length - index - 1
})
}
</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" />
</div>
<DataTable :value="chain.rules" @row-reorder="onRowReorder" responsiveLayout="scroll">
<DataTable :value="rules()" @row-reorder="onRowReorder" responsiveLayout="scroll">
<Column rowReorder headerStyle="width: 3rem" />
<Column field="enabled" :header="t('acl.rule.enabled')">
<template #body="{ data }">
@@ -1,8 +1,8 @@
<script setup lang="ts">
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 { GroupIdentity, GroupInfo } from '../../types/network';
import { GroupIdentity, GroupInfo, ensureGroupInfo } from '../../types/network';
const props = defineProps<{
groupNames?: string[]
@@ -18,6 +18,17 @@ const editingGroupIndex = ref(-1)
const showGroupDialog = ref(false)
const oldGroupName = ref('')
function groupInfo() {
return ensureGroupInfo(group.value)
}
const members = computed({
get: () => groupInfo().members,
set: value => {
groupInfo().members = value
},
})
function addGroup() {
editingGroupIndex.value = -1
editingGroup.value = {
@@ -30,13 +41,13 @@ function addGroup() {
function editGroup(index: number) {
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 || ''
showGroupDialog.value = true
}
function deleteGroup(index: number) {
group.value.declares.splice(index, 1)
groupInfo().declares.splice(index, 1)
}
function saveGroup() {
@@ -44,15 +55,15 @@ function saveGroup() {
const newName = editingGroup.value.group_name
if (editingGroupIndex.value === -1) {
group.value.declares.push(editingGroup.value)
groupInfo().declares.push(editingGroup.value)
} else {
if (oldGroupName.value && oldGroupName.value !== newName) {
// 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
emit('rename-group', { oldName: oldGroupName.value, newName })
}
group.value.declares[editingGroupIndex.value] = editingGroup.value
groupInfo().declares[editingGroupIndex.value] = editingGroup.value
}
showGroupDialog.value = false
}
@@ -70,7 +81,7 @@ function saveGroup() {
<Button icon="pi pi-plus" :label="t('web.common.add')" severity="success" @click="addGroup" />
</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_secret" :header="t('acl.group.secret')">
<template #body="{ data }">
@@ -90,7 +101,7 @@ function saveGroup() {
<div class="flex flex-col gap-2">
<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')" />
</div>
@@ -2,7 +2,7 @@
import { Button, Menu, Tab, TabList, TabPanel, TabPanels, Tabs } from 'primevue'
import { computed, ref } from 'vue'
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 AclGroupEditor from './AclGroupEditor.vue'
@@ -12,6 +12,7 @@ const { t } = useI18n()
const activeTab = ref(0)
const menu = ref()
const aclV1 = computed(() => ensureAclV1(acl.value))
const addMenuModel = ref([
{ label: () => t('acl.inbound'), command: () => addChain(AclChainType.Inbound) },
@@ -20,10 +21,6 @@ const addMenuModel = ref([
])
function addChain(type: AclChainType) {
if (!acl.value.acl_v1) {
acl.value.acl_v1 = { chains: [], group: { declares: [], members: [] } }
}
let defaultName = ''
switch (type) {
case AclChainType.Inbound: defaultName = 'Inbound'; break;
@@ -31,7 +28,7 @@ function addChain(type: AclChainType) {
case AclChainType.Forward: defaultName = 'Forward'; break;
}
acl.value.acl_v1.chains.push({
aclV1.value.chains.push({
name: defaultName,
chain_type: type,
description: '',
@@ -40,21 +37,20 @@ function addChain(type: AclChainType) {
default_action: AclAction.Allow
})
activeTab.value = acl.value.acl_v1.chains.length - 1
activeTab.value = aclV1.value.chains.length - 1
}
function removeChain(index: number) {
if (confirm(t('acl.delete_chain_confirm'))) {
acl.value.acl_v1?.chains.splice(index, 1)
if (activeTab.value >= (acl.value.acl_v1?.chains.length || 0)) {
activeTab.value = Math.max(0, (acl.value.acl_v1?.chains.length || 0))
aclV1.value.chains.splice(index, 1)
if (activeTab.value >= aclV1.value.chains.length) {
activeTab.value = Math.max(0, aclV1.value.chains.length)
}
}
}
function handleRenameGroup({ oldName, newName }: { oldName: string, newName: string }) {
if (!acl.value.acl_v1) return
acl.value.acl_v1.chains.forEach(chain => {
aclV1.value.chains.forEach(chain => {
chain.rules.forEach(rule => {
rule.source_groups = rule.source_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(() => {
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 chains = acl.value.acl_v1?.chains || []
const chains = aclV1.value.chains
const result: { type: string, label: string, index: number }[] = []
if (chains.length === 0) {
@@ -124,24 +120,13 @@ const tabs = computed(() => {
</div>
<!-- Rule Chains -->
<div v-if="tab.type === 'chain' && acl.acl_v1 && acl.acl_v1.chains[tab.index]" class="py-4">
<AclChainEditor v-model="acl.acl_v1.chains[tab.index]" :group-names="groupNames" />
<div v-if="tab.type === 'chain' && aclV1.chains[tab.index]" class="py-4">
<AclChainEditor v-model="aclV1.chains[tab.index]" :group-names="groupNames" />
</div>
<!-- Group Management -->
<div v-if="tab.type === 'groups'" class="py-4">
<template v-if="acl.acl_v1">
<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>
<AclGroupEditor v-model="aclV1.group" :group-names="groupNames" @rename-group="handleRenameGroup" />
</div>
</TabPanel>
</TabPanels>
@@ -1,8 +1,8 @@
<script setup lang="ts">
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 { AclAction, AclProtocol, AclRule } from '../../types/network';
import { AclAction, AclProtocol, AclRule, ensureAclRuleLists } from '../../types/network';
const props = defineProps<{
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
})
watch(() => rule.value, ensureAclRuleLists, { immediate: true })
function close() {
emit('update:visible', false)
}
@@ -341,6 +341,8 @@ web:
import_config: 导入配置
create_new: 创建新网络
network_status: 网络状态
loading_network_status: 正在加载网络状态
network_info_unavailable: 网络状态不可用
network_configuration: 网络配置
loading_network_configuration: 加载网络配置
no_network_selected: 未选择网络
@@ -341,6 +341,8 @@ web:
import_config: Import Config
create_new: Create New Network
network_status: Network Status
loading_network_status: Loading Network Status
network_info_unavailable: Network status is unavailable
network_configuration: Network Configuration
loading_network_configuration: Loading Network Configuration
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 { Ipv4Addr, Ipv4Inet, Ipv6Addr } from '../types/network'
export function ipv4ToString(ip: Ipv4Addr) {
return IPv4.fromNumber(ip.addr).toString()
export function ipv4ToString(ip: Ipv4Addr | null | undefined) {
if (!ip) {
return ''
}
return IPv4.fromNumber(ip.addr ?? 0).toString()
}
export function ipv4InetToString(ip: Ipv4Inet | undefined) {
if (ip?.address === 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(
(BigInt(ip.part1) << BigInt(96))
+ (BigInt(ip.part2) << BigInt(64))
+ (BigInt(ip.part3) << BigInt(32))
+ BigInt(ip.part4),
)
(BigInt(ip.part1 ?? 0) << BigInt(96))
+ (BigInt(ip.part2 ?? 0) << BigInt(64))
+ (BigInt(ip.part3 ?? 0) << BigInt(32))
+ BigInt(ip.part4 ?? 0),
).toString()
}
function toHexString(uint64: bigint, padding = 9): string {
@@ -43,14 +49,17 @@ function uint32ToUuid(part1: number, part2: number, part3: number, part4: number
}
export interface UUID {
part1: number;
part2: number;
part3: number;
part4: number;
part1?: number;
part2?: number;
part3?: number;
part4?: number;
}
export function UuidToStr(uuid: UUID): string {
return uint32ToUuid(uuid.part1, uuid.part2, uuid.part3, uuid.part4);
export function UuidToStr(uuid: UUID | null | undefined): string {
if (!uuid) {
return '';
}
return uint32ToUuid(uuid.part1 ?? 0, uuid.part2 ?? 0, uuid.part3 ?? 0, uuid.part4 ?? 0);
}
export interface Location {
@@ -71,11 +80,12 @@ export interface DeviceInfo {
}
export function buildDeviceInfo(device: any): DeviceInfo {
const runningInstances = device.info?.running_network_instances ?? [];
let dev_info: DeviceInfo = {
hostname: device.info?.hostname,
public_ip: device.client_url,
running_network_instances: device.info?.running_network_instances.map((instance: any) => UuidToStr(instance)),
running_network_count: device.info?.running_network_instances.length,
running_network_instances: runningInstances.map((instance: any) => UuidToStr(instance)),
running_network_count: runningInstances.length,
report_time: device.info?.report_time,
easytier_version: device.info?.easytier_version,
machine_id: UuidToStr(device.info?.machine_id),
+155 -20
View File
@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from 'uuid'
import {
NetworkConfig as NetworkConfigPb,
NetworkingMethod,
type NetworkPeerConfig,
type NetworkConfig as ProtoNetworkConfig,
type PortForwardConfig,
} from '../generated/proto/api_manage'
@@ -16,11 +17,16 @@ import {
type GroupInfo,
type Rule as AclRule,
} 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'
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<
ProtoNetworkConfig,
@@ -32,14 +38,39 @@ export type NetworkConfig = Omit<
networking_method: NetworkingMethod | string
}
export type NormalizedAclV1 = AclV1 & {
group: GroupInfo
}
const UINT64_MAX = (1n << 64n) - 1n
interface NetworkingConfigFields {
peer_urls: string[]
peers?: NetworkPeerConfig[]
public_server_url?: 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 {
return {
...NetworkConfigPb.create(),
@@ -110,15 +141,7 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
enable_magic_dns: false,
enable_private_mode: false,
port_forwards: [],
acl: {
acl_v1: {
group: {
declares: [],
members: [],
},
chains: [],
},
},
acl: emptyAcl(),
}
}
@@ -126,6 +149,88 @@ function cleanPeerUrls(urls: string[] | undefined): string[] {
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 {
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)
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 networkingMethod = config.networking_method ?? NetworkingMethod.Manual
switch (networkingMethod) {
case NetworkingMethod.PublicServer:
config.peer_urls = publicServerUrl ? [publicServerUrl] : []
config.peer_urls = publicServerUrl
? [publicServerUrl]
: (options.fillPeerUrlsFromPeers ? existingPeers.map((peer) => peer.uri) : [])
break
case NetworkingMethod.Manual:
break
@@ -174,6 +288,7 @@ function applyNetworkingMethod(config: NetworkingConfigFields): void {
config.networking_method = NetworkingMethod.Manual
config.public_server_url = ''
config.peers = peersFromUrls(config.peer_urls, existingPeers)
}
export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
@@ -181,11 +296,19 @@ export function normalizeNetworkConfig(config: NetworkConfig): NetworkConfig {
ignoreUnknownFields: true,
}) as unknown as NetworkConfig
applyNetworkingMethod(normalized)
applyNetworkingMethod(normalized, { fillPeerUrlsFromPeers: true })
normalized.mtu = normalizeNumberForInput(normalized.mtu)
normalized.instance_recv_bps_limit = normalizeUint64ForInput(
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
}
@@ -198,6 +321,9 @@ export function toBackendNetworkConfig(config: NetworkConfig): NetworkConfig {
applyNetworkingMethod(backend)
backend.mtu = normalizeNumberForInput(config.mtu) ?? undefined
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, {
useProtoFieldName: true,
@@ -286,6 +412,7 @@ export interface Route {
proxy_cidrs: string[]
hostname: string
stun_info?: StunInfo
feature_flag?: PeerFeatureFlag
inst_id: string
version: string
}
@@ -293,6 +420,7 @@ export interface Route {
export interface PeerInfo {
peer_id: number
conns: PeerConnInfo[]
default_conn_id?: CommonUuid
}
export interface PeerConnInfo {
@@ -303,7 +431,7 @@ export interface PeerConnInfo {
features: string[]
tunnel?: TunnelInfo
stats?: PeerConnStats
loss_rate: number
loss_rate?: number | string
}
export interface PeerRoutePair {
@@ -322,11 +450,18 @@ export interface TunnelInfo {
}
export interface PeerConnStats {
rx_bytes: number
tx_bytes: number
rx_packets: number
tx_packets: number
latency_us: number
rx_bytes: number | string
tx_bytes: number | string
rx_packets: number | string
tx_packets: number | string
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%')
})
})