mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-08-07 21:19:51 +00:00
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:
@@ -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) {
|
||||||
|
|||||||
@@ -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 ''
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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%')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user