Compare commits

..

No commits in common. "7dcc0ae70e1221d8e890a3721e0293fb7a07902e" and "780bb6dcdbee8f0205fd4439d09bcddcdc822951" have entirely different histories.

59 changed files with 2833 additions and 2297 deletions

766
Cargo.lock generated

File diff suppressed because it is too large Load Diff

2
easytier-gui/.npmrc Normal file
View File

@ -0,0 +1,2 @@
shamefully-hoist=true
strict-peer-dependencies=false

View File

@ -13,32 +13,34 @@
"lint:fix": "eslint . --ignore-pattern src-tauri --fix"
},
"dependencies": {
"@primevue/themes": "^4.2.1",
"@tauri-apps/plugin-autostart": "2.0.0",
"@tauri-apps/plugin-clipboard-manager": "2.0.0",
"@tauri-apps/plugin-os": "2.0.0",
"@tauri-apps/plugin-process": "2.0.0",
"@tauri-apps/plugin-shell": "2.0.1",
"@vueuse/core": "^11.2.0",
"@primevue/themes": "^4.1.0",
"@tauri-apps/plugin-autostart": "2.0.0-rc.1",
"@tauri-apps/plugin-clipboard-manager": "2.0.0-rc.1",
"@tauri-apps/plugin-os": "2.0.0-rc.1",
"@tauri-apps/plugin-process": "2.0.0-rc.1",
"@tauri-apps/plugin-shell": "2.0.0-rc.1",
"@vueuse/core": "^11.1.0",
"aura": "link:@primevue\\themes\\aura",
"easytier-frontend-lib": "workspace:*",
"ip-num": "1.5.1",
"pinia": "^2.2.4",
"primevue": "^4.2.1",
"tauri-plugin-vpnservice-api": "workspace:*",
"vue": "^3.5.12",
"primeflex": "^3.3.1",
"primeicons": "^7.0.0",
"primevue": "^4.1.0",
"tauri-plugin-vpnservice-api": "link:..\\tauri-plugin-vpnservice",
"vue": "=3.4.38",
"vue-i18n": "^10.0.4",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@antfu/eslint-config": "^3.7.3",
"@intlify/unplugin-vue-i18n": "^5.2.0",
"@primevue/auto-import-resolver": "^4.1.0",
"@tauri-apps/api": "2.1.0",
"@tauri-apps/cli": "2.1.0",
"@tauri-apps/api": "2.0.0-rc.0",
"@tauri-apps/cli": "2.0.0-rc.3",
"@types/node": "^22.7.4",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-vue": "^5.1.4",
"@vue-macros/volar": "0.30.5",
"@vue-macros/volar": "0.30.3",
"autoprefixer": "^10.4.20",
"eslint": "^9.12.0",
"eslint-plugin-format": "^0.1.2",
@ -48,7 +50,7 @@
"typescript": "^5.6.2",
"unplugin-auto-import": "^0.18.3",
"unplugin-vue-components": "^0.27.4",
"unplugin-vue-macros": "^2.13.3",
"unplugin-vue-macros": "^2.12.3",
"unplugin-vue-markdown": "^0.26.2",
"unplugin-vue-router": "^0.10.8",
"uuid": "^10.0.0",
@ -56,6 +58,6 @@
"vite-plugin-vue-devtools": "^7.4.6",
"vite-plugin-vue-layouts": "^0.11.0",
"vue-i18n": "^10.0.0",
"vue-tsc": "^2.1.10"
"vue-tsc": "^2.1.6"
}
}

View File

@ -15,11 +15,10 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2.0.0-rc", features = [] }
[dependencies]
tauri = { version = "2.1", features = [
tauri = { version = "2.0.0-rc", features = [
"tray-icon",
"image-png",
"image-ico",
"devtools",
] }
serde = { version = "1", features = ["derive"] }
@ -38,13 +37,13 @@ gethostname = "0.5"
dunce = "1.0.4"
tauri-plugin-shell = "2.0"
tauri-plugin-process = "2.0"
tauri-plugin-clipboard-manager = "2.0"
tauri-plugin-positioner = { version = "2.0", features = ["tray-icon"] }
tauri-plugin-shell = "2.0.0-rc"
tauri-plugin-process = "2.0.0-rc"
tauri-plugin-clipboard-manager = "2.0.0-rc"
tauri-plugin-positioner = { version = "2.0.0-rc", features = ["tray-icon"] }
tauri-plugin-vpnservice = { path = "../../tauri-plugin-vpnservice" }
tauri-plugin-os = "2.0"
tauri-plugin-autostart = "2.0"
tauri-plugin-os = "2.0.0-rc"
tauri-plugin-autostart = "2.0.0-rc"
[features]

View File

@ -3,12 +3,17 @@
use std::collections::BTreeMap;
use anyhow::Context;
use dashmap::DashMap;
use easytier::{
common::config::{ConfigLoader, FileLoggerConfig, TomlConfigLoader},
common::config::{
ConfigLoader, FileLoggerConfig, Flags, NetworkIdentity, PeerConfig, TomlConfigLoader,
VpnPortalConfig,
},
launcher::{NetworkConfig, NetworkInstance, NetworkInstanceRunningInfo},
utils::{self, NewFilterSender},
};
use serde::{Deserialize, Serialize};
use tauri::Manager as _;

View File

@ -154,6 +154,8 @@ declare module 'vue' {
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly num2ipv4: UnwrapRef<typeof import('./composables/utils')['num2ipv4']>
readonly num2ipv6: UnwrapRef<typeof import('./composables/utils')['num2ipv6']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>

View File

@ -0,0 +1,296 @@
<script setup lang="ts">
import InputGroup from 'primevue/inputgroup'
import InputGroupAddon from 'primevue/inputgroupaddon'
import { getOsHostname } from '~/composables/network'
import { NetworkingMethod } from '~/types/network'
const props = defineProps<{
configInvalid?: boolean
instanceId?: string
}>()
defineEmits(['runNetwork'])
const { t } = useI18n()
const networking_methods = ref([
{ value: NetworkingMethod.PublicServer, label: () => t('public_server') },
{ value: NetworkingMethod.Manual, label: () => t('manual') },
{ value: NetworkingMethod.Standalone, label: () => t('standalone') },
])
const networkStore = useNetworkStore()
const curNetwork = computed(() => {
if (props.instanceId) {
// console.log('instanceId', props.instanceId)
const c = networkStore.networkList.find(n => n.instance_id === props.instanceId)
if (c !== undefined)
return c
}
return networkStore.curNetwork
})
const protos: { [proto: string]: number } = { tcp: 11010, udp: 11010, wg: 11011, ws: 11011, wss: 11012 }
function searchUrlSuggestions(e: { query: string }): string[] {
const query = e.query
const ret = []
// if query match "^\w+:.*", then no proto prefix
if (query.match(/^\w+:.*/)) {
// if query is a valid url, then add to suggestions
try {
// eslint-disable-next-line no-new
new URL(query)
ret.push(query)
}
catch { }
}
else {
for (const proto in protos) {
let item = `${proto}://${query}`
// if query match ":\d+$", then no port suffix
if (!query.match(/:\d+$/)) {
item += `:${protos[proto]}`
}
ret.push(item)
}
}
return ret
}
const publicServerSuggestions = ref([''])
function searchPresetPublicServers(e: { query: string }) {
const presetPublicServers = [
'tcp://public.easytier.top:11010',
]
const query = e.query
// if query is sub string of presetPublicServers, add to suggestions
let ret = presetPublicServers.filter(item => item.includes(query))
// add additional suggestions
if (query.length > 0) {
ret = ret.concat(searchUrlSuggestions(e))
}
publicServerSuggestions.value = ret
}
const peerSuggestions = ref([''])
function searchPeerSuggestions(e: { query: string }) {
peerSuggestions.value = searchUrlSuggestions(e)
}
const inetSuggestions = ref([''])
function searchInetSuggestions(e: { query: string }) {
if (e.query.search('/') >= 0) {
inetSuggestions.value = [e.query]
} else {
const ret = []
for (let i = 0; i < 32; i++) {
ret.push(`${e.query}/${i}`)
}
inetSuggestions.value = ret
}
}
const listenerSuggestions = ref([''])
function searchListenerSuggestiong(e: { query: string }) {
const ret = []
for (const proto in protos) {
let item = `${proto}://0.0.0.0:`
// if query is a number, use it as port
if (e.query.match(/^\d+$/)) {
item += e.query
}
else {
item += protos[proto]
}
if (item.includes(e.query)) {
ret.push(item)
}
}
if (ret.length === 0) {
ret.push(e.query)
}
listenerSuggestions.value = ret
}
function validateHostname() {
if (curNetwork.value.hostname) {
// eslint no-useless-escape
let name = curNetwork.value.hostname!.replaceAll(/[^\u4E00-\u9FA5a-z0-9\-]*/gi, '')
if (name.length > 32)
name = name.substring(0, 32)
if (curNetwork.value.hostname !== name)
curNetwork.value.hostname = name
}
}
const osHostname = ref<string>('')
onMounted(async () => {
osHostname.value = await getOsHostname()
})
</script>
<template>
<div class="flex flex-column h-full">
<div class="flex flex-column">
<div class="w-10/12 self-center ">
<Panel :header="t('basic_settings')">
<div class="flex flex-column gap-y-2">
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-column gap-2 basis-5/12 grow">
<div class="flex align-items-center" for="virtual_ip">
<label class="mr-2"> {{ t('virtual_ipv4') }} </label>
<Checkbox v-model="curNetwork.dhcp" input-id="virtual_ip_auto" :binary="true" />
<label for="virtual_ip_auto" class="ml-2">
{{ t('virtual_ipv4_dhcp') }}
</label>
</div>
<InputGroup>
<InputText id="virtual_ip" v-model="curNetwork.virtual_ipv4" :disabled="curNetwork.dhcp"
aria-describedby="virtual_ipv4-help" />
<InputGroupAddon>
<span>/</span>
</InputGroupAddon>
<InputNumber v-model="curNetwork.network_length" :disabled="curNetwork.dhcp"
inputId="horizontal-buttons" showButtons :step="1" mode="decimal" :min="1" :max="32" fluid
class="max-w-20" />
</InputGroup>
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-column gap-2 basis-5/12 grow">
<label for="network_name">{{ t('network_name') }}</label>
<InputText id="network_name" v-model="curNetwork.network_name" aria-describedby="network_name-help" />
</div>
<div class="flex flex-column gap-2 basis-5/12 grow">
<label for="network_secret">{{ t('network_secret') }}</label>
<InputText id="network_secret" v-model="curNetwork.network_secret"
aria-describedby="network_secret-help" />
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-column gap-2 basis-5/12 grow">
<label for="nm">{{ t('networking_method') }}</label>
<SelectButton v-model="curNetwork.networking_method" :options="networking_methods"
:option-label="(v: any) => v.label()" option-value="value" />
<div class="items-center flex flex-row p-fluid gap-x-1">
<AutoComplete v-if="curNetwork.networking_method === NetworkingMethod.Manual" id="chips"
v-model="curNetwork.peer_urls" :placeholder="t('chips_placeholder', ['tcp://8.8.8.8:11010'])"
class="grow" multiple fluid :suggestions="peerSuggestions" @complete="searchPeerSuggestions" />
<AutoComplete v-if="curNetwork.networking_method === NetworkingMethod.PublicServer"
v-model="curNetwork.public_server_url" :suggestions="publicServerSuggestions"
:virtual-scroller-options="{ itemSize: 38 }" class="grow" dropdown :complete-on-focus="true"
@complete="searchPresetPublicServers" />
</div>
</div>
</div>
</div>
</Panel>
<Divider />
<Panel :header="t('advanced_settings')" toggleable collapsed>
<div class="flex flex-column gap-y-2">
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-column gap-2 basis-5/12 grow">
<div class="flex align-items-center">
<Checkbox v-model="curNetwork.latency_first" input-id="use_latency_first" :binary="true" />
<label for="use_latency_first" class="ml-2"> {{ t('use_latency_first') }} </label>
</div>
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-column gap-2 basis-5/12 grow">
<label for="hostname">{{ t('hostname') }}</label>
<InputText id="hostname" v-model="curNetwork.hostname" aria-describedby="hostname-help" :format="true"
:placeholder="t('hostname_placeholder', [osHostname])" @blur="validateHostname" />
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap w-full">
<div class="flex flex-column gap-2 grow p-fluid">
<label for="username">{{ t('proxy_cidrs') }}</label>
<AutoComplete id="subnet-proxy" v-model="curNetwork.proxy_cidrs"
:placeholder="t('chips_placeholder', ['10.0.0.0/24'])" class="w-full" multiple fluid
:suggestions="inetSuggestions" @complete="searchInetSuggestions" />
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap ">
<div class="flex flex-column gap-2 grow">
<label for="username">VPN Portal</label>
<ToggleButton v-model="curNetwork.enable_vpn_portal" on-icon="pi pi-check" off-icon="pi pi-times"
:on-label="t('off_text')" :off-label="t('on_text')" class="w-48" />
<div v-if="curNetwork.enable_vpn_portal" class="items-center flex flex-row gap-x-4">
<div class="min-w-64">
<InputGroup>
<InputText v-model="curNetwork.vpn_portal_client_network_addr"
:placeholder="t('vpn_portal_client_network')" />
<InputGroupAddon>
<span>/{{ curNetwork.vpn_portal_client_network_len }}</span>
</InputGroupAddon>
</InputGroup>
<InputNumber v-model="curNetwork.vpn_portal_listen_port" :allow-empty="false" :format="false"
:min="0" :max="65535" class="w-8" fluid />
</div>
</div>
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-column gap-2 grow p-fluid">
<label for="listener_urls">{{ t('listener_urls') }}</label>
<AutoComplete id="listener_urls" v-model="curNetwork.listener_urls" :suggestions="listenerSuggestions"
class="w-full" dropdown :complete-on-focus="true"
:placeholder="t('chips_placeholder', ['tcp://1.1.1.1:11010'])" multiple
@complete="searchListenerSuggestiong" />
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-column gap-2 basis-5/12 grow">
<label for="rpc_port">{{ t('rpc_port') }}</label>
<InputNumber id="rpc_port" v-model="curNetwork.rpc_port" aria-describedby="rpc_port-help"
:format="false" :min="0" :max="65535" />
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-column gap-2 basis-5/12 grow">
<label for="dev_name">{{ t('dev_name') }}</label>
<InputText id="dev_name" v-model="curNetwork.dev_name" aria-describedby="dev_name-help" :format="true"
:placeholder="t('dev_name_placeholder')" />
</div>
</div>
</div>
</Panel>
<div class="flex pt-4 justify-content-center">
<Button :label="t('run_network')" icon="pi pi-arrow-right" icon-pos="right" :disabled="configInvalid"
@click="$emit('runNetwork', curNetwork)" />
</div>
</div>
</div>
</div>
</template>

View File

@ -1,8 +1,5 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { EventType } from '../types/network'
import { computed } from 'vue';
import { Fieldset } from 'primevue';
import { EventType } from '~/types/network'
const props = defineProps<{
event: {

View File

@ -0,0 +1,459 @@
<script setup lang="ts">
import { useTimeAgo } from '@vueuse/core'
import { IPv4, IPv6 } from 'ip-num/IPNumber'
import type { NodeInfo, PeerRoutePair } from '~/types/network'
const props = defineProps<{
instanceId?: string
}>()
const { t } = useI18n()
const networkStore = useNetworkStore()
const curNetwork = computed(() => {
if (props.instanceId) {
// console.log('instanceId', props.instanceId)
const c = networkStore.networkList.find(n => n.instance_id === props.instanceId)
if (c !== undefined)
return c
}
return networkStore.curNetwork
})
const curNetworkInst = computed(() => {
return networkStore.networkInstances.find(n => n.instance_id === curNetwork.value.instance_id)
})
const peerRouteInfos = computed(() => {
if (curNetworkInst.value) {
const my_node_info = curNetworkInst.value.detail?.my_node_info
return [{
route: {
ipv4_addr: my_node_info?.virtual_ipv4,
hostname: my_node_info?.hostname,
version: my_node_info?.version,
},
}, ...(curNetworkInst.value.detail?.peer_route_pairs || [])]
}
return []
})
function routeCost(info: any) {
if (info.route) {
const cost = info.route.cost
return cost ? cost === 1 ? 'p2p' : `relay(${cost})` : t('status.local')
}
return '?'
}
function resolveObjPath(path: string, obj = globalThis, separator = '.') {
const properties = Array.isArray(path) ? path : path.split(separator)
return properties.reduce((prev, curr) => prev?.[curr], obj)
}
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)
}
function humanFileSize(bytes: number, si = false, dp = 1) {
const thresh = si ? 1000 : 1024
if (Math.abs(bytes) < thresh)
return `${bytes} B`
const units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
let u = -1
const r = 10 ** dp
do {
bytes /= thresh
++u
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 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) : ''
}
function rxBytes(info: PeerRoutePair) {
const rx = statsCommon(info, 'stats.rx_bytes')
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
}
function ipFormat(info: PeerRoutePair) {
const ip = info.route.ipv4_addr
if (typeof ip === 'string')
return ip
return ip ? `${num2ipv4(ip.address)}/${ip.network_length}` : ''
}
const myNodeInfo = computed(() => {
if (!curNetworkInst.value)
return {} as NodeInfo
return curNetworkInst.value.detail?.my_node_info
})
interface Chip {
label: string
icon: string
}
const myNodeInfoChips = computed(() => {
if (!curNetworkInst.value)
return []
const chips: Array<Chip> = []
const my_node_info = curNetworkInst.value.detail?.my_node_info
if (!my_node_info)
return chips
// TUN Device Name
const dev_name = curNetworkInst.value.detail?.dev_name
if (dev_name) {
chips.push({
label: `TUN Device Name: ${dev_name}`,
icon: '',
} as Chip)
}
// virtual ipv4
chips.push({
label: `Virtual IPv4: ${my_node_info.virtual_ipv4}`,
icon: '',
} as Chip)
// local ipv4s
const local_ipv4s = my_node_info.ips?.interface_ipv4s
for (const [idx, ip] of local_ipv4s?.entries()) {
chips.push({
label: `Local IPv4 ${idx}: ${num2ipv4(ip)}`,
icon: '',
} as Chip)
}
// local ipv6s
const local_ipv6s = my_node_info.ips?.interface_ipv6s
for (const [idx, ip] of local_ipv6s?.entries()) {
chips.push({
label: `Local IPv6 ${idx}: ${num2ipv6(ip)}`,
icon: '',
} as Chip)
}
// public ip
const public_ip = my_node_info.ips?.public_ipv4
if (public_ip) {
chips.push({
label: `Public IP: ${IPv4.fromNumber(public_ip.addr)}`,
icon: '',
} as Chip)
}
const public_ipv6 = my_node_info.ips?.public_ipv6
if (public_ipv6) {
chips.push({
label: `Public IPv6: ${IPv6.fromBigInt((BigInt(public_ipv6.part1) << BigInt(96))
+ (BigInt(public_ipv6.part2) << BigInt(64))
+ (BigInt(public_ipv6.part3) << BigInt(32))
+ BigInt(public_ipv6.part4),
)}`,
icon: '',
} as Chip)
}
// listeners:
const listeners = my_node_info.listeners
for (const [idx, listener] of listeners?.entries()) {
chips.push({
label: `Listener ${idx}: ${listener}`,
icon: '',
} as Chip)
}
// udp nat type
enum NatType {
// has NAT; but own a single public IP, port is not changed
Unknown = 0,
OpenInternet = 1,
NoPAT = 2,
FullCone = 3,
Restricted = 4,
PortRestricted = 5,
Symmetric = 6,
SymUdpFirewall = 7,
SymmetricEasyInc = 8,
SymmetricEasyDec = 9,
};
const udpNatType: NatType = my_node_info.stun_info?.udp_nat_type
if (udpNatType !== undefined) {
const udpNatTypeStrMap = {
[NatType.Unknown]: 'Unknown',
[NatType.OpenInternet]: 'Open Internet',
[NatType.NoPAT]: 'No PAT',
[NatType.FullCone]: 'Full Cone',
[NatType.Restricted]: 'Restricted',
[NatType.PortRestricted]: 'Port Restricted',
[NatType.Symmetric]: 'Symmetric',
[NatType.SymUdpFirewall]: 'Symmetric UDP Firewall',
[NatType.SymmetricEasyInc]: 'Symmetric Easy Inc',
[NatType.SymmetricEasyDec]: 'Symmetric Easy Dec',
}
chips.push({
label: `UDP NAT Type: ${udpNatTypeStrMap[udpNatType]}`,
icon: '',
} as Chip)
}
return chips
})
function globalSumCommon(field: string) {
let sum = 0
if (!peerRouteInfos.value)
return sum
for (const info of peerRouteInfos.value) {
const tx = statsCommon(info, field)
if (tx)
sum += tx
}
return sum
}
function txGlobalSum() {
return globalSumCommon('stats.tx_bytes')
}
function rxGlobalSum() {
return globalSumCommon('stats.rx_bytes')
}
const peerCount = computed(() => {
if (!peerRouteInfos.value)
return 0
return peerRouteInfos.value.length
})
// calculate tx/rx rate every 2 seconds
let rateIntervalId = 0
const rateInterval = 2000
let prevTxSum = 0
let prevRxSum = 0
const txRate = ref('0')
const rxRate = ref('0')
onMounted(() => {
rateIntervalId = window.setInterval(() => {
const curTxSum = txGlobalSum()
txRate.value = humanFileSize((curTxSum - prevTxSum) / (rateInterval / 1000))
prevTxSum = curTxSum
const curRxSum = rxGlobalSum()
rxRate.value = humanFileSize((curRxSum - prevRxSum) / (rateInterval / 1000))
prevRxSum = curRxSum
}, rateInterval)
})
onUnmounted(() => {
clearInterval(rateIntervalId)
})
const dialogVisible = ref(false)
const dialogContent = ref<any>('')
const dialogHeader = ref('event_log')
function showVpnPortalConfig() {
const my_node_info = myNodeInfo.value
if (!my_node_info)
return
const url = 'https://www.wireguardconfig.com/qrcode'
dialogContent.value = `${my_node_info.vpn_portal_cfg}\n\n # can generate QR code: ${url}`
dialogHeader.value = 'vpn_portal_config'
dialogVisible.value = true
}
function showEventLogs() {
const detail = curNetworkInst.value?.detail
if (!detail)
return
dialogContent.value = detail.events
dialogHeader.value = 'event_log'
dialogVisible.value = true
}
</script>
<template>
<div>
<Dialog v-model:visible="dialogVisible" modal :header="t(dialogHeader)" class="w-2/3 h-auto">
<ScrollPanel v-if="dialogHeader === 'vpn_portal_config'">
<pre>{{ dialogContent }}</pre>
</ScrollPanel>
<Timeline v-else :value="dialogContent">
<template #opposite="slotProps">
<small class="text-surface-500 dark:text-surface-400">{{ useTimeAgo(Date.parse(slotProps.item[0])) }}</small>
</template>
<template #content="slotProps">
<HumanEvent :event="slotProps.item[1]" />
</template>
</Timeline>
</Dialog>
<Card v-if="curNetworkInst?.error_msg">
<template #title>
Run Network Error
</template>
<template #content>
<div class="flex flex-column gap-y-5">
<div class="text-red-500">
{{ curNetworkInst.error_msg }}
</div>
</div>
</template>
</Card>
<template v-else>
<Card>
<template #title>
{{ t('my_node_info') }}
</template>
<template #content>
<div class="flex w-full flex-column gap-y-5">
<div class="m-0 flex flex-row justify-center gap-x-5">
<div
class="rounded-full w-32 h-32 flex flex-column align-items-center pt-4"
style="border: 1px solid green"
>
<div class="font-bold">
{{ t('peer_count') }}
</div>
<div class="text-5xl mt-1">
{{ peerCount }}
</div>
</div>
<div
class="rounded-full w-32 h-32 flex flex-column align-items-center pt-4"
style="border: 1px solid purple"
>
<div class="font-bold">
{{ t('upload') }}
</div>
<div class="text-xl mt-2">
{{ txRate }}/s
</div>
</div>
<div
class="rounded-full w-32 h-32 flex flex-column align-items-center pt-4"
style="border: 1px solid fuchsia"
>
<div class="font-bold">
{{ t('download') }}
</div>
<div class="text-xl mt-2">
{{ rxRate }}/s
</div>
</div>
</div>
<div class="flex flex-row align-items-center flex-wrap w-full max-h-40 overflow-scroll">
<Chip
v-for="(chip, i) in myNodeInfoChips" :key="i" :label="chip.label" :icon="chip.icon"
class="mr-2 mt-2 text-sm"
/>
</div>
<div v-if="myNodeInfo" class="m-0 flex flex-row justify-center gap-x-5 text-sm">
<Button severity="info" :label="t('show_vpn_portal_config')" @click="showVpnPortalConfig" />
<Button severity="info" :label="t('show_event_log')" @click="showEventLogs" />
</div>
</div>
</template>
</Card>
<Divider />
<Card>
<template #title>
{{ t('peer_info') }}
</template>
<template #content>
<DataTable :value="peerRouteInfos" column-resize-mode="fit" table-class="w-full">
<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"
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">
{{ t('status.server') }}
</Tag>
<Tag v-if="slotProps.data.route.no_relay_data" severity="warn" value="Warn">
{{ t('status.relay') }}
</Tag>
</div>
</template>
</Column>
<Column :field="routeCost" :header="t('route_cost')" />
<Column :field="latencyMs" :header="t('latency')" />
<Column :field="txBytes" :header="t('upload_bytes')" />
<Column :field="rxBytes" :header="t('download_bytes')" />
<Column :field="lossRate" :header="t('loss_rate')" />
<Column :header="t('status.version')">
<template #body="slotProps">
<span>{{ version(slotProps.data) }}</span>
</template>
</Column>
</DataTable>
</template>
</Card>
</template>
</div>
</template>
<style lang="postcss" scoped>
.p-timeline :deep(.p-timeline-event-opposite) {
@apply flex-none;
}
</style>

View File

@ -1,8 +1,6 @@
import { addPluginListener } from '@tauri-apps/api/core'
import { prepare_vpn, start_vpn, stop_vpn } from 'tauri-plugin-vpnservice-api'
import { NetworkTypes, Utils } from 'easytier-frontend-lib'
type Route = NetworkTypes.Route
import type { Route } from '~/types/network'
const networkStore = useNetworkStore()
@ -124,17 +122,12 @@ async function onNetworkInstanceChange() {
return
}
const virtual_ip = Utils.ipv4ToString(curNetworkInfo?.my_node_info?.virtual_ipv4.address)
const virtual_ip = curNetworkInfo?.node_info?.virtual_ipv4
if (!virtual_ip || !virtual_ip.length) {
await doStopVpn()
return
}
let network_length = curNetworkInfo?.my_node_info?.virtual_ipv4.network_length
if (!network_length) {
network_length = 24
}
const routes = getRoutesForVpn(curNetworkInfo?.routes)
const ipChanged = virtual_ip !== curVpnStatus.ipv4Addr

View File

@ -1,8 +1,6 @@
import { invoke } from '@tauri-apps/api/core'
import { NetworkTypes } from 'easytier-frontend-lib'
type NetworkConfig = NetworkTypes.NetworkConfig
type NetworkInstanceRunningInfo = NetworkTypes.NetworkInstanceRunningInfo
import type { NetworkConfig, NetworkInstanceRunningInfo } from '~/types/network'
export async function parseNetworkConfig(cfg: NetworkConfig) {
return invoke<string>('parse_network_config', { cfg })

View File

@ -0,0 +1,15 @@
import { IPv4, IPv6 } from 'ip-num/IPNumber'
import type { Ipv4Addr, Ipv6Addr } from '~/types/network'
export function num2ipv4(ip: Ipv4Addr) {
return IPv4.fromNumber(ip.addr)
}
export function num2ipv6(ip: Ipv6Addr) {
return IPv6.fromBigInt(
(BigInt(ip.part1) << BigInt(96))
+ (BigInt(ip.part2) << BigInt(64))
+ (BigInt(ip.part3) << BigInt(32))
+ BigInt(ip.part4),
)
}

View File

@ -5,11 +5,12 @@ import ToastService from 'primevue/toastservice'
import { createRouter, createWebHistory } from 'vue-router/auto'
import { routes } from 'vue-router/auto-routes'
import App from '~/App.vue'
import EasyTierFrontendLib, { I18nUtils } from 'easytier-frontend-lib'
import { i18n, loadLanguageAsync } from '~/modules/i18n'
import { getAutoLaunchStatusAsync, loadAutoLaunchStatusAsync } from './modules/auto_launch'
import '~/styles.css'
import 'easytier-frontend-lib/style.css'
import 'primeicons/primeicons.css'
import 'primeflex/primeflex.css'
if (import.meta.env.PROD) {
document.addEventListener('keydown', (event) => {
@ -28,7 +29,7 @@ if (import.meta.env.PROD) {
}
async function main() {
await I18nUtils.loadLanguageAsync(localStorage.getItem('lang') || 'en')
await loadLanguageAsync(localStorage.getItem('lang') || 'en')
await loadAutoLaunchStatusAsync(getAutoLaunchStatusAsync())
const app = createApp(App)
@ -40,18 +41,14 @@ async function main() {
app.use(router)
app.use(createPinia())
app.use(EasyTierFrontendLib)
// app.use(i18n, { useScope: 'global' })
app.use(i18n, { useScope: 'global' })
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
prefix: 'p',
darkModeSelector: 'system',
cssLayer: {
name: 'primevue',
order: 'tailwind-base, primevue, tailwind-utilities'
}
cssLayer: false,
},
},
})

View File

@ -0,0 +1,50 @@
import { createI18n } from 'vue-i18n'
import type { Locale } from 'vue-i18n'
// Import i18n resources
// https://vitejs.dev/guide/features.html#glob-import
export const i18n = createI18n({
legacy: false,
locale: '',
fallbackLocale: '',
messages: {},
})
const localesMap = Object.fromEntries(
Object.entries(import.meta.glob('../../locales/*.yml'))
.map(([path, loadLocale]) => [path.match(/([\w-]*)\.yml$/)?.[1], loadLocale]),
) as Record<Locale, () => Promise<{ default: Record<string, string> }>>
export const availableLocales = Object.keys(localesMap)
const loadedLanguages: string[] = []
function setI18nLanguage(lang: Locale) {
i18n.global.locale.value = lang as any
localStorage.setItem('lang', lang)
return lang
}
export async function loadLanguageAsync(lang: string): Promise<Locale> {
// If the same language
if (i18n.global.locale.value === lang)
return setI18nLanguage(lang)
// If the language was already loaded
if (loadedLanguages.includes(lang))
return setI18nLanguage(lang)
// If the language hasn't been loaded yet
let messages
try {
messages = await localesMap[lang]()
}
catch {
messages = await localesMap.en()
}
i18n.global.setLocaleMessage(lang, messages.default)
loadedLanguages.push(lang)
return setI18nLanguage(lang)
}

View File

@ -8,11 +8,14 @@ import { exit } from '@tauri-apps/plugin-process'
import { open } from '@tauri-apps/plugin-shell'
import TieredMenu from 'primevue/tieredmenu'
import { useToast } from 'primevue/usetoast'
import { NetworkTypes, Config, Status, Utils, I18nUtils } from 'easytier-frontend-lib'
import Config from '~/components/Config.vue'
import Status from '~/components/Status.vue'
import { isAutostart, setLoggingLevel } from '~/composables/network'
import { useTray } from '~/composables/tray'
import { getAutoLaunchStatusAsync as getAutoLaunchStatus, loadAutoLaunchStatusAsync } from '~/modules/auto_launch'
import { loadLanguageAsync } from '~/modules/i18n'
import { type NetworkConfig, NetworkingMethod } from '~/types/network'
const { t, locale } = useI18n()
const visible = ref(false)
@ -62,27 +65,6 @@ const toast = useToast()
const networkStore = useNetworkStore()
const curNetworkConfig = computed(() => {
if (networkStore.curNetworkId) {
// console.log('instanceId', props.instanceId)
const c = networkStore.networkList.find(n => n.instance_id === networkStore.curNetworkId)
if (c !== undefined)
return c
}
return networkStore.curNetwork
})
const curNetworkInst = computed<NetworkTypes.NetworkInstance | null>(() => {
let ret = networkStore.networkInstances.find(n => n.instance_id === curNetworkConfig.value.instance_id)
console.log('curNetworkInst', ret)
if (ret === undefined) {
return null;
} else {
return ret;
}
})
function addNewNetwork() {
networkStore.addNewNetwork()
networkStore.curNetwork = networkStore.lastNetwork
@ -100,7 +82,7 @@ networkStore.$subscribe(async () => {
}
})
async function runNetworkCb(cfg: NetworkTypes.NetworkConfig, cb: () => void) {
async function runNetworkCb(cfg: NetworkConfig, cb: () => void) {
if (type() === 'android') {
await prepareVpnService()
networkStore.clearNetworkInstances()
@ -124,7 +106,7 @@ async function runNetworkCb(cfg: NetworkTypes.NetworkConfig, cb: () => void) {
cb()
}
async function stopNetworkCb(cfg: NetworkTypes.NetworkConfig, cb: () => void) {
async function stopNetworkCb(cfg: NetworkConfig, cb: () => void) {
// console.log('stopNetworkCb', cfg, cb)
cb()
networkStore.removeNetworkInstance(cfg.instance_id)
@ -163,7 +145,7 @@ const setting_menu_items = ref([
label: () => t('exchange_language'),
icon: 'pi pi-language',
command: async () => {
await I18nUtils.loadLanguageAsync((locale.value === 'en' ? 'cn' : 'en'))
await loadLanguageAsync((locale.value === 'en' ? 'cn' : 'en'))
await setTrayMenu([
await MenuItemExit(t('tray.exit')),
await MenuItemShow(t('tray.show')),
@ -239,7 +221,7 @@ onBeforeMount(async () => {
getCurrentWindow().hide()
const autoStartIds = networkStore.autoStartInstIds
for (const id of autoStartIds) {
const cfg = networkStore.networkList.find((item: NetworkTypes.NetworkConfig) => item.instance_id === id)
const cfg = networkStore.networkList.find(item => item.instance_id === id)
if (cfg) {
networkStore.addNetworkInstance(cfg.instance_id)
await runNetworkInstance(cfg)
@ -263,7 +245,7 @@ function isRunning(id: string) {
</script>
<template>
<div id="root" class="flex flex-col">
<div id="root" class="flex flex-column">
<Dialog v-model:visible="visible" modal header="Config File" :style="{ width: '70%' }">
<Panel>
<ScrollPanel style="width: 100%; height: 300px">
@ -271,7 +253,7 @@ function isRunning(id: string) {
</ScrollPanel>
</Panel>
<Divider />
<div class="flex gap-2 justify-end">
<div class="flex gap-2 justify-content-end">
<Button type="button" :label="t('close')" @click="visible = false" />
</div>
</Dialog>
@ -283,55 +265,65 @@ function isRunning(id: string) {
<div>
<Toolbar>
<template #start>
<div class="flex items-center">
<div class="flex align-items-center">
<Button icon="pi pi-plus" severity="primary" :label="t('add_new_network')" @click="addNewNetwork" />
</div>
</template>
<template #center>
<div class="min-w-40">
<Select v-model="networkStore.curNetwork" :options="networkStore.networkList" :highlight-on-select="false"
:placeholder="t('select_network')" class="w-full">
<Dropdown
v-model="networkStore.curNetwork" :options="networkStore.networkList" :highlight-on-select="false"
:placeholder="t('select_network')" class="w-full"
>
<template #value="slotProps">
<div class="flex items-start content-center">
<div class="mr-4 flex-col">
<div class="mr-3 flex-column">
<span>{{ slotProps.value.network_name }}</span>
</div>
<Tag class="my-auto leading-3" :severity="isRunning(slotProps.value.instance_id) ? 'success' : 'info'"
:value="t(isRunning(slotProps.value.instance_id) ? 'network_running' : 'network_stopped')" />
<Tag
class="my-auto leading-3" :severity="isRunning(slotProps.value.instance_id) ? 'success' : 'info'"
:value="t(isRunning(slotProps.value.instance_id) ? 'network_running' : 'network_stopped')"
/>
</div>
</template>
<template #option="slotProps">
<div class="flex flex-col items-start content-center max-w-full">
<div class="flex">
<div class="mr-4">
<div class="mr-3">
{{ t('network_name') }}: {{ slotProps.option.network_name }}
</div>
<Tag class="my-auto leading-3"
<Tag
class="my-auto leading-3"
:severity="isRunning(slotProps.option.instance_id) ? 'success' : 'info'"
:value="t(isRunning(slotProps.option.instance_id) ? 'network_running' : 'network_stopped')" />
:value="t(isRunning(slotProps.option.instance_id) ? 'network_running' : 'network_stopped')"
/>
</div>
<div v-if="slotProps.option.networking_method !== NetworkTypes.NetworkingMethod.Standalone"
class="max-w-full overflow-hidden text-ellipsis">
{{ slotProps.option.networking_method === NetworkTypes.NetworkingMethod.Manual
<div
v-if="slotProps.option.networking_method !== NetworkingMethod.Standalone"
class="max-w-full overflow-hidden text-ellipsis"
>
{{ slotProps.option.networking_method === NetworkingMethod.Manual
? slotProps.option.peer_urls.join(', ')
: slotProps.option.public_server_url }}
</div>
<div
v-if="isRunning(slotProps.option.instance_id) && networkStore.instances[slotProps.option.instance_id].detail && (!!networkStore.instances[slotProps.option.instance_id].detail?.my_node_info.virtual_ipv4)">
{{
Utils.ipv4InetToString(networkStore.instances[slotProps.option.instance_id].detail?.my_node_info.virtual_ipv4)
}}
v-if="isRunning(slotProps.option.instance_id) && networkStore.instances[slotProps.option.instance_id].detail && (networkStore.instances[slotProps.option.instance_id].detail?.my_node_info.virtual_ipv4 !== '')"
>
{{ networkStore.instances[slotProps.option.instance_id].detail
? networkStore.instances[slotProps.option.instance_id].detail?.my_node_info.virtual_ipv4 : '' }}
</div>
</div>
</template>
</Select>
</Dropdown>
</div>
</template>
<template #end>
<Button icon="pi pi-cog" severity="secondary" aria-haspopup="true" :label="t('settings')"
aria-controls="overlay_setting_menu" @click="toggle_setting_menu" />
<Button
icon="pi pi-cog" severity="secondary" aria-haspopup="true" :label="t('settings')"
aria-controls="overlay_setting_menu" @click="toggle_setting_menu"
/>
<TieredMenu id="overlay_setting_menu" ref="setting_menu" :model="setting_menu_items" :popup="true" />
</template>
</Toolbar>
@ -349,16 +341,20 @@ function isRunning(id: string) {
</StepList>
<StepPanels value="1">
<StepPanel v-slot="{ activateCallback = (s: string) => { } } = {}" value="1">
<Config :instance-id="networkStore.curNetworkId" :config-invalid="messageBarSeverity !== Severity.None"
:cur-network="curNetworkConfig" @run-network="runNetworkCb($event, () => activateCallback('2'))" />
<Config
:instance-id="networkStore.curNetworkId" :config-invalid="messageBarSeverity !== Severity.None"
@run-network="runNetworkCb($event, () => activateCallback('2'))"
/>
</StepPanel>
<StepPanel v-slot="{ activateCallback = (s: string) => { } } = {}" value="2">
<div class="flex flex-col">
<Status :cur-network-inst="curNetworkInst" />
<div class="flex flex-column">
<Status :instance-id="networkStore.curNetworkId" />
</div>
<div class="flex pt-6 justify-center">
<Button :label="t('stop_network')" severity="danger" icon="pi pi-arrow-left"
@click="stopNetworkCb(networkStore.curNetwork, () => activateCallback('1'))" />
<div class="flex pt-4 justify-content-center">
<Button
:label="t('stop_network')" severity="danger" icon="pi pi-arrow-left"
@click="stopNetworkCb(networkStore.curNetwork, () => activateCallback('1'))"
/>
</div>
</StepPanel>
</StepPanels>

View File

@ -1,25 +1,26 @@
import { NetworkTypes } from 'easytier-frontend-lib'
import type { NetworkConfig, NetworkInstance, NetworkInstanceRunningInfo } from '~/types/network'
import { DEFAULT_NETWORK_CONFIG } from '~/types/network'
export const useNetworkStore = defineStore('networkStore', {
state: () => {
const networkList = [NetworkTypes.DEFAULT_NETWORK_CONFIG()]
const networkList = [DEFAULT_NETWORK_CONFIG()]
return {
// for initially empty lists
networkList: networkList as NetworkTypes.NetworkConfig[],
networkList: networkList as NetworkConfig[],
// for data that is not yet loaded
curNetwork: networkList[0],
// uuid -> instance
instances: {} as Record<string, NetworkTypes.NetworkInstance>,
instances: {} as Record<string, NetworkInstance>,
networkInfos: {} as Record<string, NetworkTypes.NetworkInstanceRunningInfo>,
networkInfos: {} as Record<string, NetworkInstanceRunningInfo>,
autoStartInstIds: [] as string[],
}
},
getters: {
lastNetwork(): NetworkTypes.NetworkConfig {
lastNetwork(): NetworkConfig {
return this.networkList[this.networkList.length - 1]
},
@ -27,7 +28,7 @@ export const useNetworkStore = defineStore('networkStore', {
return this.curNetwork.instance_id
},
networkInstances(): Array<NetworkTypes.NetworkInstance> {
networkInstances(): Array<NetworkInstance> {
return Object.values(this.instances)
},
@ -38,7 +39,7 @@ export const useNetworkStore = defineStore('networkStore', {
actions: {
addNewNetwork() {
this.networkList.push(NetworkTypes.DEFAULT_NETWORK_CONFIG())
this.networkList.push(DEFAULT_NETWORK_CONFIG())
},
delCurNetwork() {
@ -65,7 +66,7 @@ export const useNetworkStore = defineStore('networkStore', {
this.instances = {}
},
updateWithNetworkInfos(networkInfos: Record<string, NetworkTypes.NetworkInstanceRunningInfo>) {
updateWithNetworkInfos(networkInfos: Record<string, NetworkInstanceRunningInfo>) {
this.networkInfos = networkInfos
for (const [instanceId, info] of Object.entries(networkInfos)) {
if (this.instances[instanceId] === undefined)
@ -78,17 +79,17 @@ export const useNetworkStore = defineStore('networkStore', {
},
loadFromLocalStorage() {
let networkList: NetworkTypes.NetworkConfig[]
let networkList: NetworkConfig[]
// if localStorage default is [{}], instanceId will be undefined
networkList = JSON.parse(localStorage.getItem('networkList') || '[]')
networkList = networkList.map((cfg) => {
return { ...NetworkTypes.DEFAULT_NETWORK_CONFIG(), ...cfg } as NetworkTypes.NetworkConfig
return { ...DEFAULT_NETWORK_CONFIG(), ...cfg } as NetworkConfig
})
// prevent a empty list from localStorage, should not happen
if (networkList.length === 0)
networkList = [NetworkTypes.DEFAULT_NETWORK_CONFIG()]
networkList = [DEFAULT_NETWORK_CONFIG()]
this.networkList = networkList
this.curNetwork = this.networkList[0]

View File

@ -0,0 +1,213 @@
import { v4 as uuidv4 } from 'uuid'
export enum NetworkingMethod {
PublicServer = 'PublicServer',
Manual = 'Manual',
Standalone = 'Standalone',
}
export interface NetworkConfig {
instance_id: string
dhcp: boolean
virtual_ipv4: string
network_length: number
hostname?: string
network_name: string
network_secret: string
networking_method: NetworkingMethod
public_server_url: string
peer_urls: string[]
proxy_cidrs: string[]
enable_vpn_portal: boolean
vpn_portal_listen_port: number
vpn_portal_client_network_addr: string
vpn_portal_client_network_len: number
advanced_settings: boolean
listener_urls: string[]
rpc_port: number
latency_first: boolean
dev_name: string
}
export function DEFAULT_NETWORK_CONFIG(): NetworkConfig {
return {
instance_id: uuidv4(),
dhcp: true,
virtual_ipv4: '',
network_length: 24,
network_name: 'easytier',
network_secret: '',
networking_method: NetworkingMethod.PublicServer,
public_server_url: 'tcp://public.easytier.top:11010',
peer_urls: [],
proxy_cidrs: [],
enable_vpn_portal: false,
vpn_portal_listen_port: 22022,
vpn_portal_client_network_addr: '',
vpn_portal_client_network_len: 24,
advanced_settings: false,
listener_urls: [
'tcp://0.0.0.0:11010',
'udp://0.0.0.0:11010',
'wg://0.0.0.0:11011',
],
rpc_port: 0,
latency_first: true,
dev_name: '',
}
}
export interface NetworkInstance {
instance_id: string
running: boolean
error_msg: string
detail?: NetworkInstanceRunningInfo
}
export interface NetworkInstanceRunningInfo {
dev_name: string
my_node_info: NodeInfo
events: Record<string, any>
node_info: NodeInfo
routes: Route[]
peers: PeerInfo[]
peer_route_pairs: PeerRoutePair[]
running: boolean
error_msg?: string
}
export interface Ipv4Addr {
addr: number
}
export interface Ipv6Addr {
part1: number
part2: number
part3: number
part4: number
}
export interface NodeInfo {
virtual_ipv4: string
hostname: string
version: string
ips: {
public_ipv4: Ipv4Addr
interface_ipv4s: Ipv4Addr[]
public_ipv6: Ipv6Addr
interface_ipv6s: Ipv6Addr[]
listeners: {
serialization: string
scheme_end: number
username_end: number
host_start: number
host_end: number
host: any
port?: number
path_start: number
query_start?: number
fragment_start?: number
}[]
}
stun_info: StunInfo
listeners: string[]
vpn_portal_cfg?: string
}
export interface StunInfo {
udp_nat_type: number
tcp_nat_type: number
last_update_time: number
}
export interface Route {
peer_id: number
ipv4_addr: {
address: Ipv4Addr
network_length: number
} | string | null
next_hop_peer_id: number
cost: number
proxy_cidrs: string[]
hostname: string
stun_info?: StunInfo
inst_id: string
version: string
}
export interface PeerInfo {
peer_id: number
conns: PeerConnInfo[]
}
export interface PeerConnInfo {
conn_id: string
my_peer_id: number
is_client: boolean
peer_id: number
features: string[]
tunnel?: TunnelInfo
stats?: PeerConnStats
loss_rate: number
}
export interface PeerRoutePair {
route: Route
peer?: PeerInfo
}
export interface TunnelInfo {
tunnel_type: string
local_addr: string
remote_addr: string
}
export interface PeerConnStats {
rx_bytes: number
tx_bytes: number
rx_packets: number
tx_packets: number
latency_us: number
}
export enum EventType {
TunDeviceReady = 'TunDeviceReady', // string
TunDeviceError = 'TunDeviceError', // string
PeerAdded = 'PeerAdded', // number
PeerRemoved = 'PeerRemoved', // number
PeerConnAdded = 'PeerConnAdded', // PeerConnInfo
PeerConnRemoved = 'PeerConnRemoved', // PeerConnInfo
ListenerAdded = 'ListenerAdded', // any
ListenerAddFailed = 'ListenerAddFailed', // any, string
ListenerAcceptFailed = 'ListenerAcceptFailed', // any, string
ConnectionAccepted = 'ConnectionAccepted', // string, string
ConnectionError = 'ConnectionError', // string, string, string
Connecting = 'Connecting', // any
ConnectError = 'ConnectError', // string, string, string
VpnPortalClientConnected = 'VpnPortalClientConnected', // string, string
VpnPortalClientDisconnected = 'VpnPortalClientDisconnected', // string, string, string
DhcpIpv4Changed = 'DhcpIpv4Changed', // ipv4 | null, ipv4 | null
DhcpIpv4Conflicted = 'DhcpIpv4Conflicted', // ipv4 | null
}

View File

@ -43,7 +43,6 @@ clap = { version = "4.4.8", features = [
"wrap_help",
] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.5.0", features = [
"v4",
"fast-rng",

View File

@ -21,8 +21,6 @@
"@primevue/themes": "^4.2.1",
"@vueuse/core": "^11.1.0",
"aura": "link:@primevue\\themes\\aura",
"axios": "^1.7.7",
"floating-vue": "^5.2",
"ip-num": "1.5.1",
"primeicons": "^7.0.0",
"primevue": "^4.2.1",
@ -43,6 +41,6 @@
"typescript": "~5.6.3",
"vite": "^5.4.10",
"vite-plugin-dts": "^4.3.0",
"vue-tsc": "^2.1.10"
"vue-tsc": "^2.1.8"
}
}

View File

@ -8,6 +8,7 @@ import { useI18n } from 'vue-i18n'
const props = defineProps<{
configInvalid?: boolean
instanceId?: string
hostname?: string
}>()

View File

@ -4,7 +4,7 @@ import { IPv4 } from 'ip-num/IPNumber'
import { NetworkInstance, 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 { num2ipv4, num2ipv6 } from '../modules/utils';
import { DataTable, Column, Tag, Chip, Button, Dialog, ScrollPanel, Timeline, Divider, Card, } from 'primevue';
const props = defineProps<{
@ -138,7 +138,7 @@ const myNodeInfoChips = computed(() => {
// virtual ipv4
chips.push({
label: `Virtual IPv4: ${ipv4InetToString(my_node_info.virtual_ipv4)}`,
label: `Virtual IPv4: ${my_node_info.virtual_ipv4}`,
icon: '',
} as Chip)
@ -146,7 +146,7 @@ const myNodeInfoChips = computed(() => {
const local_ipv4s = my_node_info.ips?.interface_ipv4s
for (const [idx, ip] of local_ipv4s?.entries()) {
chips.push({
label: `Local IPv4 ${idx}: ${ipv4ToString(ip)}`,
label: `Local IPv4 ${idx}: ${num2ipv4(ip)}`,
icon: '',
} as Chip)
}
@ -155,7 +155,7 @@ const myNodeInfoChips = computed(() => {
const local_ipv6s = my_node_info.ips?.interface_ipv6s
for (const [idx, ip] of local_ipv6s?.entries()) {
chips.push({
label: `Local IPv6 ${idx}: ${ipv6ToString(ip)}`,
label: `Local IPv6 ${idx}: ${num2ipv6(ip)}`,
icon: '',
} as Chip)
}
@ -172,7 +172,7 @@ const myNodeInfoChips = computed(() => {
const public_ipv6 = my_node_info.ips?.public_ipv6
if (public_ipv6) {
chips.push({
label: `Public IPv6: ${ipv6ToString(public_ipv6)}`,
label: `Public IPv6: ${num2ipv6(public_ipv6)}`,
icon: '',
} as Chip)
}
@ -181,7 +181,7 @@ const myNodeInfoChips = computed(() => {
const listeners = my_node_info.listeners
for (const [idx, listener] of listeners?.entries()) {
chips.push({
label: `Listener ${idx}: ${listener.url}`,
label: `Listener ${idx}: ${listener}`,
icon: '',
} as Chip)
}
@ -295,7 +295,7 @@ function showEventLogs() {
if (!detail)
return
dialogContent.value = detail.events.map((event: string) => JSON.parse(event))
dialogContent.value = detail.events
dialogHeader.value = 'event_log'
dialogVisible.value = true
}
@ -309,11 +309,10 @@ function showEventLogs() {
</ScrollPanel>
<Timeline v-else :value="dialogContent">
<template #opposite="slotProps">
<small class="text-surface-500 dark:text-surface-400">{{ useTimeAgo(Date.parse(slotProps.item.time))
}}</small>
<small class="text-surface-500 dark:text-surface-400">{{ useTimeAgo(Date.parse(slotProps.item[0])) }}</small>
</template>
<template #content="slotProps">
<HumanEvent :event="slotProps.item.event" />
<HumanEvent :event="slotProps.item[1]" />
</template>
</Timeline>
</Dialog>

View File

@ -7,18 +7,9 @@ import PrimeVue from 'primevue/config'
import I18nUtils from './modules/i18n'
import * as NetworkTypes from './types/network'
import HumanEvent from './components/HumanEvent.vue';
// do not use primevue tooltip, it has serious memory leak issue
// https://github.com/primefaces/primevue/issues/5856
// import Tooltip from 'primevue/tooltip';
import { vTooltip } from 'floating-vue';
import * as Api from './modules/api';
import * as Utils from './modules/utils';
export default {
install: (app: App): void => {
install: (app: App) => {
app.use(I18nUtils.i18n, { useScope: 'global' })
app.use(PrimeVue, {
theme: {
@ -36,9 +27,7 @@ export default {
app.component('Config', Config);
app.component('Status', Status);
app.component('HumanEvent', HumanEvent);
app.directive('tooltip', vTooltip as any);
}
};
export { Config, Status, I18nUtils, NetworkTypes, Api, Utils };
export { Config, Status, I18nUtils, NetworkTypes };

View File

@ -1,18 +1,11 @@
import { IPv4, IPv6 } from 'ip-num/IPNumber'
import { Ipv4Addr, Ipv4Inet, Ipv6Addr } from '../types/network'
import { Ipv4Addr, Ipv6Addr } from '../types/network'
export function ipv4ToString(ip: Ipv4Addr) {
return IPv4.fromNumber(ip.addr).toString()
export function num2ipv4(ip: Ipv4Addr) {
return IPv4.fromNumber(ip.addr)
}
export function ipv4InetToString(ip: Ipv4Inet | undefined) {
if (ip?.address === undefined) {
return 'undefined'
}
return `${ipv4ToString(ip.address)}/${ip.network_length}`
}
export function ipv6ToString(ip: Ipv6Addr) {
export function num2ipv6(ip: Ipv6Addr) {
return IPv6.fromBigInt(
(BigInt(ip.part1) << BigInt(96))
+ (BigInt(ip.part2) << BigInt(64))
@ -20,89 +13,3 @@ export function ipv6ToString(ip: Ipv6Addr) {
+ BigInt(ip.part4),
)
}
function toHexString(uint64: bigint, padding = 9): string {
let hexString = uint64.toString(16);
while (hexString.length < padding) {
hexString = '0' + hexString;
}
return hexString;
}
function uint32ToUuid(part1: number, part2: number, part3: number, part4: number): string {
// 将两个 uint64 转换为 16 进制字符串
const part1Hex = toHexString(BigInt(part1), 8);
const part2Hex = toHexString(BigInt(part2), 8);
const part3Hex = toHexString(BigInt(part3), 8);
const part4Hex = toHexString(BigInt(part4), 8);
// 构造 UUID 格式字符串
const uuid = `${part1Hex.substring(0, 8)}-${part2Hex.substring(0, 4)}-${part2Hex.substring(4, 8)}-${part3Hex.substring(0, 4)}-${part3Hex.substring(4, 8)}${part4Hex.substring(0, 12)}`;
return uuid;
}
export interface UUID {
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 interface DeviceInfo {
hostname: string;
public_ip: string;
running_network_count: number;
report_time: string;
easytier_version: string;
running_network_instances?: Array<string>;
machine_id: string;
}
export function buildDeviceInfo(device: any): DeviceInfo {
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,
report_time: device.info?.report_time,
easytier_version: device.info?.easytier_version,
machine_id: UuidToStr(device.info?.machine_id),
};
return dev_info;
}
// write a class to run a function periodically and can be stopped by calling stop(), use setTimeout to trigger the function
export class PeriodicTask {
private interval: number;
private task: (() => Promise<void>) | undefined;
private timer: any;
constructor(task: () => Promise<void>, interval: number) {
this.interval = interval;
this.task = task;
}
_runTaskHelper(nextInterval: number) {
this.timer = setTimeout(async () => {
if (this.task) {
await this.task();
this._runTaskHelper(this.interval);
}
}, nextInterval);
}
start() {
this._runTaskHelper(0);
}
stop() {
this.task = undefined;
clearTimeout(this.timer);
}
}

View File

@ -1,5 +1,4 @@
@import 'primeicons/primeicons.css';
@import 'floating-vue/dist/style.css';
.frontend-lib {

View File

@ -84,7 +84,8 @@ export interface NetworkInstance {
export interface NetworkInstanceRunningInfo {
dev_name: string
my_node_info: NodeInfo
events: Array<string>,
events: Record<string, any>
node_info: NodeInfo
routes: Route[]
peers: PeerInfo[]
peer_route_pairs: PeerRoutePair[]
@ -96,11 +97,6 @@ export interface Ipv4Addr {
addr: number
}
export interface Ipv4Inet {
address: Ipv4Addr
network_length: number
}
export interface Ipv6Addr {
part1: number
part2: number
@ -108,12 +104,8 @@ export interface Ipv6Addr {
part4: number
}
export interface Url {
url: string
}
export interface NodeInfo {
virtual_ipv4: Ipv4Inet,
virtual_ipv4: string
hostname: string
version: string
ips: {
@ -135,7 +127,7 @@ export interface NodeInfo {
}[]
}
stun_info: StunInfo
listeners: Url[]
listeners: string[]
vpn_portal_cfg?: string
}
@ -147,7 +139,10 @@ export interface StunInfo {
export interface Route {
peer_id: number
ipv4_addr: Ipv4Inet | string | null
ipv4_addr: {
address: Ipv4Addr
network_length: number
} | string | null
next_hop_peer_id: number
cost: number
proxy_cidrs: string[]

View File

@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/easytier.png" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EasyTier Dashboard</title>
<title>Vite + Vue + TS</title>
</head>
<body>
<div id="app"></div>

View File

@ -15,8 +15,7 @@
"easytier-frontend-lib": "workspace:*",
"primevue": "^4.2.1",
"tailwindcss-primeui": "^0.3.4",
"vue": "^3.5.12",
"vue-router": "4"
"vue": "^3.5.12"
},
"devDependencies": {
"@types/node": "^22.8.6",
@ -27,6 +26,6 @@
"typescript": "~5.6.2",
"vite": "^5.4.10",
"vite-plugin-singlefile": "^2.0.3",
"vue-tsc": "^2.1.10"
"vue-tsc": "^2.1.8"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -2,7 +2,12 @@
import { I18nUtils } from 'easytier-frontend-lib'
import { onMounted } from 'vue';
import { Toast, DynamicDialog } from 'primevue';
import Login from './components/Login.vue'
import { Button } from 'primevue';
import ApiClient from './modules/api';
import DeviceList from './components/DeviceList.vue';
const api = new ApiClient('http://10.147.223.128:11211/api/v1/'); // Replace with actual API URL
onMounted(async () => {
await I18nUtils.loadLanguageAsync('cn')
@ -13,10 +18,109 @@ onMounted(async () => {
<!-- https://flowbite.com/docs/components/sidebar/#sidebar-with-navbar -->
<template>
<Toast />
<DynamicDialog />
<div id="root" class="">
<nav class="fixed top-0 z-50 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700">
<div class="px-3 py-3 lg:px-5 lg:pl-3">
<div class="flex items-center justify-between">
<div class="flex items-center justify-start rtl:justify-end">
<button data-drawer-target="logo-sidebar" data-drawer-toggle="logo-sidebar" aria-controls="logo-sidebar"
type="button"
class="inline-flex items-center p-2 text-sm text-gray-500 rounded-lg sm:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600">
<span class="sr-only">Open sidebar</span>
<i class="pi pi-list" style="font-size: 1.3rem"></i>
</button>
<a href="https://flowbite.com" class="flex ms-2 md:me-24">
<img src="https://flowbite.com/docs/images/logo.svg" class="h-8 me-3" alt="FlowBite Logo" />
<span
class="self-center text-xl font-semibold sm:text-2xl whitespace-nowrap dark:text-white">EasyTier</span>
</a>
</div>
<div class="flex items-center">
<div class="flex items-center ms-3">
<div>
<button type="button"
class="flex text-sm bg-gray-800 rounded-full focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
aria-expanded="false" data-dropdown-toggle="dropdown-user">
<span class="sr-only">Open user menu</span>
<img class="w-8 h-8 rounded-full" src="https://flowbite.com/docs/images/people/profile-picture-5.jpg"
alt="user photo">
</button>
</div>
<div
class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded shadow dark:bg-gray-700 dark:divide-gray-600"
id="dropdown-user">
<div class="px-4 py-3" role="none">
<p class="text-sm text-gray-900 dark:text-white" role="none">
Neil Sims
</p>
<p class="text-sm font-medium text-gray-900 truncate dark:text-gray-300" role="none">
neil.sims@flowbite.com
</p>
</div>
<ul class="py-1" role="none">
<li>
<a href="#"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem">Dashboard</a>
</li>
<li>
<a href="#"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem">Settings</a>
</li>
<li>
<a href="#"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem">Earnings</a>
</li>
<li>
<a href="#"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem">Sign out</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</nav>
<RouterView />
<aside id="logo-sidebar"
class="fixed top-0 left-0 z-40 w-64 h-screen pt-20 transition-transform -translate-x-full bg-white border-r border-gray-200 sm:translate-x-0 dark:bg-gray-800 dark:border-gray-700"
aria-label="Sidebar">
<div class="h-full px-3 pb-4 overflow-y-auto bg-white dark:bg-gray-800">
<ul class="space-y-2 font-medium">
<li>
<Button variant="text" class="w-full justify-start gap-x-3 pl-1.5" severity="contrast">
<i class="pi pi-chart-pie" style="font-size: 1.2rem"></i>
<span class="mb-0.5">DashBoard</span>
</Button>
</li>
<li>
<Button variant="text" class="w-full justify-start gap-x-3 pl-1.5" severity="contrast">
<i class="pi pi-server" style="font-size: 1.2rem"></i>
<span class="mb-0.5">Devices</span>
</Button>
</li>
</ul>
</div>
</aside>
<div class="p-4 sm:ml-64">
<div class="p-4 border-2 border-gray-200 border-dashed rounded-lg dark:border-gray-700 mt-14">
<div class="grid grid-cols-1 gap-4 mb-4">
<DeviceList :api="api"></DeviceList>
</div>
<div class="grid grid-cols-1 gap-4 mb-4">
<Login :api="api"></Login>
</div>
</div>
</div>
</div>
</template>
<style scoped>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -1,33 +0,0 @@
<script lang="ts" setup>
import { computed, inject, ref } from 'vue';
import { Card, Password, Button } from 'primevue';
import { Api } from 'easytier-frontend-lib';
const dialogRef = inject<any>('dialogRef');
const api = computed<Api.ApiClient>(() => dialogRef.value.data.api);
const password = ref('');
const changePassword = async () => {
await api.value.change_password(password.value);
dialogRef.value.close();
}
</script>
<template>
<div class="flex items-center justify-center">
<Card class="w-full max-w-md p-6">
<template #header>
<h2 class="text-2xl font-semibold text-center">Change Password
</h2>
</template>
<template #content>
<div class="flex flex-col space-y-4">
<Password v-model="password" placeholder="New Password" :feedback="false" toggleMask />
<Button @click="changePassword" label="Ok" />
</div>
</template>
</Card>
</div>
</template>

View File

@ -1,65 +0,0 @@
<script setup lang="ts">
import { Card, useToast } from 'primevue';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Api, Utils } from 'easytier-frontend-lib';
const props = defineProps({
api: Api.ApiClient,
});
const toast = useToast();
const summary = ref<Api.Summary | undefined>(undefined);
const loadSummary = async () => {
const resp = await props.api?.get_summary();
summary.value = resp;
};
const periodFunc = new Utils.PeriodicTask(async () => {
try {
await loadSummary();
} catch (e) {
toast.add({ severity: 'error', summary: 'Load Summary Failed', detail: e, life: 2000 });
console.error(e);
}
}, 1000);
onMounted(async () => {
periodFunc.start();
});
onUnmounted(() => {
periodFunc.stop();
});
const deviceCount = computed<number | undefined>(
() => {
return summary.value?.device_count;
},
);
</script>
<template>
<div class="grid grid-cols-3 gap-4">
<Card class="h-full">
<template #title>Device Count</template>
<template #content>
<div class="w-full flex justify-center text-7xl font-bold text-green-800 mt-4">
{{ deviceCount }}
</div>
</template>
</Card>
<div class="flex items-center justify-center rounded bg-gray-50 dark:bg-gray-800">
<p class="text-2xl text-gray-400 dark:text-gray-500">
<!-- <svg class="w-3.5 h-3.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 18 18">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 1v16M1 9h16" />
</svg> -->
</p>
</div>
</div>
</template>

View File

@ -1,86 +1,204 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Button, Column, DataTable, Drawer, ProgressSpinner, useToast } from 'primevue';
import { useRoute, useRouter } from 'vue-router';
import { Api, Utils } from 'easytier-frontend-lib';
import { computed, onMounted, ref } from 'vue';
import ApiClient, { ValidateConfigResponse } from '../modules/api';
import { Config, Status, NetworkTypes } from 'easytier-frontend-lib'
import { Button, Column, DataTable, Drawer, Toolbar, IftaLabel, Select, Dialog, ConfirmPopup, useConfirm } from 'primevue';
function toHexString(uint64: bigint, padding = 9): string {
let hexString = uint64.toString(16);
while (hexString.length < padding) {
hexString = '0' + hexString;
}
return hexString;
}
function uint32ToUuid(part1: number, part2: number, part3: number, part4: number): string {
// uint64 16
const part1Hex = toHexString(BigInt(part1), 8);
const part2Hex = toHexString(BigInt(part2), 8);
const part3Hex = toHexString(BigInt(part3), 8);
const part4Hex = toHexString(BigInt(part4), 8);
// UUID
const uuid = `${part1Hex.substring(0, 8)}-${part2Hex.substring(0, 4)}-${part2Hex.substring(4, 8)}-${part3Hex.substring(0, 4)}-${part3Hex.substring(4, 8)}${part4Hex.substring(0, 12)}`;
return uuid;
}
interface UUID {
part1: number;
part2: number;
part3: number;
part4: number;
}
function UuidToStr(uuid: UUID): string {
return uint32ToUuid(uuid.part1, uuid.part2, uuid.part3, uuid.part4);
}
const props = defineProps({
api: Api.ApiClient,
api: ApiClient,
});
const api = props.api;
const deviceList = ref<Array<Utils.DeviceInfo> | undefined>(undefined);
interface DeviceList {
hostname: string;
public_ip: string;
running_network_count: number;
report_time: string;
easytier_version: string;
running_network_instances?: Array<string>;
machine_id: string;
}
const selectedDeviceId = computed<string | undefined>(() => route.params.deviceId as string);
const route = useRoute();
const router = useRouter();
const toast = useToast();
const selectedDevice = ref<DeviceList | null>(null);
const deviceList = ref<Array<DeviceList>>([]);
const instanceIdList = computed(() => {
let insts = selectedDevice.value?.running_network_instances || [];
let options = insts.map((instance: string) => {
return { uuid: instance };
});
console.log("options", options);
return options;
});
const selectedInstanceId = ref<any | null>(null);
const curNetworkInfo = ref<NetworkTypes.NetworkInstance | null>(null);
const loadDevices = async () => {
const resp = await api?.list_machines();
let devices: Array<Utils.DeviceInfo> = [];
console.log(resp);
let devices: Array<DeviceList> = [];
for (const device of (resp || [])) {
devices.push({
hostname: device.info?.hostname,
public_ip: device.client_url,
running_network_instances: device.info?.running_network_instances.map((instance: any) => Utils.UuidToStr(instance)),
running_network_instances: device.info?.running_network_instances.map((instance: any) => UuidToStr(instance)),
running_network_count: device.info?.running_network_instances.length,
report_time: device.info?.report_time,
easytier_version: device.info?.easytier_version,
machine_id: Utils.UuidToStr(device.info?.machine_id),
machine_id: UuidToStr(device.info?.machine_id),
});
}
console.debug("device list", deviceList.value);
deviceList.value = devices;
console.log(deviceList.value);
};
const periodFunc = new Utils.PeriodicTask(async () => {
try {
await loadDevices();
} catch (e) {
toast.add({ severity: 'error', summary: 'Load Device List Failed', detail: e, life: 2000 });
console.error(e);
interface SelectedDevice {
machine_id: string;
instance_id: string;
}
const checkDeviceSelected = (): SelectedDevice => {
let machine_id = selectedDevice.value?.machine_id;
let inst_id = selectedInstanceId.value?.uuid;
if (machine_id && inst_id) {
return { machine_id, instance_id: inst_id };
} else {
throw new Error("No device selected");
}
}, 1000);
}
const loadDeviceInfo = async () => {
let selectedDevice = checkDeviceSelected();
if (!selectedDevice) {
return;
}
let ret = await api?.get_network_info(selectedDevice.machine_id, selectedDevice.instance_id);
let device_info = ret[selectedDevice.instance_id]
curNetworkInfo.value = {
instance_id: selectedDevice.instance_id,
running: device_info.running,
error_msg: device_info.error_msg,
detail: device_info,
} as NetworkTypes.NetworkInstance;
}
onMounted(async () => {
periodFunc.start();
setInterval(loadDevices, 1000);
setInterval(loadDeviceInfo, 1000);
});
onUnmounted(() => {
periodFunc.stop();
});
const visibleRight = ref(false);
const deviceManageVisible = computed<boolean>({
get: () => !!selectedDeviceId.value,
set: (value) => {
if (!value) {
router.push({ name: 'deviceList', params: { deviceId: undefined } });
}
const showCreateNetworkDialog = ref(false);
const newNetworkConfig = ref<NetworkTypes.NetworkConfig>(NetworkTypes.DEFAULT_NETWORK_CONFIG());
const verifyNetworkConfig = async (): Promise<ValidateConfigResponse | undefined> => {
let machine_id = selectedDevice.value?.machine_id;
if (!machine_id) {
throw new Error("No machine selected");
}
});
const selectedDeviceHostname = computed<string | undefined>(() => {
return deviceList.value?.find((device) => device.machine_id === selectedDeviceId.value)?.hostname;
});
if (!newNetworkConfig.value) {
throw new Error("No network config");
}
let ret = await api?.validate_config(machine_id, newNetworkConfig.value);
console.log("verifyNetworkConfig", ret);
return ret;
}
const createNewNetwork = async () => {
let config = await verifyNetworkConfig();
if (!config) {
return;
}
let machine_id = selectedDevice.value?.machine_id;
if (!machine_id) {
throw new Error("No machine selected");
}
let ret = await api?.run_network(machine_id, config?.toml_config);
console.log("createNewNetwork", ret);
showCreateNetworkDialog.value = false;
await loadDevices();
}
const confirm = useConfirm();
const confirmDeleteNetwork = (event: any) => {
confirm.require({
target: event.currentTarget,
message: 'Do you want to delete this network?',
icon: 'pi pi-info-circle',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Delete',
severity: 'danger'
},
accept: async () => {
const ret = checkDeviceSelected();
await api?.delete_network(ret?.machine_id, ret?.instance_id);
await loadDevices();
},
reject: () => {
return;
}
});
};
</script>
<style scoped></style>
<template>
<div v-if="deviceList === undefined" class="w-full flex justify-center">
<ProgressSpinner />
</div>
<ConfirmPopup></ConfirmPopup>
<Dialog v-model:visible="showCreateNetworkDialog" modal header="Create New Network" :style="{ width: '55rem' }">
<Config :cur-network="newNetworkConfig" @run-network="createNewNetwork"></Config>
</Dialog>
<DataTable :value="deviceList" tableStyle="min-width: 50rem" :metaKeySelection="true" sortField="hostname"
:sortOrder="-1" v-if="deviceList !== undefined">
:sortOrder="-1">
<template #header>
<div class="text-xl font-bold">Device List</div>
</template>
<Column field="hostname" header="Hostname" sortable style="width: 180px"></Column>
<Column field="public_ip" header="Public IP" style="width: 150px"></Column>
<Column field="running_network_count" header="Running Network Count" sortable style="width: 150px"></Column>
@ -88,23 +206,38 @@ const selectedDeviceHostname = computed<string | undefined>(() => {
<Column field="easytier_version" header="EasyTier Version" sortable style="width: 150px"></Column>
<Column class="w-24 !text-end">
<template #body="{ data }">
<Button icon="pi pi-cog"
@click="router.push({ name: 'deviceManagement', params: { deviceId: data.machine_id, instanceId: data.running_network_instances[0] } })"
severity="secondary" rounded></Button>
<Button icon="pi pi-search" @click="selectedDevice = data; visibleRight = true" severity="secondary"
rounded></Button>
</template>
</Column>
<template #footer>
<div class="flex justify-end">
<div class="flex justify-start">
<Button icon="pi pi-refresh" label="Reload" severity="info" @click="loadDevices" />
</div>
</template>
</DataTable>
<Drawer v-model:visible="deviceManageVisible" :header="`Manage ${selectedDeviceHostname}`" position="right"
class="w-1/2 min-w-96">
<RouterView v-slot="{ Component }">
<component :is="Component" :api="api" :deviceList="deviceList" @update="loadDevices" />
</RouterView>
<Drawer v-model:visible="visibleRight" header="Device Management" position="right" class="w-1/2 min-w-96">
<Toolbar>
<template #start>
<IftaLabel>
<Select v-model="selectedInstanceId" :options="instanceIdList" optionLabel="uuid"
inputId="dd-inst-id" placeholder="Select Instance" />
<label class="mr-3" for="dd-inst-id">Network</label>
</IftaLabel>
</template>
<template #end>
<div class="gap-x-3 flex">
<Button @click="confirmDeleteNetwork($event)" icon="pi pi-minus" severity="danger" label="Delete"
iconPos="right" />
<Button @click="showCreateNetworkDialog = true" icon="pi pi-plus" label="Create" iconPos="right" />
</div>
</template>
</Toolbar>
<Status v-bind:cur-network-inst="curNetworkInfo">
</Status>
</Drawer>
</template>

View File

@ -1,197 +0,0 @@
<script setup lang="ts">
import { Toolbar, IftaLabel, Select, Button, ConfirmPopup, Dialog, useConfirm, useToast } from 'primevue';
import { NetworkTypes, Status, Utils, Api, } from 'easytier-frontend-lib';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const props = defineProps<{
api: Api.ApiClient;
deviceList: Array<Utils.DeviceInfo> | undefined;
}>();
const emits = defineEmits(['update']);
const route = useRoute();
const router = useRouter();
const toast = useToast();
const deviceId = computed<string>(() => {
return route.params.deviceId as string;
});
const instanceId = computed<string>(() => {
return route.params.instanceId as string;
});
const deviceInfo = computed<Utils.DeviceInfo | undefined | null>(() => {
return deviceId.value ? props.deviceList?.find((device) => device.machine_id === deviceId.value) : null;
});
const curNetworkInfo = ref<NetworkTypes.NetworkInstance | null>(null);
const isEditing = ref(false);
const showCreateNetworkDialog = ref(false);
const newNetworkConfig = ref<NetworkTypes.NetworkConfig>(NetworkTypes.DEFAULT_NETWORK_CONFIG());
const instanceIdList = computed(() => {
let insts = deviceInfo.value?.running_network_instances || [];
let options = insts.map((instance: string) => {
return { uuid: instance };
});
return options;
});
const selectedInstanceId = computed({
get() {
return instanceIdList.value.find((instance) => instance.uuid === instanceId.value);
},
set(value: any) {
console.log("set instanceId", value);
router.push({ name: 'deviceManagement', params: { deviceId: deviceId.value, instanceId: value.uuid } });
}
});
const confirm = useConfirm();
const confirmDeleteNetwork = (event: any) => {
confirm.require({
target: event.currentTarget,
message: 'Do you want to delete this network?',
icon: 'pi pi-info-circle',
rejectProps: {
label: 'Cancel',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Delete',
severity: 'danger'
},
accept: async () => {
try {
await props.api?.delete_network(deviceId.value, instanceId.value);
} catch (e) {
console.error(e);
}
emits('update');
},
reject: () => {
return;
}
});
};
// const verifyNetworkConfig = async (): Promise<ValidateConfigResponse | undefined> => {
// let ret = await props.api?.validate_config(deviceId.value, newNetworkConfig.value);
// console.log("verifyNetworkConfig", ret);
// return ret;
// }
const createNewNetwork = async () => {
try {
if (isEditing.value) {
await props.api?.delete_network(deviceId.value, instanceId.value);
}
let ret = await props.api?.run_network(deviceId.value, newNetworkConfig.value);
console.debug("createNewNetwork", ret);
} catch (e: any) {
console.error(e);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to create network, error: ' + JSON.stringify(e.response.data), life: 2000 });
return;
}
emits('update');
showCreateNetworkDialog.value = false;
}
const newNetwork = () => {
newNetworkConfig.value = NetworkTypes.DEFAULT_NETWORK_CONFIG();
isEditing.value = false;
showCreateNetworkDialog.value = true;
}
const editNetwork = async () => {
if (!deviceId.value || !instanceId.value) {
toast.add({ severity: 'error', summary: 'Error', detail: 'No network instance selected', life: 2000 });
return;
}
isEditing.value = true;
try {
let ret = await props.api?.get_network_config(deviceId.value, instanceId.value);
console.debug("editNetwork", ret);
newNetworkConfig.value = ret;
showCreateNetworkDialog.value = true;
} catch (e: any) {
console.error(e);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to edit network, error: ' + JSON.stringify(e.response.data), life: 2000 });
return;
}
}
const loadDeviceInfo = async () => {
if (!deviceId.value || !instanceId.value) {
return;
}
let ret = await props.api?.get_network_info(deviceId.value, instanceId.value);
let device_info = ret[instanceId.value];
curNetworkInfo.value = {
instance_id: instanceId.value,
running: device_info.running,
error_msg: device_info.error_msg,
detail: device_info,
} as NetworkTypes.NetworkInstance;
}
let periodFunc = new Utils.PeriodicTask(async () => {
try {
await loadDeviceInfo();
} catch (e) {
console.debug(e);
}
}, 1000);
onMounted(async () => {
periodFunc.start();
});
onUnmounted(() => {
periodFunc.stop();
});
</script>
<template>
<ConfirmPopup></ConfirmPopup>
<Dialog v-model:visible="showCreateNetworkDialog" modal :header="!isEditing ? 'Create New Network' : 'Edit Network'"
:style="{ width: '55rem' }">
<Config :cur-network="newNetworkConfig" @run-network="createNewNetwork"></Config>
</Dialog>
<Toolbar>
<template #start>
<IftaLabel>
<Select v-model="selectedInstanceId" :options="instanceIdList" optionLabel="uuid" inputId="dd-inst-id"
placeholder="Select Instance" />
<label class="mr-3" for="dd-inst-id">Network</label>
</IftaLabel>
</template>
<template #end>
<div class="gap-x-3 flex">
<Button @click="confirmDeleteNetwork($event)" icon="pi pi-minus" severity="danger" label="Delete"
iconPos="right" />
<Button @click="editNetwork" icon="pi pi-pen-to-square" label="Edit" iconPos="right" severity="info" />
<Button @click="newNetwork" icon="pi pi-plus" label="Create" iconPos="right" />
</div>
</template>
</Toolbar>
<Status v-bind:cur-network-inst="curNetworkInfo" v-if="!!selectedInstanceId">
</Status>
<div class="grid grid-cols-1 gap-4 place-content-center h-full" v-if="!selectedInstanceId">
<div class="text-center text-xl"> Select or create a network instance to manage </div>
</div>
</template>

View File

@ -1,65 +1,3 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Card, InputText, Password, Button, AutoComplete } from 'primevue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { Api } from 'easytier-frontend-lib';
defineProps<{
isRegistering: boolean;
}>();
const api = computed<Api.ApiClient>(() => new Api.ApiClient(apiHost.value));
const router = useRouter();
const toast = useToast();
const username = ref('');
const password = ref('');
const registerUsername = ref('');
const registerPassword = ref('');
const captcha = ref('');
const captchaSrc = computed(() => api.value.captcha_url());
const onSubmit = async () => {
// Add your login logic here
const credential: Api.Credential = { username: username.value, password: password.value, };
let ret = await api.value?.login(credential);
if (ret.success) {
localStorage.setItem('apiHost', btoa(apiHost.value));
router.push({
name: 'dashboard',
params: { apiHost: btoa(apiHost.value) },
});
} else {
toast.add({ severity: 'error', summary: 'Login Failed', detail: ret.message, life: 2000 });
}
};
const onRegister = async () => {
const credential: Api.Credential = { username: registerUsername.value, password: registerPassword.value };
const registerReq: Api.RegisterData = { credentials: credential, captcha: captcha.value };
let ret = await api.value?.register(registerReq);
if (ret.success) {
toast.add({ severity: 'success', summary: 'Register Success', detail: ret.message, life: 2000 });
router.push({ name: 'login' });
} else {
toast.add({ severity: 'error', summary: 'Register Failed', detail: ret.message, life: 2000 });
}
};
const defaultApiHost = 'http://10.147.223.128:11211'
const apiHost = ref<string>(defaultApiHost)
const apiHostSuggestions = ref<Array<string>>([])
const apiHostSearch = async (event: { query: string }) => {
apiHostSuggestions.value = [];
if (event.query) {
apiHostSuggestions.value.push(event.query);
}
apiHostSuggestions.value.push(defaultApiHost);
}
</script>
<template>
<div class="flex items-center justify-center min-h-screen">
<Card class="w-full max-w-md p-6">
@ -68,11 +6,6 @@ const apiHostSearch = async (event: { query: string }) => {
</h2>
</template>
<template #content>
<div class="p-field mb-4">
<label for="api-host" class="block text-sm font-medium">Api Host</label>
<AutoComplete id="api-host" v-model="apiHost" dropdown :suggestions="apiHostSuggestions"
@complete="apiHostSearch" class="w-full" />
</div>
<form v-if="!isRegistering" @submit.prevent="onSubmit" class="space-y-4">
<div class="p-field">
<label for="username" class="block text-sm font-medium">Username</label>
@ -80,14 +13,14 @@ const apiHostSearch = async (event: { query: string }) => {
</div>
<div class="p-field">
<label for="password" class="block text-sm font-medium">Password</label>
<Password id="password" v-model="password" required toggleMask :feedback="false" />
<Password id="password" v-model="password" required toggleMask />
</div>
<div class="flex items-center justify-between">
<Button label="Login" type="submit" class="w-full" />
</div>
<div class="flex items-center justify-between">
<Button label="Register" type="button" class="w-full"
@click="$router.replace({ name: 'register' })" severity="secondary" />
<Button label="Register" type="button" class="w-full" @click="isRegistering = true"
severity="secondary" />
</div>
</form>
@ -99,7 +32,7 @@ const apiHostSearch = async (event: { query: string }) => {
<div class="p-field">
<label for="register-password" class="block text-sm font-medium">Password</label>
<Password id="register-password" v-model="registerPassword" required toggleMask
:feedback="false" class="w-full" />
class="w-full" />
</div>
<div class="p-field">
<label for="captcha" class="block text-sm font-medium">Captcha</label>
@ -110,8 +43,8 @@ const apiHostSearch = async (event: { query: string }) => {
<Button label="Register" type="submit" class="w-full" />
</div>
<div class="flex items-center justify-between">
<Button label="Back to Login" type="button" class="w-full"
@click="$router.replace({ name: 'login' })" severity="secondary" />
<Button label="Back to Login" type="button" class="w-full" @click="isRegistering = false"
severity="secondary" />
</div>
</form>
</template>
@ -119,4 +52,42 @@ const apiHostSearch = async (event: { query: string }) => {
</div>
</template>
<style scoped></style>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Card, InputText, Password, Button } from 'primevue';
import ApiClient from '../modules/api';
import { Credential } from '../modules/api';
const props = defineProps({
api: ApiClient,
});
const api = props.api;
const username = ref('');
const password = ref('');
const registerUsername = ref('');
const registerPassword = ref('');
const captcha = ref('');
const captchaSrc = computed(() => api?.captcha_url());
const isRegistering = ref(false);
const onSubmit = async () => {
console.log('Username:', username.value);
console.log('Password:', password.value);
// Add your login logic here
const credential: Credential = { username: username.value, password: password.value, };
const ret = await api?.login(credential);
alert(ret?.message);
};
const onRegister = () => {
console.log('Register Username:', registerUsername.value);
console.log('Register Password:', registerPassword.value);
console.log('Captcha:', captcha.value);
// Add your register logic here
};
</script>
<style scoped></style>

View File

@ -1,173 +0,0 @@
<script setup lang="ts">
import { Api, I18nUtils } from 'easytier-frontend-lib'
import { computed, onMounted, ref } from 'vue';
import { Button, TieredMenu } from 'primevue';
import { useRoute, useRouter } from 'vue-router';
import { useDialog } from 'primevue/usedialog';
import ChangePassword from './ChangePassword.vue';
import Icon from '../assets/easytier.png'
const route = useRoute();
const router = useRouter();
const api = computed<Api.ApiClient | undefined>(() => {
try {
return new Api.ApiClient(atob(route.params.apiHost as string), () => {
router.push({ name: 'login' });
})
} catch (e) {
router.push({ name: 'login' });
}
});
const dialog = useDialog();
onMounted(async () => {
await I18nUtils.loadLanguageAsync('cn')
});
const userMenu = ref();
const userMenuItems = ref([
{
label: 'Change Password',
icon: 'pi pi-key',
command: () => {
console.log('File');
let ret = dialog.open(ChangePassword, {
props: {
modal: true,
},
data: {
api: api.value,
}
});
console.log("return", ret)
},
},
{
label: 'Logout',
icon: 'pi pi-sign-out',
command: async () => {
try {
await api.value?.logout();
} catch (e) {
console.error("logout failed", e);
}
router.push({ name: 'login' });
},
},
])
const forceShowSideBar = ref(false)
</script>
<!-- https://flowbite.com/docs/components/sidebar/#sidebar-with-navbar -->
<template>
<nav class="fixed top-0 z-50 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700">
<div class="px-3 py-3 lg:px-5 lg:pl-3">
<div class="flex items-center justify-between">
<div class="flex items-center justify-start rtl:justify-end">
<div class="sm:hidden">
<Button type="button" aria-haspopup="true" icon="pi pi-list" variant="text" size="large"
severity="contrast" @click="forceShowSideBar = !forceShowSideBar" />
</div>
<a href="https://easytier.top" class="flex ms-2 md:me-24">
<img :src="Icon" class="h-9 me-3" alt="FlowBite Logo" />
<span
class="self-center text-xl font-semibold sm:text-2xl whitespace-nowrap dark:text-white">EasyTier</span>
</a>
</div>
<div class="flex items-center">
<div class="flex items-center ms-3">
<div>
<Button type="button" @click="userMenu.toggle($event)" aria-haspopup="true"
aria-controls="user-menu" icon="pi pi-user" raised rounded />
<TieredMenu ref="userMenu" id="user-menu" :model="userMenuItems" popup />
</div>
<div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded shadow dark:bg-gray-700 dark:divide-gray-600"
id="dropdown-user">
<div class="px-4 py-3" role="none">
<p class="text-sm text-gray-900 dark:text-white" role="none">
Neil Sims
</p>
<p class="text-sm font-medium text-gray-900 truncate dark:text-gray-300" role="none">
neil.sims@flowbite.com
</p>
</div>
<ul class="py-1" role="none">
<li>
<a href="#"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem">Dashboard</a>
</li>
<li>
<a href="#"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem">Settings</a>
</li>
<li>
<a href="#"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem">Earnings</a>
</li>
<li>
<a href="#"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
role="menuitem">Sign out</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</nav>
<aside id="logo-sidebar"
class="fixed top-1 left-0 z-40 w-64 h-screen pt-20 transition-transform bg-white border-r border-gray-201 sm:translate-x-0 dark:bg-gray-800 dark:border-gray-700"
:class="{ '-translate-x-full': !forceShowSideBar }" aria-label="Sidebar">
<div class="h-full px-3 pb-4 overflow-y-auto bg-white dark:bg-gray-800">
<ul class="space-y-2 font-medium">
<li>
<Button variant="text" class="w-full justify-start gap-x-3 pl-1.5 sidebar-button"
severity="contrast" @click="router.push({ name: 'dashboard' })">
<i class="pi pi-chart-pie text-xl"></i>
<span class="mb-0.5">DashBoard</span>
</Button>
</li>
<li>
<Button variant="text" class="w-full justify-start gap-x-3 pl-1.5 sidebar-button"
severity="contrast" @click="router.push({ name: 'deviceList' })">
<i class="pi pi-server text-xl"></i>
<span class="mb-0.5">Devices</span>
</Button>
</li>
<li>
<Button variant="text" class="w-full justify-start gap-x-3 pl-1.5 sidebar-button"
severity="contrast" @click="router.push({ name: 'login' })">
<i class="pi pi-sign-in text-xl"></i>
<span class="mb-0.5">Login Page</span>
</Button>
</li>
</ul>
</div>
</aside>
<div class="p-4 sm:ml-64">
<div class="p-4 border-2 border-gray-200 border-dashed rounded-lg dark:border-gray-700 mt-14">
<div class="grid grid-cols-1 gap-4">
<RouterView v-slot="{ Component }">
<component :is="Component" :api="api" />
</RouterView>
</div>
</div>
</div>
</template>
<style scoped>
.sidebar-button {
text-align: left;
justify-content: left;
}
</style>

View File

@ -7,72 +7,6 @@ import PrimeVue from 'primevue/config'
import Aura from '@primevue/themes/aura'
import ConfirmationService from 'primevue/confirmationservice';
import { createRouter, createWebHashHistory } from 'vue-router'
import MainPage from './components/MainPage.vue'
import Login from './components/Login.vue'
import DeviceList from './components/DeviceList.vue'
import DeviceManagement from './components/DeviceManagement.vue'
import Dashboard from './components/Dashboard.vue'
import DialogService from 'primevue/dialogservice';
import ToastService from 'primevue/toastservice';
const routes = [
{
path: '/auth', children: [
{
name: 'login',
path: '',
component: Login,
alias: 'login',
props: { isRegistering: false }
},
{
name: 'register',
path: 'register',
component: Login,
props: { isRegistering: true }
}
]
},
{
path: '/h/:apiHost', component: MainPage, children: [
{
path: '',
alias: 'dashboard',
name: 'dashboard',
component: Dashboard,
},
{
path: 'deviceList',
name: 'deviceList',
component: DeviceList,
children: [
{
path: 'device/:deviceId/:instanceId?',
name: 'deviceManagement',
component: DeviceManagement,
}
]
},
]
},
{
path: '/:pathMatch(.*)*', name: 'notFound', redirect: () => {
let apiHost = localStorage.getItem('apiHost');
if (apiHost) {
return { name: 'dashboard', params: { apiHost: apiHost } }
} else {
return { name: 'login' }
}
}
},
]
const router = createRouter({
history: createWebHashHistory(),
routes,
})
createApp(App).use(PrimeVue,
{
theme: {
@ -87,4 +21,4 @@ createApp(App).use(PrimeVue,
}
}
}
).use(ToastService as any).use(DialogService as any).use(router).use(ConfirmationService as any).use(EasytierFrontendLib).mount('#app')
).use(ConfirmationService as any).use(EasytierFrontendLib).mount('#app')

View File

@ -11,8 +11,8 @@ export interface LoginResponse {
}
export interface RegisterResponse {
success: boolean;
message: string;
user: any; // 同上
}
// 定义请求体数据结构
@ -22,27 +22,21 @@ export interface Credential {
}
export interface RegisterData {
credentials: Credential;
credential: Credential;
captcha: string;
}
export interface Summary {
device_count: number;
}
export class ApiClient {
class ApiClient {
private client: AxiosInstance;
private authFailedCb: Function | undefined;
constructor(baseUrl: string, authFailedCb: Function | undefined = undefined) {
constructor(baseUrl: string) {
this.client = axios.create({
baseURL: baseUrl + '/api/v1',
baseURL: baseUrl,
withCredentials: true, // 如果需要支持跨域携带cookie
headers: {
'Content-Type': 'application/json',
},
});
this.authFailedCb = authFailedCb;
// 添加请求拦截器
this.client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
@ -53,18 +47,12 @@ export class ApiClient {
// 添加响应拦截器
this.client.interceptors.response.use((response: AxiosResponse) => {
console.debug('Axios Response:', response);
console.log('Axios Response:', response);
return response.data; // 假设服务器返回的数据都在data属性中
}, (error: any) => {
if (error.response) {
let response: AxiosResponse = error.response;
if (response.status == 401 && this.authFailedCb) {
console.error('Unauthorized:', response.data);
this.authFailedCb();
} else {
// 请求已发出但是服务器响应的状态码不在2xx范围
console.error('Response Error:', error.response.data);
}
// 请求已发出但是服务器响应的状态码不在2xx范围
console.error('Response Error:', error.response.data);
} else if (error.request) {
// 请求已发出,但是没有收到响应
console.error('Request Error:', error.request);
@ -76,20 +64,6 @@ export class ApiClient {
});
}
// 注册
public async register(data: RegisterData): Promise<RegisterResponse> {
try {
const response = await this.client.post<RegisterResponse>('/auth/register', data);
console.log("register response:", response);
return { success: true, message: 'Register success', };
} catch (error) {
if (error instanceof AxiosError) {
return { success: false, message: 'Failed to register, error: ' + JSON.stringify(error.response?.data), };
}
return { success: false, message: 'Unknown error, error: ' + error, };
}
}
// 登录
public async login(data: Credential): Promise<LoginResponse> {
try {
@ -108,24 +82,10 @@ export class ApiClient {
}
}
public async logout() {
await this.client.get('/auth/logout');
if (this.authFailedCb) {
this.authFailedCb();
}
}
public async change_password(new_password: string) {
await this.client.put('/auth/password', { new_password: new_password });
}
public async check_login_status() {
try {
await this.client.get('/auth/check_login_status');
return true;
} catch (error) {
return false;
}
// 注册
public async register(data: RegisterData): Promise<RegisterResponse> {
const response = await this.client.post<RegisterResponse>('/auth/register', data);
return response.data;
}
public async list_session() {
@ -143,11 +103,6 @@ export class ApiClient {
return response.info.map;
}
public async get_network_config(machine_id: string, inst_id: string): Promise<any> {
const response = await this.client.get<any, Record<string, any>>('/machines/' + machine_id + '/networks/config/' + inst_id);
return response;
}
public async validate_config(machine_id: string, config: any): Promise<ValidateConfigResponse> {
const response = await this.client.post<any, ValidateConfigResponse>(`/machines/${machine_id}/validate-config`, {
config: config,
@ -155,7 +110,7 @@ export class ApiClient {
return response;
}
public async run_network(machine_id: string, config: any): Promise<undefined> {
public async run_network(machine_id: string, config: string): Promise<undefined> {
await this.client.post<string>(`/machines/${machine_id}/networks`, {
config: config,
});
@ -165,13 +120,8 @@ export class ApiClient {
await this.client.delete<string>(`/machines/${machine_id}/networks/${inst_id}`);
}
public async get_summary(): Promise<Summary> {
const response = await this.client.get<any, Summary>('/summary');
return response;
}
public captcha_url() {
return this.client.defaults.baseURL + '/auth/captcha';
return this.client.defaults.baseURL + 'auth/captcha';
}
}

View File

@ -22,5 +22,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "../frontend-lib/src/modules/api.ts"]
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

View File

@ -6,9 +6,8 @@ use easytier::{
rpc_impl::bidirect::BidirectRpcManager,
rpc_types::{self, controller::BaseController},
web::{
HeartbeatRequest, HeartbeatResponse, NetworkConfig, RunNetworkInstanceRequest,
WebClientService, WebClientServiceClientFactory, WebServerService,
WebServerServiceServer,
HeartbeatRequest, HeartbeatResponse, RunNetworkInstanceRequest, WebClientService,
WebClientServiceClientFactory, WebServerService, WebServerServiceServer,
},
},
tunnel::Tunnel,
@ -161,13 +160,7 @@ impl Session {
);
return;
}
let req = req.unwrap();
if req.machine_id.is_none() {
tracing::warn!(?req, "Machine id is not set, ignore");
continue;
}
let running_inst_ids = req
.running_network_instances
.iter()
@ -194,11 +187,7 @@ impl Session {
}
};
let local_configs = match storage
.db
.list_network_configs(user_id, Some(req.machine_id.unwrap().into()), true)
.await
{
let local_configs = match storage.db.list_network_configs(user_id, true).await {
Ok(configs) => configs,
Err(e) => {
tracing::error!("Failed to list network configs, error: {:?}", e);
@ -217,9 +206,7 @@ impl Session {
BaseController::default(),
RunNetworkInstanceRequest {
inst_id: Some(c.network_instance_id.clone().into()),
config: Some(
serde_json::from_str::<NetworkConfig>(&c.network_config).unwrap(),
),
config: c.network_config,
},
)
.await;

View File

@ -16,7 +16,7 @@ pub struct StorageToken {
pub struct StorageInner {
// some map for indexing
pub token_clients_map: DashMap<String, DashSet<url::Url>>,
pub machine_client_url_map: DashMap<uuid::Uuid, DashSet<url::Url>>,
pub machine_client_url_map: DashMap<uuid::Uuid, url::Url>,
pub db: Db,
}
@ -51,9 +51,7 @@ impl Storage {
self.0
.machine_client_url_map
.entry(stoken.machine_id)
.or_insert_with(DashSet::new)
.insert(stoken.client_url.clone());
.insert(stoken.machine_id, stoken.client_url.clone());
}
pub fn remove_client(&self, stoken: &StorageToken) {
@ -62,12 +60,7 @@ impl Storage {
set.is_empty()
});
self.0
.machine_client_url_map
.remove_if(&stoken.machine_id, |_, set| {
set.remove(&stoken.client_url);
set.is_empty()
});
self.0.machine_client_url_map.remove(&stoken.machine_id);
}
pub fn weak_ref(&self) -> WeakRefStorage {
@ -78,8 +71,7 @@ impl Storage {
self.0
.machine_client_url_map
.get(&machine_id)
.map(|url| url.iter().next().map(|url| url.clone()))
.flatten()
.map(|url| url.clone())
}
pub fn list_token_clients(&self, token: &str) -> Vec<url::Url> {

View File

@ -9,8 +9,6 @@ pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub user_id: i32,
#[sea_orm(column_type = "Text")]
pub device_id: String,
#[sea_orm(column_type = "Text", unique)]
pub network_instance_id: String,
#[sea_orm(column_type = "Text")]

View File

@ -65,7 +65,6 @@ impl Db {
pub async fn insert_or_update_user_network_config<T: ToString>(
&self,
user_id: UserIdInDb,
device_id: uuid::Uuid,
network_inst_id: uuid::Uuid,
network_config: T,
) -> Result<(), DbErr> {
@ -82,7 +81,6 @@ impl Db {
.to_owned();
let insert_m = urnc::ActiveModel {
user_id: sea_orm::Set(user_id),
device_id: sea_orm::Set(device_id.to_string()),
network_instance_id: sea_orm::Set(network_inst_id.to_string()),
network_config: sea_orm::Set(network_config.to_string()),
disabled: sea_orm::Set(false),
@ -118,7 +116,6 @@ impl Db {
pub async fn list_network_configs(
&self,
user_id: UserIdInDb,
device_id: Option<uuid::Uuid>,
only_enabled: bool,
) -> Result<Vec<user_running_network_configs::Model>, DbErr> {
use entity::user_running_network_configs as urnc;
@ -129,11 +126,6 @@ impl Db {
} else {
configs
};
let configs = if let Some(device_id) = device_id {
configs.filter(urnc::Column::DeviceId.eq(device_id.to_string()))
} else {
configs
};
let configs = configs.all(self.orm_db()).await?;
@ -175,9 +167,8 @@ mod tests {
let user_id = 1;
let network_config = "test_config";
let inst_id = uuid::Uuid::new_v4();
let device_id = uuid::Uuid::new_v4();
db.insert_or_update_user_network_config(user_id, device_id, inst_id, network_config)
db.insert_or_update_user_network_config(user_id, inst_id, network_config)
.await
.unwrap();
@ -192,7 +183,7 @@ mod tests {
// overwrite the config
let network_config = "test_config2";
db.insert_or_update_user_network_config(user_id, device_id, inst_id, network_config)
db.insert_or_update_user_network_config(user_id, inst_id, network_config)
.await
.unwrap();
@ -202,17 +193,14 @@ mod tests {
.await
.unwrap()
.unwrap();
println!("device: {}, {:?}", device_id, result2);
println!("{:?}", result2);
assert_eq!(result2.network_config, network_config);
assert_eq!(result.create_time, result2.create_time);
assert_ne!(result.update_time, result2.update_time);
assert_eq!(
db.list_network_configs(user_id, Some(device_id), true)
.await
.unwrap()
.len(),
db.list_network_configs(user_id, true).await.unwrap().len(),
1
);

View File

@ -4,6 +4,94 @@ use sea_orm_migration::{prelude::*, schema::*};
pub struct Migration;
/*
-- # Entity schema.
-- Create `users` table.
create table if not exists users (
id integer primary key autoincrement,
username text not null unique,
password text not null
);
-- Create `groups` table.
create table if not exists groups (
id integer primary key autoincrement,
name text not null unique
);
-- Create `permissions` table.
create table if not exists permissions (
id integer primary key autoincrement,
name text not null unique
);
-- # Join tables.
-- Create `users_groups` table for many-to-many relationships between users and groups.
create table if not exists users_groups (
user_id integer references users(id),
group_id integer references groups(id),
primary key (user_id, group_id)
);
-- Create `groups_permissions` table for many-to-many relationships between groups and permissions.
create table if not exists groups_permissions (
group_id integer references groups(id),
permission_id integer references permissions(id),
primary key (group_id, permission_id)
);
-- # Fixture hydration.
-- Insert "user" user. password: "user"
insert into users (username, password)
values (
'user',
'$argon2i$v=19$m=16,t=2,p=1$dHJ5dXZkYmZkYXM$UkrNqWz0BbSVBq4ykLSuJw'
);
-- Insert "admin" user. password: "admin"
insert into users (username, password)
values (
'admin',
'$argon2i$v=19$m=16,t=2,p=1$Ymd1Y2FlcnQ$x0q4oZinW9S1ZB9BcaHEpQ'
);
-- Insert "users" and "superusers" groups.
insert into groups (name) values ('users');
insert into groups (name) values ('superusers');
-- Insert individual permissions.
insert into permissions (name) values ('sessions');
insert into permissions (name) values ('devices');
-- Insert group permissions.
insert into groups_permissions (group_id, permission_id)
values (
(select id from groups where name = 'users'),
(select id from permissions where name = 'devices')
), (
(select id from groups where name = 'superusers'),
(select id from permissions where name = 'sessions')
);
-- Insert users into groups.
insert into users_groups (user_id, group_id)
values (
(select id from users where username = 'user'),
(select id from groups where name = 'users')
), (
(select id from users where username = 'admin'),
(select id from groups where name = 'users')
), (
(select id from users where username = 'admin'),
(select id from groups where name = 'superusers')
);
*/
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20241029_000001_init"
@ -53,7 +141,6 @@ enum UserRunningNetworkConfigs {
Table,
Id,
UserId,
DeviceId,
NetworkInstanceId,
NetworkConfig,
Disabled,
@ -186,7 +273,6 @@ impl MigrationTrait for Migration {
.table(UserRunningNetworkConfigs::Table)
.col(pk_auto(UserRunningNetworkConfigs::Id).not_null())
.col(integer(UserRunningNetworkConfigs::UserId).not_null())
.col(text(UserRunningNetworkConfigs::DeviceId).not_null())
.col(
text(UserRunningNetworkConfigs::NetworkInstanceId)
.unique_key()

View File

@ -22,10 +22,6 @@ pub struct LoginResult {
pub fn router() -> Router<AppStateInner> {
let r = Router::new()
.route("/api/v1/auth/password", put(self::put::change_password))
.route(
"/api/v1/auth/check_login_status",
get(self::get::check_login_status),
)
.route_layer(login_required!(Backend));
Router::new()
.merge(r)
@ -172,17 +168,4 @@ mod get {
)),
}
}
pub async fn check_login_status(
auth_session: AuthSession,
) -> Result<Json<Void>, HttpHandleError> {
if auth_session.user.is_some() {
Ok(Json(Void::default()))
} else {
Err((
StatusCode::UNAUTHORIZED,
Json::from(other_error("Not logged in")),
))
}
}
}

View File

@ -11,7 +11,7 @@ use axum_login::tower_sessions::{ExpiredDeletion, SessionManagerLayer};
use axum_login::{login_required, AuthManagerLayerBuilder, AuthzBackend};
use axum_messages::MessagesManagerLayer;
use easytier::common::scoped_task::ScopedTask;
use easytier::proto::rpc_types;
use easytier::proto::{rpc_types};
use network::NetworkApi;
use sea_orm::DbErr;
use tokio::net::TcpListener;
@ -43,11 +43,6 @@ type AppState = State<AppStateInner>;
#[derive(Debug, serde::Deserialize, serde::Serialize)]
struct ListSessionJsonResp(Vec<StorageToken>);
#[derive(Debug, serde::Deserialize, serde::Serialize)]
struct GetSummaryJsonResp {
device_count: u32,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Error {
message: String,
@ -103,32 +98,16 @@ impl RestfulServer {
auth_session: AuthSession,
State(client_mgr): AppState,
) -> Result<Json<ListSessionJsonResp>, HttpHandleError> {
let perms = auth_session
let pers = auth_session
.backend
.get_group_permissions(auth_session.user.as_ref().unwrap())
.await
.unwrap();
println!("{:?}", perms);
println!("{:?}", pers);
let ret = client_mgr.list_sessions().await;
Ok(ListSessionJsonResp(ret).into())
}
async fn handle_get_summary(
auth_session: AuthSession,
State(client_mgr): AppState,
) -> Result<Json<GetSummaryJsonResp>, HttpHandleError> {
let Some(user) = auth_session.user else {
return Err((StatusCode::UNAUTHORIZED, other_error("No such user").into()));
};
let machines = client_mgr.list_machine_by_token(user.tokens[0].clone());
Ok(GetSummaryJsonResp {
device_count: machines.len() as u32,
}
.into())
}
pub async fn start(&mut self) -> Result<(), anyhow::Error> {
let listener = TcpListener::bind(self.bind_addr).await?;
@ -164,7 +143,6 @@ impl RestfulServer {
let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build();
let app = Router::new()
.route("/api/v1/summary", get(Self::handle_get_summary))
.route("/api/v1/sessions", get(Self::handle_list_all_sessions))
.merge(self.network_api.build_route())
.route_layer(login_required!(Backend))

View File

@ -9,7 +9,7 @@ use dashmap::DashSet;
use easytier::launcher::NetworkConfig;
use easytier::proto::common::Void;
use easytier::proto::rpc_types::controller::BaseController;
use easytier::proto::web::*;
use easytier::proto::{web::*};
use crate::client_manager::session::Session;
use crate::client_manager::ClientManager;
@ -38,7 +38,7 @@ struct ValidateConfigJsonReq {
#[derive(Debug, serde::Deserialize, serde::Serialize)]
struct RunNetworkJsonReq {
config: NetworkConfig,
config: String,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
@ -145,7 +145,7 @@ impl NetworkApi {
BaseController::default(),
RunNetworkInstanceRequest {
inst_id: None,
config: Some(config.clone()),
config: config.clone(),
},
)
.await
@ -155,9 +155,8 @@ impl NetworkApi {
.db()
.insert_or_update_user_network_config(
auth_session.user.as_ref().unwrap().id(),
machine_id,
resp.inst_id.clone().unwrap_or_default().into(),
serde_json::to_string(&config).unwrap(),
config,
)
.await
.map_err(convert_db_error)?;
@ -289,36 +288,6 @@ impl NetworkApi {
Ok(Json(ListMachineJsonResp { machines }))
}
async fn handle_get_network_config(
auth_session: AuthSession,
State(client_mgr): AppState,
Path((machine_id, inst_id)): Path<(uuid::Uuid, uuid::Uuid)>,
) -> Result<Json<NetworkConfig>, HttpHandleError> {
let inst_id = inst_id.to_string();
let db_row = client_mgr
.db()
.list_network_configs(auth_session.user.unwrap().id(), Some(machine_id), false)
.await
.map_err(convert_db_error)?
.iter()
.find(|x| x.network_instance_id == inst_id)
.map(|x| x.network_config.clone())
.ok_or((
StatusCode::NOT_FOUND,
other_error(format!("No such network instance: {}", inst_id)).into(),
))?;
Ok(serde_json::from_str::<NetworkConfig>(&db_row)
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
other_error(format!("Failed to parse network config: {:?}", e)).into(),
)
})?
.into())
}
pub fn build_route(&mut self) -> Router<AppStateInner> {
Router::new()
.route("/api/v1/machines", get(Self::handle_list_machines))
@ -342,9 +311,5 @@ impl NetworkApi {
"/api/v1/machines/:machine-id/networks/info/:inst-id",
get(Self::handle_collect_one_network_info),
)
.route(
"/api/v1/machines/:machine-id/networks/config/:inst-id",
get(Self::handle_get_network_config),
)
}
}

View File

@ -120,7 +120,10 @@ core_clap:
ipv6_listener:
en: "the url of the ipv6 listener, e.g.: tcp://[::]:11010, if not set, will listen on random udp port"
zh-CN: "IPv6 监听器的URL例如tcp://[::]:11010如果未设置将在随机UDP端口上监听"
work_dir:
en: "Specify the working directory for the program. If not specified, the current directory will be used."
zh-CN: "指定程序的工作目录。如果未指定,将使用当前目录。"
core_app:
panic_backtrace_save:
en: "backtrace saved to easytier-panic.log"

View File

@ -25,8 +25,6 @@ define_global_var!(OSPF_UPDATE_MY_GLOBAL_FOREIGN_NETWORK_INTERVAL_SEC, u64, 10);
pub const UDP_HOLE_PUNCH_CONNECTOR_SERVICE_ID: u32 = 2;
pub const WIN_SERVICE_WORK_DIR_REG_KEY: &str = "SOFTWARE\\EasyTier\\Service\\WorkDir";
pub const EASYTIER_VERSION: &str = git_version::git_version!(
args = ["--abbrev=8", "--always", "--dirty=~"],
prefix = concat!(env!("CARGO_PKG_VERSION"), "-"),

View File

@ -1,13 +1,18 @@
use std::{
ffi::OsString, fmt::Write, net::SocketAddr, path::PathBuf, sync::Mutex, time::Duration, vec,
ffi::OsString,
net::SocketAddr,
path::PathBuf,
sync::Mutex,
time::Duration,
vec
};
use anyhow::{Context, Ok};
use clap::{command, Args, Parser, Subcommand};
use humansize::format_size;
use service_manager::*;
use tabled::settings::Style;
use tokio::time::timeout;
use service_manager::*;
use easytier::{
common::{
@ -29,7 +34,7 @@ use easytier::{
rpc_types::controller::BaseController,
},
tunnel::tcp::TcpTunnelConnector,
utils::{cost_to_str, float_to_str, PeerRoutePair},
utils::{cost_to_str, float_to_str, PeerRoutePair},
};
rust_i18n::i18n!("locales", fallback = "en");
@ -57,7 +62,7 @@ enum SubCommand {
PeerCenter,
VpnPortal,
Node(NodeArgs),
Service(ServiceArgs),
Service(ServiceArgs)
}
#[derive(Args, Debug)]
@ -78,7 +83,7 @@ enum PeerSubCommand {
Remove,
List(PeerListArgs),
ListForeign,
ListGlobalForeign,
ListGlobalForeign,
}
#[derive(Args, Debug)]
@ -125,12 +130,9 @@ struct NodeArgs {
}
#[derive(Args, Debug)]
struct ServiceArgs {
#[arg(short, long, default_value = env!("CARGO_PKG_NAME"), help = "service name")]
name: String,
struct ServiceArgs{
#[command(subcommand)]
sub_command: ServiceSubCommand,
sub_command: ServiceSubCommand
}
#[derive(Subcommand, Debug)]
@ -139,28 +141,15 @@ enum ServiceSubCommand {
Uninstall,
Status,
Start,
Stop,
Stop
}
#[derive(Args, Debug)]
struct InstallArgs {
#[arg(long, default_value = env!("CARGO_PKG_DESCRIPTION"), help = "service description")]
description: String,
#[arg(long)]
display_name: Option<String>,
#[arg(long, default_value = "false")]
disable_autostart: bool,
#[arg(long)]
core_path: Option<PathBuf>,
#[arg(long)]
service_work_dir: Option<PathBuf>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
core_args: Option<Vec<OsString>>,
core_args: Option<Vec<OsString>>
}
type Error = anyhow::Error;
@ -519,54 +508,48 @@ impl CommandHandler {
}
}
pub struct ServiceInstallOptions {
pub program: PathBuf,
pub args: Vec<OsString>,
pub work_directory: PathBuf,
pub disable_autostart: bool,
pub description: Option<String>,
pub display_name: Option<String>,
}
pub struct Service {
pub struct Service{
lable: ServiceLabel,
kind: ServiceManagerKind,
service_manager: Box<dyn ServiceManager>,
service_manager: Box<dyn ServiceManager>
}
impl Service {
pub fn new(name: String) -> Result<Self, Error> {
pub fn new() -> Result<Self, Error> {
#[cfg(target_os = "windows")]
let service_manager = Box::new(crate::win_service_manager::WinServiceManager::new()?);
let service_manager = Box::new(
crate::win_service_manager::WinServiceManager::new(
Some(OsString::from("EasyTier Service")),
Some(OsString::from(env!("CARGO_PKG_DESCRIPTION"))),
vec![OsString::from("dnscache"), OsString::from("rpcss")],
)?
);
#[cfg(not(target_os = "windows"))]
let service_manager = <dyn ServiceManager>::native()?;
let kind = ServiceManagerKind::native()?;
Ok(Self {
lable: name.parse()?,
kind,
service_manager,
lable: env!("CARGO_PKG_NAME").parse().unwrap(),
service_manager
})
}
pub fn install(&self, options: &ServiceInstallOptions) -> Result<(), Error> {
pub fn install(&self, bin_path: std::path::PathBuf, bin_args: Vec<OsString>) -> Result<(), Error> {
let ctx = ServiceInstallCtx {
label: self.lable.clone(),
program: options.program.clone(),
args: options.args.clone(),
contents: self.make_install_content_option(options),
autostart: !options.disable_autostart,
username: None,
working_directory: Some(options.work_directory.clone()),
contents: None,
program: bin_path,
args: bin_args,
autostart: true,
username: None,
working_directory: None,
environment: None,
};
if self.status()? != ServiceStatus::NotInstalled {
return Err(anyhow::anyhow!("Service is already installed"));
}
self.service_manager
.install(ctx)
.map_err(|e| anyhow::anyhow!("failed to install service: {}", e))
self.service_manager.install(ctx).map_err(|e| anyhow::anyhow!("failed to install service: {}", e))
}
pub fn uninstall(&self) -> Result<(), Error> {
@ -585,9 +568,7 @@ impl Service {
})?;
}
self.service_manager
.uninstall(ctx)
.map_err(|e| anyhow::anyhow!("failed to uninstall service: {}", e))
self.service_manager.uninstall(ctx).map_err(|e| anyhow::anyhow!("failed to uninstall service: {}", e))
}
pub fn status(&self) -> Result<ServiceStatus, Error> {
@ -606,14 +587,16 @@ impl Service {
let status = self.status()?;
match status {
ServiceStatus::Running => Err(anyhow::anyhow!("Service is already running")),
ServiceStatus::Running => {
Err(anyhow::anyhow!("Service is already running"))
}
ServiceStatus::Stopped(_) => {
self.service_manager
.start(ctx)
.map_err(|e| anyhow::anyhow!("failed to start service: {}", e))?;
self.service_manager.start(ctx).map_err(|e| anyhow::anyhow!("failed to start service: {}", e))?;
Ok(())
}
ServiceStatus::NotInstalled => Err(anyhow::anyhow!("Service is not installed")),
ServiceStatus::NotInstalled => {
Err(anyhow::anyhow!("Service is not installed"))
}
}
}
@ -625,149 +608,18 @@ impl Service {
match status {
ServiceStatus::Running => {
self.service_manager
.stop(ctx)
.map_err(|e| anyhow::anyhow!("failed to stop service: {}", e))?;
self.service_manager.stop(ctx).map_err(|e| anyhow::anyhow!("failed to stop service: {}", e))?;
Ok(())
}
ServiceStatus::Stopped(_) => Err(anyhow::anyhow!("Service is already stopped")),
ServiceStatus::NotInstalled => Err(anyhow::anyhow!("Service is not installed")),
}
}
fn make_install_content_option(&self, options: &ServiceInstallOptions) -> Option<String> {
match self.kind {
ServiceManagerKind::Systemd => Some(self.make_systemd_unit(options).unwrap()),
ServiceManagerKind::Rcd => Some(self.make_rcd_script(options).unwrap()),
ServiceManagerKind::OpenRc => Some(self.make_open_rc_script(options).unwrap()),
_ => {
#[cfg(target_os = "windows")]
{
let win_options = win_service_manager::WinServiceInstallOptions {
description: options.description.clone(),
display_name: options.display_name.clone(),
dependencies: Some(vec!["rpcss".to_string(), "dnscache".to_string()]),
};
Some(serde_json::to_string(&win_options).unwrap())
}
#[cfg(not(target_os = "windows"))]
None
ServiceStatus::Stopped(_) => {
Err(anyhow::anyhow!("Service is already stopped"))
}
ServiceStatus::NotInstalled => {
Err(anyhow::anyhow!("Service is not installed"))
}
}
}
fn make_systemd_unit(
&self,
options: &ServiceInstallOptions,
) -> Result<String, std::fmt::Error> {
let args = options
.args
.iter()
.map(|a| a.to_string_lossy())
.collect::<Vec<_>>()
.join(" ");
let target_app = options.program.display().to_string();
let work_dir = options.work_directory.display().to_string();
let mut unit_content = String::new();
writeln!(unit_content, "[Unit]")?;
writeln!(unit_content, "After=network.target syslog.target")?;
if let Some(ref d) = options.description {
writeln!(unit_content, "Description={d}")?;
}
writeln!(unit_content, "StartLimitIntervalSec=0")?;
writeln!(unit_content)?;
writeln!(unit_content, "[Service]")?;
writeln!(unit_content, "Type=simple")?;
writeln!(unit_content, "WorkingDirectory={work_dir}")?;
writeln!(unit_content, "ExecStart={target_app} {args}")?;
writeln!(unit_content, "Restart=Always")?;
writeln!(unit_content, "LimitNOFILE=infinity")?;
writeln!(unit_content)?;
writeln!(unit_content, "[Install]")?;
writeln!(unit_content, "WantedBy=multi-user.target")?;
std::result::Result::Ok(unit_content)
}
fn make_rcd_script(&self, options: &ServiceInstallOptions) -> Result<String, std::fmt::Error> {
let name = self.lable.to_qualified_name();
let args = options
.args
.iter()
.map(|a| a.to_string_lossy())
.collect::<Vec<_>>()
.join(" ");
let target_app = options.program.display().to_string();
let work_dir = options.work_directory.display().to_string();
let mut script = String::new();
writeln!(script, "#!/bin/sh")?;
writeln!(script, "#")?;
writeln!(script, "# PROVIDE: {name}")?;
writeln!(script, "# REQUIRE: LOGIN FILESYSTEMS NETWORKING ")?;
writeln!(script, "# KEYWORD: shutdown")?;
writeln!(script)?;
writeln!(script, ". /etc/rc.subr")?;
writeln!(script)?;
writeln!(script, "name=\"{name}\"")?;
if let Some(ref d) = options.description {
writeln!(script, "desc=\"{d}\"")?;
}
writeln!(script, "rcvar=\"{name}_enable\"")?;
writeln!(script)?;
writeln!(script, "load_rc_config ${{name}}")?;
writeln!(script)?;
writeln!(script, ": ${{{name}_options=\"{args}\"}}")?;
writeln!(script)?;
writeln!(script, "{name}_chdir=\"{work_dir}\"")?;
writeln!(script, "pidfile=\"/var/run/${{name}}.pid\"")?;
writeln!(script, "procname=\"{target_app}\"")?;
writeln!(script, "command=\"/usr/sbin/daemon\"")?;
writeln!(
script,
"command_args=\"-c -S -T ${{name}} -p ${{pidfile}} ${{procname}} ${{{name}_options}}\""
)?;
writeln!(script)?;
writeln!(script, "run_rc_command \"$1\"")?;
std::result::Result::Ok(script)
}
fn make_open_rc_script(
&self,
options: &ServiceInstallOptions,
) -> Result<String, std::fmt::Error> {
let args = options
.args
.iter()
.map(|a| a.to_string_lossy())
.collect::<Vec<_>>()
.join(" ");
let target_app = options.program.display().to_string();
let work_dir = options.work_directory.display().to_string();
let mut script = String::new();
writeln!(script, "#!/sbin/openrc-run")?;
writeln!(script)?;
if let Some(ref d) = options.description {
writeln!(script, "description=\"{d}\"")?;
}
writeln!(script, "command=\"{target_app}\"")?;
writeln!(script, "command_args=\"{args}\"")?;
writeln!(script, "pidfile=\"/run/${{RC_SVCNAME}}.pid\"")?;
writeln!(script, "command_background=\"yes\"")?;
writeln!(script, "directory=\"{work_dir}\"")?;
writeln!(script)?;
writeln!(script, "depend() {{")?;
writeln!(script, " need net")?;
writeln!(script, " use looger")?;
writeln!(script, "}}")?;
std::result::Result::Ok(script)
}
}
#[tokio::main]
@ -933,9 +785,9 @@ async fn main() -> Result<(), Error> {
}
}
SubCommand::Service(service_args) => {
let service = Service::new(service_args.name)?;
let service = Service::new()?;
match service_args.sub_command {
ServiceSubCommand::Install(install_args) => {
ServiceSubCommand::Install(install_args) => {
let bin_path = install_args.core_path.unwrap_or_else(|| {
let mut ret = std::env::current_exe()
.unwrap()
@ -953,35 +805,7 @@ async fn main() -> Result<(), Error> {
anyhow::anyhow!("failed to get easytier core application: {}", e)
})?;
let bin_args = install_args.core_args.unwrap_or_default();
let work_dir = install_args.service_work_dir.unwrap_or_else(|| {
if cfg!(target_os = "windows") {
bin_path.parent().unwrap().to_path_buf()
} else {
std::env::temp_dir()
}
});
let work_dir = std::fs::canonicalize(&work_dir).map_err(|e| {
anyhow::anyhow!(
"failed to get service work directory[{}]: {}",
work_dir.display(),
e
)
})?;
if !work_dir.is_dir() {
return Err(anyhow::anyhow!("work directory is not a directory"));
}
let install_options = ServiceInstallOptions {
program: bin_path,
args: bin_args,
work_directory: work_dir,
disable_autostart: install_args.disable_autostart,
description: Some(install_args.description),
display_name: install_args.display_name,
};
service.install(&install_options)?;
service.install(bin_path, bin_args)?;
}
ServiceSubCommand::Uninstall => {
service.uninstall()?;
@ -1009,76 +833,67 @@ async fn main() -> Result<(), Error> {
#[cfg(target_os = "windows")]
mod win_service_manager {
use std::{ffi::OsStr, ffi::OsString, io, path::PathBuf};
use windows_service::{
service::{
ServiceAccess, ServiceDependency, ServiceErrorControl, ServiceInfo, ServiceStartType,
ServiceType,
ServiceErrorControl,
ServiceDependency,
ServiceInfo,
ServiceStartType,
ServiceAccess
},
service_manager::{ServiceManager, ServiceManagerAccess},
service_manager::{
ServiceManagerAccess,
ServiceManager
}
};
use std::{
io,
ffi::OsString,
ffi::OsStr
};
use service_manager::{
ServiceInstallCtx, ServiceLevel, ServiceStartCtx, ServiceStatus, ServiceStatusCtx,
ServiceStopCtx, ServiceUninstallCtx,
ServiceInstallCtx,
ServiceLevel,
ServiceStartCtx,
ServiceStatus,
ServiceStatusCtx,
ServiceUninstallCtx,
ServiceStopCtx
};
use winreg::{enums::*, RegKey};
use easytier::common::constants::WIN_SERVICE_WORK_DIR_REG_KEY;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct WinServiceInstallOptions {
pub dependencies: Option<Vec<String>>,
pub description: Option<String>,
pub display_name: Option<String>,
}
pub struct WinServiceManager {
service_manager: ServiceManager,
display_name: Option<OsString>,
description: Option<OsString>,
dependencies: Vec<OsString>
}
impl WinServiceManager {
pub fn new() -> Result<Self, crate::Error> {
let service_manager =
ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::ALL_ACCESS)?;
Ok(Self { service_manager })
pub fn new(display_name: Option<OsString>, description: Option<OsString>, dependencies: Vec<OsString>,) -> Result<Self, crate::Error> {
let service_manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::ALL_ACCESS,
)?;
Ok(Self {
service_manager,
display_name,
description,
dependencies,
})
}
}
impl service_manager::ServiceManager for WinServiceManager {
fn available(&self) -> io::Result<bool> {
Ok(true)
}
fn install(&self, ctx: ServiceInstallCtx) -> io::Result<()> {
let start_type_ = if ctx.autostart {
ServiceStartType::AutoStart
} else {
ServiceStartType::OnDemand
};
let start_type_ = if ctx.autostart { ServiceStartType::AutoStart } else { ServiceStartType::OnDemand };
let srv_name = OsString::from(ctx.label.to_qualified_name());
let mut dis_name = srv_name.clone();
let mut description: Option<OsString> = None;
let mut dependencies = Vec::<ServiceDependency>::new();
if let Some(s) = ctx.contents.as_ref() {
let options: WinServiceInstallOptions = serde_json::from_str(s.as_str()).unwrap();
if let Some(d) = options.dependencies {
dependencies = d
.iter()
.map(|dep| ServiceDependency::Service(OsString::from(dep.clone())))
.collect::<Vec<_>>();
}
if let Some(d) = options.description {
description = Some(OsString::from(d));
}
if let Some(d) = options.display_name {
dis_name = OsString::from(d);
}
}
let dis_name = self.display_name.clone().unwrap_or_else(|| srv_name.clone());
let dependencies = self.dependencies.iter().map(|dep| ServiceDependency::Service(dep.clone())).collect::<Vec<_>>();
let service_info = ServiceInfo {
name: srv_name,
display_name: dis_name,
@ -1089,81 +904,67 @@ mod win_service_manager {
launch_arguments: ctx.args,
dependencies: dependencies.clone(),
account_name: None,
account_password: None,
account_password: None
};
let service = self
.service_manager
.create_service(&service_info, ServiceAccess::ALL_ACCESS)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
if let Some(s) = description {
service
.set_description(s.clone())
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
let service = self.service_manager.create_service(&service_info, ServiceAccess::ALL_ACCESS).map_err(|e| {
io::Error::new(io::ErrorKind::Other, e)
})?;
if let Some(s) = &self.description {
service.set_description(s.clone()).map_err(|e| {
io::Error::new(io::ErrorKind::Other, e)
})?;
}
if let Some(work_dir) = ctx.working_directory {
set_service_work_directory(&ctx.label.to_qualified_name(), work_dir)?;
}
Ok(())
}
fn uninstall(&self, ctx: ServiceUninstallCtx) -> io::Result<()> {
let service = self
.service_manager
.open_service(ctx.label.to_qualified_name(), ServiceAccess::ALL_ACCESS)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
service
.delete()
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))
let service = self.service_manager.open_service(ctx.label.to_qualified_name(), ServiceAccess::ALL_ACCESS).map_err(|e|{
io::Error::new(io::ErrorKind::Other, e)
})?;
service.delete().map_err(|e|{
io::Error::new(io::ErrorKind::Other, e)
})
}
fn start(&self, ctx: ServiceStartCtx) -> io::Result<()> {
let service = self
.service_manager
.open_service(ctx.label.to_qualified_name(), ServiceAccess::ALL_ACCESS)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
service
.start(&[] as &[&OsStr])
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))
let service = self.service_manager.open_service(ctx.label.to_qualified_name(), ServiceAccess::ALL_ACCESS).map_err(|e|{
io::Error::new(io::ErrorKind::Other, e)
})?;
service.start(&[] as &[&OsStr]).map_err(|e|{
io::Error::new(io::ErrorKind::Other, e)
})
}
fn stop(&self, ctx: ServiceStopCtx) -> io::Result<()> {
let service = self
.service_manager
.open_service(ctx.label.to_qualified_name(), ServiceAccess::ALL_ACCESS)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
_ = service
.stop()
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
let service = self.service_manager.open_service(ctx.label.to_qualified_name(), ServiceAccess::ALL_ACCESS).map_err(|e|{
io::Error::new(io::ErrorKind::Other, e)
})?;
_ = service.stop().map_err(|e|{
io::Error::new(io::ErrorKind::Other, e)
})?;
Ok(())
}
fn level(&self) -> ServiceLevel {
ServiceLevel::System
}
fn set_level(&mut self, level: ServiceLevel) -> io::Result<()> {
match level {
ServiceLevel::System => Ok(()),
_ => Err(io::Error::new(
io::ErrorKind::Other,
"Unsupported service level",
)),
_ => Err(io::Error::new(io::ErrorKind::Other, "Unsupported service level"))
}
}
fn status(&self, ctx: ServiceStatusCtx) -> io::Result<ServiceStatus> {
let service = match self
.service_manager
.open_service(ctx.label.to_qualified_name(), ServiceAccess::QUERY_STATUS)
{
let service = match self.service_manager.open_service(ctx.label.to_qualified_name(), ServiceAccess::QUERY_STATUS) {
Ok(s) => s,
Err(e) => {
if let windows_service::Error::Winapi(ref win_err) = e {
@ -1174,23 +975,15 @@ mod win_service_manager {
return Err(io::Error::new(io::ErrorKind::Other, e));
}
};
let status = service
.query_status()
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
let status = service.query_status().map_err(|e|{
io::Error::new(io::ErrorKind::Other, e)
})?;
match status.current_state {
windows_service::service::ServiceState::Stopped => Ok(ServiceStatus::Stopped(None)),
_ => Ok(ServiceStatus::Running),
}
}
}
fn set_service_work_directory(service_name: &str, work_directory: PathBuf) -> io::Result<()> {
let (reg_key, _) =
RegKey::predef(HKEY_LOCAL_MACHINE).create_subkey(WIN_SERVICE_WORK_DIR_REG_KEY)?;
reg_key
.set_value::<OsString, _>(service_name, &work_directory.as_os_str().to_os_string())?;
Ok(())
}
}
}

View File

@ -289,6 +289,12 @@ struct Cli {
help = t!("core_clap.ipv6_listener").to_string()
)]
ipv6_listener: Option<String>,
#[arg(
long,
help = t!("core_clap.work_dir").to_string()
)]
work_dir: Option<String>,
}
rust_i18n::i18n!("locales", fallback = "en");
@ -357,7 +363,7 @@ impl Cli {
}
return Ok("0.0.0.0:0".parse().unwrap());
}
return Ok(format!("0.0.0.0:{}", port).parse().unwrap());
return Ok("0.0.0.0:0".parse().unwrap());
}
Ok(rpc_portal.parse()?)
@ -374,32 +380,28 @@ impl TryFrom<&Cli> for TomlConfigLoader {
config_file
);
return Ok(TomlConfigLoader::new(config_file)
.with_context(|| format!("failed to load config file: {:?}", cli.config_file))?);
.with_context(|| format!("failed to load config file: {:?}", cli.config_file))?)
}
let cfg = TomlConfigLoader::default();
cfg.set_hostname(cli.hostname.clone());
cfg.set_network_identity(NetworkIdentity::new(
cli.network_name.clone(),
cli.network_secret.clone(),
));
cfg.set_network_identity(NetworkIdentity::new(cli.network_name.clone(), cli.network_secret.clone()));
cfg.set_dhcp(cli.dhcp);
if let Some(ipv4) = &cli.ipv4 {
cfg.set_ipv4(Some(ipv4.parse().with_context(|| {
format!("failed to parse ipv4 address: {}", ipv4)
})?))
cfg.set_ipv4(Some(
ipv4.parse()
.with_context(|| format!("failed to parse ipv4 address: {}", ipv4))?
))
}
let mut peers = Vec::<PeerConfig>::with_capacity(cli.peers.len());
for p in &cli.peers {
peers.push(PeerConfig {
uri: p
.parse()
.with_context(|| format!("failed to parse peer uri: {}", p))?,
uri: p.parse().with_context(|| format!("failed to parse peer uri: {}", p))?,
});
}
cfg.set_peers(peers);
@ -414,21 +416,22 @@ impl TryFrom<&Cli> for TomlConfigLoader {
for n in cli.proxy_networks.iter() {
cfg.add_proxy_cidr(
n.parse()
.with_context(|| format!("failed to parse proxy network: {}", n))?,
.with_context(|| format!("failed to parse proxy network: {}", n))?
);
}
cfg.set_rpc_portal(
Cli::parse_rpc_portal(cli.rpc_portal.clone())
.with_context(|| format!("failed to parse rpc portal: {}", cli.rpc_portal))?,
);
cfg.set_rpc_portal(Cli::parse_rpc_portal(cli.rpc_portal.clone()).with_context(|| {
format!("failed to parse rpc portal: {}", cli.rpc_portal)
})?);
if let Some(external_nodes) = cli.external_node.as_ref() {
let mut old_peers = cfg.get_peers();
old_peers.push(PeerConfig {
uri: external_nodes.parse().with_context(|| {
format!("failed to parse external node uri: {}", external_nodes)
})?,
uri: external_nodes
.parse()
.with_context(|| {
format!("failed to parse external node uri: {}", external_nodes)
})?
});
cfg.set_peers(old_peers);
}
@ -453,15 +456,11 @@ impl TryFrom<&Cli> for TomlConfigLoader {
let url: url::Url = vpn_portal
.parse()
.with_context(|| format!("failed to parse vpn portal url: {}", vpn_portal))?;
let host = url
.host_str()
.ok_or_else(|| anyhow::anyhow!("vpn portal url missing host"))?;
let port = url
.port()
.ok_or_else(|| anyhow::anyhow!("vpn portal url missing port"))?;
let client_cidr = url.path()[1..].parse().with_context(|| {
format!("failed to parse vpn portal client cidr: {}", url.path())
})?;
let host = url.host_str().ok_or_else(|| anyhow::anyhow!("vpn portal url missing host"))?;
let port = url.port().ok_or_else(|| anyhow::anyhow!("vpn portal url missing port"))?;
let client_cidr = url.path()[1..]
.parse()
.with_context(|| format!("failed to parse vpn portal client cidr: {}", url.path()))?;
let wireguard_listen: SocketAddr = format!("{}:{}", host, port).parse().unwrap();
cfg.set_vpn_portal_config(VpnPortalConfig {
wireguard_listen,
@ -471,11 +470,8 @@ impl TryFrom<&Cli> for TomlConfigLoader {
if let Some(manual_routes) = cli.manual_routes.as_ref() {
let mut routes = Vec::<cidr::Ipv4Cidr>::with_capacity(manual_routes.len());
for r in manual_routes {
routes.push(
r.parse()
.with_context(|| format!("failed to parse route: {}", r))?,
);
for r in manual_routes{
routes.push(r.parse().with_context(|| format!("failed to parse route: {}", r))?);
}
cfg.set_routes(Some(routes));
}
@ -644,30 +640,14 @@ pub fn handle_event(mut events: EventBusSubscriber) -> tokio::task::JoinHandle<(
})
}
#[cfg(target_os = "windows")]
fn win_service_set_work_dir(service_name: &std::ffi::OsString) -> anyhow::Result<()> {
use easytier::common::constants::WIN_SERVICE_WORK_DIR_REG_KEY;
use winreg::enums::*;
use winreg::RegKey;
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
let key = hklm.open_subkey_with_flags(WIN_SERVICE_WORK_DIR_REG_KEY, KEY_READ)?;
let dir_pat_str = key.get_value::<std::ffi::OsString, _>(service_name)?;
let dir_path = std::fs::canonicalize(dir_pat_str)?;
std::env::set_current_dir(dir_path)?;
Ok(())
}
#[cfg(target_os = "windows")]
fn win_service_event_loop(
stop_notify: std::sync::Arc<tokio::sync::Notify>,
cli: Cli,
status_handle: windows_service::service_control_handler::ServiceStatusHandle,
) {
use std::time::Duration;
status_handle: windows_service::service_control_handler::ServiceStatusHandle,
) {
use tokio::runtime::Runtime;
use std::time::Duration;
use windows_service::service::*;
let normal_status = ServiceStatus {
@ -689,6 +669,12 @@ fn win_service_event_loop(
process_id: None,
};
if cli.work_dir == None {
let mut path = std::env::current_exe().unwrap();
path.pop();
std::env::set_current_dir(path).unwrap();
}
std::thread::spawn(move || {
let rt = Runtime::new().unwrap();
rt.block_on(async move {
@ -715,15 +701,13 @@ fn win_service_event_loop(
}
#[cfg(target_os = "windows")]
fn win_service_main(arg: Vec<std::ffi::OsString>) {
fn win_service_main(_: Vec<std::ffi::OsString>) {
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Notify;
use windows_service::service::*;
use windows_service::service_control_handler::*;
_ = win_service_set_work_dir(&arg[0]);
let cli = Cli::parse();
let stop_notify_send = Arc::new(Notify::new());
@ -747,17 +731,16 @@ fn win_service_main(arg: Vec<std::ffi::OsString>) {
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
};
status_handle
.set_service_status(next_status)
.expect("set service status fail");
};
status_handle.set_service_status(next_status).expect("set service status fail");
win_service_event_loop(stop_notify_recv, cli, status_handle);
}
async fn run_main(cli: Cli) -> anyhow::Result<()> {
let cfg = TomlConfigLoader::try_from(&cli)?;
init_logger(&cfg, false)?;
if let Some(dir) = cli.work_dir.as_ref() {
std::env::set_current_dir(dir).map_err(|e| anyhow::anyhow!("failed to set work dir: {}", e))?;
}
if cli.config_server.is_some() {
let config_server_url_s = cli.config_server.clone().unwrap();
@ -793,6 +776,9 @@ async fn run_main(cli: Cli) -> anyhow::Result<()> {
return Ok(());
}
let cfg = TomlConfigLoader::try_from(&cli)?;
init_logger(&cfg, false)?;
println!("Starting easytier with config:");
println!("############### TOML ###############\n");
println!("{}", cfg.dump());
@ -800,8 +786,8 @@ async fn run_main(cli: Cli) -> anyhow::Result<()> {
let mut l = launcher::NetworkInstance::new(cfg).set_fetch_node_info(false);
let _t = ScopedTask::from(handle_event(l.start().unwrap()));
if let Some(e) = l.wait().await {
anyhow::bail!("launcher error: {}", e);
if let Some(e) = l.wait().await{
return Err(anyhow::anyhow!("launcher error: {}", e));
}
Ok(())
}
@ -815,23 +801,22 @@ async fn main() {
#[cfg(target_os = "windows")]
match windows_service::service_dispatcher::start(String::new(), ffi_service_main) {
Ok(_) => std::thread::park(),
Err(e) => {
let should_panic = if let windows_service::Error::Winapi(ref io_error) = e {
io_error.raw_os_error() != Some(0x427) // ERROR_FAILED_SERVICE_CONTROLLER_CONNECT
} else {
true
};
if should_panic {
panic!("SCM start an error: {}", e);
}
}
};
Err(e) =>
{
let should_panic = if let windows_service::Error::Winapi(ref io_error) = e {
io_error.raw_os_error() != Some(0x427) // ERROR_FAILED_SERVICE_CONTROLLER_CONNECT
} else { true };
if should_panic {
panic!("SCM start an error: {}", e);
}
}
};
let cli = Cli::parse();
if let Err(e) = run_main(cli).await {
eprintln!("error: {:?}", e);
std::process::exit(1);
}
}
}

View File

@ -23,15 +23,9 @@ use tokio::{sync::broadcast, task::JoinSet};
pub type MyNodeInfo = crate::proto::web::MyNodeInfo;
#[derive(serde::Serialize, Clone)]
pub struct Event {
time: DateTime<Local>,
event: GlobalCtxEvent,
}
struct EasyTierData {
events: RwLock<VecDeque<Event>>,
my_node_info: RwLock<MyNodeInfo>,
events: RwLock<VecDeque<(DateTime<Local>, GlobalCtxEvent)>>,
node_info: RwLock<MyNodeInfo>,
routes: RwLock<Vec<Route>>,
peers: RwLock<Vec<PeerInfo>>,
tun_fd: Arc<RwLock<Option<i32>>>,
@ -46,7 +40,7 @@ impl Default for EasyTierData {
Self {
event_subscriber: RwLock::new(tx),
events: RwLock::new(VecDeque::new()),
my_node_info: RwLock::new(MyNodeInfo::default()),
node_info: RwLock::new(MyNodeInfo::default()),
routes: RwLock::new(Vec::new()),
peers: RwLock::new(Vec::new()),
tun_fd: Arc::new(RwLock::new(None)),
@ -85,12 +79,9 @@ impl EasyTierLauncher {
async fn handle_easytier_event(event: GlobalCtxEvent, data: &EasyTierData) {
let mut events = data.events.write().unwrap();
let _ = data.event_subscriber.read().unwrap().send(event.clone());
events.push_front(Event {
time: chrono::Local::now(),
event: event,
});
if events.len() > 20 {
events.pop_back();
events.push_back((chrono::Local::now(), event));
if events.len() > 100 {
events.pop_front();
}
}
@ -162,7 +153,7 @@ impl EasyTierLauncher {
global_ctx_c.get_flags().dev_name.clone();
let node_info = MyNodeInfo {
virtual_ipv4: global_ctx_c.get_ipv4().map(|ip| ip.into()),
virtual_ipv4: global_ctx_c.get_ipv4().map(|x| x.address().into()),
hostname: global_ctx_c.get_hostname(),
version: EASYTIER_VERSION.to_string(),
ips: Some(global_ctx_c.get_ip_collector().collect_ip_addrs().await),
@ -180,7 +171,7 @@ impl EasyTierLauncher {
.await,
),
};
*data_c.my_node_info.write().unwrap() = node_info.clone();
*data_c.node_info.write().unwrap() = node_info.clone();
*data_c.routes.write().unwrap() = peer_mgr_c.list_routes().await;
*data_c.peers.write().unwrap() = PeerManagerRpcService::new(peer_mgr_c.clone())
.list_peers()
@ -276,13 +267,13 @@ impl EasyTierLauncher {
self.data.tun_dev_name.read().unwrap().clone()
}
pub fn get_events(&self) -> Vec<Event> {
pub fn get_events(&self) -> Vec<(DateTime<Local>, GlobalCtxEvent)> {
let events = self.data.events.read().unwrap();
events.iter().cloned().collect()
}
pub fn get_node_info(&self) -> MyNodeInfo {
self.data.my_node_info.read().unwrap().clone()
self.data.node_info.read().unwrap().clone()
}
pub fn get_routes(&self) -> Vec<Route> {
@ -350,8 +341,9 @@ impl NetworkInstance {
events: launcher
.get_events()
.iter()
.map(|e| serde_json::to_string(e).unwrap())
.map(|(t, e)| (t.to_string(), format!("{:?}", e)))
.collect(),
node_info: Some(launcher.get_node_info()),
routes,
peers,
peer_route_pairs,

View File

@ -43,7 +43,7 @@ message NetworkConfig {
}
message MyNodeInfo {
common.Ipv4Inet virtual_ipv4 = 1;
common.Ipv4Addr virtual_ipv4 = 1;
string hostname = 2;
string version = 3;
peer_rpc.GetIpListResponse ips = 4;
@ -55,12 +55,13 @@ message MyNodeInfo {
message NetworkInstanceRunningInfo {
string dev_name = 1;
MyNodeInfo my_node_info = 2;
repeated string events = 3;
repeated cli.Route routes = 4;
repeated cli.PeerInfo peers = 5;
repeated cli.PeerRoutePair peer_route_pairs = 6;
bool running = 7;
optional string error_msg = 8;
map<string, string> events = 3;
MyNodeInfo node_info = 4;
repeated cli.Route routes = 5;
repeated cli.PeerInfo peers = 6;
repeated cli.PeerRoutePair peer_route_pairs = 7;
bool running = 8;
optional string error_msg = 9;
}
message NetworkInstanceRunningInfoMap {
@ -96,7 +97,7 @@ message ValidateConfigResponse {
message RunNetworkInstanceRequest {
common.UUID inst_id = 1;
NetworkConfig config = 2;
string config = 2;
}
message RunNetworkInstanceResponse {

View File

@ -100,10 +100,7 @@ impl WebClientService for Controller {
_: BaseController,
req: RunNetworkInstanceRequest,
) -> Result<RunNetworkInstanceResponse, rpc_types::error::Error> {
if req.config.is_none() {
return Err(anyhow::anyhow!("config is required").into());
}
let cfg = req.config.unwrap().gen_config()?;
let cfg = TomlConfigLoader::new_from_str(&req.config)?;
let id = cfg.get_id();
if let Some(inst_id) = req.inst_id {
cfg.set_id(inst_id.into());

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@ Default permissions for the plugin
- `allow-ping`
- `allow-start-vpn`
## Permission Table
### Permission Table
<table>
<tr>

View File

@ -295,59 +295,81 @@
"type": "string",
"oneOf": [
{
"description": "Enables the ping command without any pre-configured scope.",
"description": "allow-ping -> Enables the ping command without any pre-configured scope.",
"type": "string",
"const": "allow-ping"
"enum": [
"allow-ping"
]
},
{
"description": "Denies the ping command without any pre-configured scope.",
"description": "deny-ping -> Denies the ping command without any pre-configured scope.",
"type": "string",
"const": "deny-ping"
"enum": [
"deny-ping"
]
},
{
"description": "Enables the prepare_vpn command without any pre-configured scope.",
"description": "allow-prepare-vpn -> Enables the prepare_vpn command without any pre-configured scope.",
"type": "string",
"const": "allow-prepare-vpn"
"enum": [
"allow-prepare-vpn"
]
},
{
"description": "Denies the prepare_vpn command without any pre-configured scope.",
"description": "deny-prepare-vpn -> Denies the prepare_vpn command without any pre-configured scope.",
"type": "string",
"const": "deny-prepare-vpn"
"enum": [
"deny-prepare-vpn"
]
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"description": "allow-register-listener -> Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "allow-register-listener"
"enum": [
"allow-register-listener"
]
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"description": "deny-register-listener -> Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "deny-register-listener"
"enum": [
"deny-register-listener"
]
},
{
"description": "Enables the start_vpn command without any pre-configured scope.",
"description": "allow-start-vpn -> Enables the start_vpn command without any pre-configured scope.",
"type": "string",
"const": "allow-start-vpn"
"enum": [
"allow-start-vpn"
]
},
{
"description": "Denies the start_vpn command without any pre-configured scope.",
"description": "deny-start-vpn -> Denies the start_vpn command without any pre-configured scope.",
"type": "string",
"const": "deny-start-vpn"
"enum": [
"deny-start-vpn"
]
},
{
"description": "Enables the stop_vpn command without any pre-configured scope.",
"description": "allow-stop-vpn -> Enables the stop_vpn command without any pre-configured scope.",
"type": "string",
"const": "allow-stop-vpn"
"enum": [
"allow-stop-vpn"
]
},
{
"description": "Denies the stop_vpn command without any pre-configured scope.",
"description": "deny-stop-vpn -> Denies the stop_vpn command without any pre-configured scope.",
"type": "string",
"const": "deny-stop-vpn"
"enum": [
"deny-stop-vpn"
]
},
{
"description": "Default permissions for the plugin",
"description": "default -> Default permissions for the plugin",
"type": "string",
"const": "default"
"enum": [
"default"
]
}
]
}