Compare commits

...

8 Commits

Author SHA1 Message Date
chao wan
7dcc0ae70e Address omissions 2024-11-16 02:31:19 +08:00
chao wan
d5c9c588d4 Retrigger check 2024-11-15 22:47:56 +08:00
chao wan
02e90f76ae Merge branch 'main' of https://github.com/hvvvvvvv/EasyTier 2024-11-15 21:19:18 +08:00
chao wan
d751fce7c4 fix bug 2024-11-15 21:19:08 +08:00
咸鱼而已
a17c44955e
Merge branch 'EasyTier:main' into main 2024-11-15 21:00:34 +08:00
chao wan
1d37f7c21f Add more service installation options. 2024-11-15 20:58:46 +08:00
Sijie.Sun
4fc3ff8ce8
gui use frontend-lib, fix memory leak (#467)
Some checks failed
EasyTier Core / pre_job (push) Has been cancelled
EasyTier GUI / pre_job (push) Has been cancelled
EasyTier Mobile / pre_job (push) Has been cancelled
EasyTier Test / pre_job (push) Has been cancelled
EasyTier Core / build (freebsd-13.2-x86_64, 13.2, ubuntu-22.04, x86_64-unknown-freebsd) (push) Has been cancelled
EasyTier Core / build (linux-aarch64, ubuntu-22.04, aarch64-unknown-linux-musl) (push) Has been cancelled
EasyTier Core / build (linux-arm, ubuntu-22.04, arm-unknown-linux-musleabi) (push) Has been cancelled
EasyTier Core / build (linux-armhf, ubuntu-22.04, arm-unknown-linux-musleabihf) (push) Has been cancelled
EasyTier Core / build (linux-armv7, ubuntu-22.04, armv7-unknown-linux-musleabi) (push) Has been cancelled
EasyTier Core / build (linux-armv7hf, ubuntu-22.04, armv7-unknown-linux-musleabihf) (push) Has been cancelled
EasyTier Core / build (linux-mips, ubuntu-22.04, mips-unknown-linux-musl) (push) Has been cancelled
EasyTier Core / build (linux-mipsel, ubuntu-22.04, mipsel-unknown-linux-musl) (push) Has been cancelled
EasyTier Core / build (linux-x86_64, ubuntu-22.04, x86_64-unknown-linux-musl) (push) Has been cancelled
EasyTier Core / build (macos-aarch64, macos-latest, aarch64-apple-darwin) (push) Has been cancelled
EasyTier Core / build (macos-x86_64, macos-latest, x86_64-apple-darwin) (push) Has been cancelled
EasyTier Core / build (windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
EasyTier Core / core-result (push) Has been cancelled
EasyTier GUI / build-gui (linux-aarch64, aarch64-unknown-linux-gnu, ubuntu-22.04, aarch64-unknown-linux-musl) (push) Has been cancelled
EasyTier GUI / build-gui (linux-x86_64, x86_64-unknown-linux-gnu, ubuntu-22.04, x86_64-unknown-linux-musl) (push) Has been cancelled
EasyTier GUI / build-gui (macos-aarch64, aarch64-apple-darwin, macos-latest, aarch64-apple-darwin) (push) Has been cancelled
EasyTier GUI / build-gui (macos-x86_64, x86_64-apple-darwin, macos-latest, x86_64-apple-darwin) (push) Has been cancelled
EasyTier GUI / build-gui (windows-x86_64, x86_64-pc-windows-msvc, windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
EasyTier GUI / gui-result (push) Has been cancelled
EasyTier Mobile / build-mobile (android, ubuntu-22.04, android) (push) Has been cancelled
EasyTier Mobile / mobile-result (push) Has been cancelled
EasyTier Test / test (push) Has been cancelled
2024-11-10 23:03:40 +08:00
Sijie.Sun
88e6de9d7e
make all frontend functions works (#466)
Some checks are pending
EasyTier Core / pre_job (push) Waiting to run
EasyTier Core / build (freebsd-13.2-x86_64, 13.2, ubuntu-22.04, x86_64-unknown-freebsd) (push) Blocked by required conditions
EasyTier Core / build (linux-aarch64, ubuntu-22.04, aarch64-unknown-linux-musl) (push) Blocked by required conditions
EasyTier Core / build (linux-arm, ubuntu-22.04, arm-unknown-linux-musleabi) (push) Blocked by required conditions
EasyTier Core / build (linux-armhf, ubuntu-22.04, arm-unknown-linux-musleabihf) (push) Blocked by required conditions
EasyTier Core / build (linux-armv7, ubuntu-22.04, armv7-unknown-linux-musleabi) (push) Blocked by required conditions
EasyTier Core / build (linux-armv7hf, ubuntu-22.04, armv7-unknown-linux-musleabihf) (push) Blocked by required conditions
EasyTier Core / build (linux-mips, ubuntu-22.04, mips-unknown-linux-musl) (push) Blocked by required conditions
EasyTier Core / build (linux-mipsel, ubuntu-22.04, mipsel-unknown-linux-musl) (push) Blocked by required conditions
EasyTier Core / build (linux-x86_64, ubuntu-22.04, x86_64-unknown-linux-musl) (push) Blocked by required conditions
EasyTier Core / build (macos-aarch64, macos-latest, aarch64-apple-darwin) (push) Blocked by required conditions
EasyTier Core / build (macos-x86_64, macos-latest, x86_64-apple-darwin) (push) Blocked by required conditions
EasyTier Core / build (windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Blocked by required conditions
EasyTier Core / core-result (push) Blocked by required conditions
EasyTier GUI / pre_job (push) Waiting to run
EasyTier GUI / build-gui (linux-aarch64, aarch64-unknown-linux-gnu, ubuntu-22.04, aarch64-unknown-linux-musl) (push) Blocked by required conditions
EasyTier GUI / build-gui (linux-x86_64, x86_64-unknown-linux-gnu, ubuntu-22.04, x86_64-unknown-linux-musl) (push) Blocked by required conditions
EasyTier GUI / build-gui (macos-aarch64, aarch64-apple-darwin, macos-latest, aarch64-apple-darwin) (push) Blocked by required conditions
EasyTier GUI / build-gui (macos-x86_64, x86_64-apple-darwin, macos-latest, x86_64-apple-darwin) (push) Blocked by required conditions
EasyTier GUI / build-gui (windows-x86_64, x86_64-pc-windows-msvc, windows-latest, x86_64-pc-windows-msvc) (push) Blocked by required conditions
EasyTier GUI / gui-result (push) Blocked by required conditions
EasyTier Mobile / pre_job (push) Waiting to run
EasyTier Mobile / build-mobile (android, ubuntu-22.04, android) (push) Blocked by required conditions
EasyTier Mobile / mobile-result (push) Blocked by required conditions
EasyTier Test / pre_job (push) Waiting to run
EasyTier Test / test (push) Blocked by required conditions
2024-11-10 11:06:58 +08:00
59 changed files with 2299 additions and 2835 deletions

766
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -154,8 +154,6 @@ declare module 'vue' {
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']> readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']> readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']> 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 onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']> readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']> readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,213 +0,0 @@
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,6 +43,7 @@ clap = { version = "4.4.8", features = [
"wrap_help", "wrap_help",
] } ] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.5.0", features = [ uuid = { version = "1.5.0", features = [
"v4", "v4",
"fast-rng", "fast-rng",

View File

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

View File

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

View File

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

View File

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

View File

@ -7,9 +7,18 @@ import PrimeVue from 'primevue/config'
import I18nUtils from './modules/i18n' import I18nUtils from './modules/i18n'
import * as NetworkTypes from './types/network' 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 { export default {
install: (app: App) => { install: (app: App): void => {
app.use(I18nUtils.i18n, { useScope: 'global' }) app.use(I18nUtils.i18n, { useScope: 'global' })
app.use(PrimeVue, { app.use(PrimeVue, {
theme: { theme: {
@ -27,7 +36,9 @@ export default {
app.component('Config', Config); app.component('Config', Config);
app.component('Status', Status); app.component('Status', Status);
app.component('HumanEvent', HumanEvent);
app.directive('tooltip', vTooltip as any);
} }
}; };
export { Config, Status, I18nUtils, NetworkTypes }; export { Config, Status, I18nUtils, NetworkTypes, Api, Utils };

View File

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

View File

@ -1,11 +1,18 @@
import { IPv4, IPv6 } from 'ip-num/IPNumber' import { IPv4, IPv6 } from 'ip-num/IPNumber'
import { Ipv4Addr, Ipv6Addr } from '../types/network' import { Ipv4Addr, Ipv4Inet, Ipv6Addr } from '../types/network'
export function num2ipv4(ip: Ipv4Addr) { export function ipv4ToString(ip: Ipv4Addr) {
return IPv4.fromNumber(ip.addr) return IPv4.fromNumber(ip.addr).toString()
} }
export function num2ipv6(ip: Ipv6Addr) { export function ipv4InetToString(ip: Ipv4Inet | undefined) {
if (ip?.address === undefined) {
return 'undefined'
}
return `${ipv4ToString(ip.address)}/${ip.network_length}`
}
export function ipv6ToString(ip: Ipv6Addr) {
return IPv6.fromBigInt( return IPv6.fromBigInt(
(BigInt(ip.part1) << BigInt(96)) (BigInt(ip.part1) << BigInt(96))
+ (BigInt(ip.part2) << BigInt(64)) + (BigInt(ip.part2) << BigInt(64))
@ -13,3 +20,89 @@ export function num2ipv6(ip: Ipv6Addr) {
+ BigInt(ip.part4), + 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,4 +1,5 @@
@import 'primeicons/primeicons.css'; @import 'primeicons/primeicons.css';
@import 'floating-vue/dist/style.css';
.frontend-lib { .frontend-lib {

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -2,12 +2,7 @@
import { I18nUtils } from 'easytier-frontend-lib' import { I18nUtils } from 'easytier-frontend-lib'
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import Login from './components/Login.vue' import { Toast, DynamicDialog } from 'primevue';
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 () => { onMounted(async () => {
await I18nUtils.loadLanguageAsync('cn') await I18nUtils.loadLanguageAsync('cn')
@ -18,109 +13,10 @@ onMounted(async () => {
<!-- https://flowbite.com/docs/components/sidebar/#sidebar-with-navbar --> <!-- https://flowbite.com/docs/components/sidebar/#sidebar-with-navbar -->
<template> <template>
<div id="root" class=""> <Toast />
<nav class="fixed top-0 z-50 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700"> <DynamicDialog />
<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>
<aside id="logo-sidebar" <RouterView />
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> </template>
<style scoped> <style scoped>

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,33 @@
<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

@ -0,0 +1,65 @@
<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,204 +1,86 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, onUnmounted, ref } from 'vue';
import ApiClient, { ValidateConfigResponse } from '../modules/api'; import { Button, Column, DataTable, Drawer, ProgressSpinner, useToast } from 'primevue';
import { Config, Status, NetworkTypes } from 'easytier-frontend-lib' import { useRoute, useRouter } from 'vue-router';
import { Button, Column, DataTable, Drawer, Toolbar, IftaLabel, Select, Dialog, ConfirmPopup, useConfirm } from 'primevue'; import { Api, Utils } from 'easytier-frontend-lib';
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({ const props = defineProps({
api: ApiClient, api: Api.ApiClient,
}); });
const api = props.api; const api = props.api;
interface DeviceList { const deviceList = ref<Array<Utils.DeviceInfo> | undefined>(undefined);
hostname: string;
public_ip: string;
running_network_count: number;
report_time: string;
easytier_version: string;
running_network_instances?: Array<string>;
machine_id: string;
}
const selectedDevice = ref<DeviceList | null>(null); const selectedDeviceId = computed<string | undefined>(() => route.params.deviceId as string);
const deviceList = ref<Array<DeviceList>>([]);
const instanceIdList = computed(() => { const route = useRoute();
let insts = selectedDevice.value?.running_network_instances || []; const router = useRouter();
let options = insts.map((instance: string) => { const toast = useToast();
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 loadDevices = async () => {
const resp = await api?.list_machines(); const resp = await api?.list_machines();
console.log(resp); let devices: Array<Utils.DeviceInfo> = [];
let devices: Array<DeviceList> = [];
for (const device of (resp || [])) { for (const device of (resp || [])) {
devices.push({ devices.push({
hostname: device.info?.hostname, hostname: device.info?.hostname,
public_ip: device.client_url, public_ip: device.client_url,
running_network_instances: device.info?.running_network_instances.map((instance: any) => UuidToStr(instance)), running_network_instances: device.info?.running_network_instances.map((instance: any) => Utils.UuidToStr(instance)),
running_network_count: device.info?.running_network_instances.length, running_network_count: device.info?.running_network_instances.length,
report_time: device.info?.report_time, report_time: device.info?.report_time,
easytier_version: device.info?.easytier_version, easytier_version: device.info?.easytier_version,
machine_id: UuidToStr(device.info?.machine_id), machine_id: Utils.UuidToStr(device.info?.machine_id),
}); });
} }
console.debug("device list", deviceList.value);
deviceList.value = devices; deviceList.value = devices;
console.log(deviceList.value);
}; };
interface SelectedDevice { const periodFunc = new Utils.PeriodicTask(async () => {
machine_id: string; try {
instance_id: string; await loadDevices();
} } catch (e) {
toast.add({ severity: 'error', summary: 'Load Device List Failed', detail: e, life: 2000 });
const checkDeviceSelected = (): SelectedDevice => { console.error(e);
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 () => { onMounted(async () => {
setInterval(loadDevices, 1000); periodFunc.start();
setInterval(loadDeviceInfo, 1000);
}); });
const visibleRight = ref(false); onUnmounted(() => {
periodFunc.stop();
});
const showCreateNetworkDialog = ref(false); const deviceManageVisible = computed<boolean>({
const newNetworkConfig = ref<NetworkTypes.NetworkConfig>(NetworkTypes.DEFAULT_NETWORK_CONFIG()); get: () => !!selectedDeviceId.value,
set: (value) => {
const verifyNetworkConfig = async (): Promise<ValidateConfigResponse | undefined> => { if (!value) {
let machine_id = selectedDevice.value?.machine_id; router.push({ name: 'deviceList', params: { deviceId: undefined } });
if (!machine_id) {
throw new Error("No machine selected");
} }
if (!newNetworkConfig.value) {
throw new Error("No network config");
} }
});
let ret = await api?.validate_config(machine_id, newNetworkConfig.value); const selectedDeviceHostname = computed<string | undefined>(() => {
console.log("verifyNetworkConfig", ret); return deviceList.value?.find((device) => device.machine_id === selectedDeviceId.value)?.hostname;
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> </script>
<style scoped></style> <style scoped></style>
<template> <template>
<ConfirmPopup></ConfirmPopup> <div v-if="deviceList === undefined" class="w-full flex justify-center">
<Dialog v-model:visible="showCreateNetworkDialog" modal header="Create New Network" :style="{ width: '55rem' }"> <ProgressSpinner />
<Config :cur-network="newNetworkConfig" @run-network="createNewNetwork"></Config> </div>
</Dialog>
<DataTable :value="deviceList" tableStyle="min-width: 50rem" :metaKeySelection="true" sortField="hostname" <DataTable :value="deviceList" tableStyle="min-width: 50rem" :metaKeySelection="true" sortField="hostname"
:sortOrder="-1"> :sortOrder="-1" v-if="deviceList !== undefined">
<template #header> <template #header>
<div class="text-xl font-bold">Device List</div> <div class="text-xl font-bold">Device List</div>
</template> </template>
<Column field="hostname" header="Hostname" sortable style="width: 180px"></Column> <Column field="hostname" header="Hostname" sortable style="width: 180px"></Column>
<Column field="public_ip" header="Public IP" style="width: 150px"></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> <Column field="running_network_count" header="Running Network Count" sortable style="width: 150px"></Column>
@ -206,38 +88,23 @@ const confirmDeleteNetwork = (event: any) => {
<Column field="easytier_version" header="EasyTier Version" sortable style="width: 150px"></Column> <Column field="easytier_version" header="EasyTier Version" sortable style="width: 150px"></Column>
<Column class="w-24 !text-end"> <Column class="w-24 !text-end">
<template #body="{ data }"> <template #body="{ data }">
<Button icon="pi pi-search" @click="selectedDevice = data; visibleRight = true" severity="secondary" <Button icon="pi pi-cog"
rounded></Button> @click="router.push({ name: 'deviceManagement', params: { deviceId: data.machine_id, instanceId: data.running_network_instances[0] } })"
severity="secondary" rounded></Button>
</template> </template>
</Column> </Column>
<template #footer> <template #footer>
<div class="flex justify-start"> <div class="flex justify-end">
<Button icon="pi pi-refresh" label="Reload" severity="info" @click="loadDevices" /> <Button icon="pi pi-refresh" label="Reload" severity="info" @click="loadDevices" />
</div> </div>
</template> </template>
</DataTable> </DataTable>
<Drawer v-model:visible="visibleRight" header="Device Management" position="right" class="w-1/2 min-w-96"> <Drawer v-model:visible="deviceManageVisible" :header="`Manage ${selectedDeviceHostname}`" position="right"
<Toolbar> class="w-1/2 min-w-96">
<template #start> <RouterView v-slot="{ Component }">
<IftaLabel> <component :is="Component" :api="api" :deviceList="deviceList" @update="loadDevices" />
<Select v-model="selectedInstanceId" :options="instanceIdList" optionLabel="uuid" </RouterView>
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> </Drawer>
</template> </template>

View File

@ -0,0 +1,197 @@
<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,3 +1,65 @@
<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> <template>
<div class="flex items-center justify-center min-h-screen"> <div class="flex items-center justify-center min-h-screen">
<Card class="w-full max-w-md p-6"> <Card class="w-full max-w-md p-6">
@ -6,6 +68,11 @@
</h2> </h2>
</template> </template>
<template #content> <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"> <form v-if="!isRegistering" @submit.prevent="onSubmit" class="space-y-4">
<div class="p-field"> <div class="p-field">
<label for="username" class="block text-sm font-medium">Username</label> <label for="username" class="block text-sm font-medium">Username</label>
@ -13,14 +80,14 @@
</div> </div>
<div class="p-field"> <div class="p-field">
<label for="password" class="block text-sm font-medium">Password</label> <label for="password" class="block text-sm font-medium">Password</label>
<Password id="password" v-model="password" required toggleMask /> <Password id="password" v-model="password" required toggleMask :feedback="false" />
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Button label="Login" type="submit" class="w-full" /> <Button label="Login" type="submit" class="w-full" />
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Button label="Register" type="button" class="w-full" @click="isRegistering = true" <Button label="Register" type="button" class="w-full"
severity="secondary" /> @click="$router.replace({ name: 'register' })" severity="secondary" />
</div> </div>
</form> </form>
@ -32,7 +99,7 @@
<div class="p-field"> <div class="p-field">
<label for="register-password" class="block text-sm font-medium">Password</label> <label for="register-password" class="block text-sm font-medium">Password</label>
<Password id="register-password" v-model="registerPassword" required toggleMask <Password id="register-password" v-model="registerPassword" required toggleMask
class="w-full" /> :feedback="false" class="w-full" />
</div> </div>
<div class="p-field"> <div class="p-field">
<label for="captcha" class="block text-sm font-medium">Captcha</label> <label for="captcha" class="block text-sm font-medium">Captcha</label>
@ -43,8 +110,8 @@
<Button label="Register" type="submit" class="w-full" /> <Button label="Register" type="submit" class="w-full" />
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Button label="Back to Login" type="button" class="w-full" @click="isRegistering = false" <Button label="Back to Login" type="button" class="w-full"
severity="secondary" /> @click="$router.replace({ name: 'login' })" severity="secondary" />
</div> </div>
</form> </form>
</template> </template>
@ -52,42 +119,4 @@
</div> </div>
</template> </template>
<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> <style scoped></style>

View File

@ -0,0 +1,173 @@
<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,6 +7,72 @@ import PrimeVue from 'primevue/config'
import Aura from '@primevue/themes/aura' import Aura from '@primevue/themes/aura'
import ConfirmationService from 'primevue/confirmationservice'; 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, createApp(App).use(PrimeVue,
{ {
theme: { theme: {
@ -21,4 +87,4 @@ createApp(App).use(PrimeVue,
} }
} }
} }
).use(ConfirmationService as any).use(EasytierFrontendLib).mount('#app') ).use(ToastService as any).use(DialogService as any).use(router).use(ConfirmationService as any).use(EasytierFrontendLib).mount('#app')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,94 +4,6 @@ use sea_orm_migration::{prelude::*, schema::*};
pub struct Migration; 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 { impl MigrationName for Migration {
fn name(&self) -> &str { fn name(&self) -> &str {
"m20241029_000001_init" "m20241029_000001_init"
@ -141,6 +53,7 @@ enum UserRunningNetworkConfigs {
Table, Table,
Id, Id,
UserId, UserId,
DeviceId,
NetworkInstanceId, NetworkInstanceId,
NetworkConfig, NetworkConfig,
Disabled, Disabled,
@ -273,6 +186,7 @@ impl MigrationTrait for Migration {
.table(UserRunningNetworkConfigs::Table) .table(UserRunningNetworkConfigs::Table)
.col(pk_auto(UserRunningNetworkConfigs::Id).not_null()) .col(pk_auto(UserRunningNetworkConfigs::Id).not_null())
.col(integer(UserRunningNetworkConfigs::UserId).not_null()) .col(integer(UserRunningNetworkConfigs::UserId).not_null())
.col(text(UserRunningNetworkConfigs::DeviceId).not_null())
.col( .col(
text(UserRunningNetworkConfigs::NetworkInstanceId) text(UserRunningNetworkConfigs::NetworkInstanceId)
.unique_key() .unique_key()

View File

@ -22,6 +22,10 @@ pub struct LoginResult {
pub fn router() -> Router<AppStateInner> { pub fn router() -> Router<AppStateInner> {
let r = Router::new() let r = Router::new()
.route("/api/v1/auth/password", put(self::put::change_password)) .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)); .route_layer(login_required!(Backend));
Router::new() Router::new()
.merge(r) .merge(r)
@ -168,4 +172,17 @@ 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_login::{login_required, AuthManagerLayerBuilder, AuthzBackend};
use axum_messages::MessagesManagerLayer; use axum_messages::MessagesManagerLayer;
use easytier::common::scoped_task::ScopedTask; use easytier::common::scoped_task::ScopedTask;
use easytier::proto::{rpc_types}; use easytier::proto::rpc_types;
use network::NetworkApi; use network::NetworkApi;
use sea_orm::DbErr; use sea_orm::DbErr;
use tokio::net::TcpListener; use tokio::net::TcpListener;
@ -43,6 +43,11 @@ type AppState = State<AppStateInner>;
#[derive(Debug, serde::Deserialize, serde::Serialize)] #[derive(Debug, serde::Deserialize, serde::Serialize)]
struct ListSessionJsonResp(Vec<StorageToken>); struct ListSessionJsonResp(Vec<StorageToken>);
#[derive(Debug, serde::Deserialize, serde::Serialize)]
struct GetSummaryJsonResp {
device_count: u32,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)] #[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Error { pub struct Error {
message: String, message: String,
@ -98,16 +103,32 @@ impl RestfulServer {
auth_session: AuthSession, auth_session: AuthSession,
State(client_mgr): AppState, State(client_mgr): AppState,
) -> Result<Json<ListSessionJsonResp>, HttpHandleError> { ) -> Result<Json<ListSessionJsonResp>, HttpHandleError> {
let pers = auth_session let perms = auth_session
.backend .backend
.get_group_permissions(auth_session.user.as_ref().unwrap()) .get_group_permissions(auth_session.user.as_ref().unwrap())
.await .await
.unwrap(); .unwrap();
println!("{:?}", pers); println!("{:?}", perms);
let ret = client_mgr.list_sessions().await; let ret = client_mgr.list_sessions().await;
Ok(ListSessionJsonResp(ret).into()) 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> { pub async fn start(&mut self) -> Result<(), anyhow::Error> {
let listener = TcpListener::bind(self.bind_addr).await?; let listener = TcpListener::bind(self.bind_addr).await?;
@ -143,6 +164,7 @@ impl RestfulServer {
let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build(); let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build();
let app = Router::new() let app = Router::new()
.route("/api/v1/summary", get(Self::handle_get_summary))
.route("/api/v1/sessions", get(Self::handle_list_all_sessions)) .route("/api/v1/sessions", get(Self::handle_list_all_sessions))
.merge(self.network_api.build_route()) .merge(self.network_api.build_route())
.route_layer(login_required!(Backend)) .route_layer(login_required!(Backend))

View File

@ -9,7 +9,7 @@ use dashmap::DashSet;
use easytier::launcher::NetworkConfig; use easytier::launcher::NetworkConfig;
use easytier::proto::common::Void; use easytier::proto::common::Void;
use easytier::proto::rpc_types::controller::BaseController; 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::session::Session;
use crate::client_manager::ClientManager; use crate::client_manager::ClientManager;
@ -38,7 +38,7 @@ struct ValidateConfigJsonReq {
#[derive(Debug, serde::Deserialize, serde::Serialize)] #[derive(Debug, serde::Deserialize, serde::Serialize)]
struct RunNetworkJsonReq { struct RunNetworkJsonReq {
config: String, config: NetworkConfig,
} }
#[derive(Debug, serde::Deserialize, serde::Serialize)] #[derive(Debug, serde::Deserialize, serde::Serialize)]
@ -145,7 +145,7 @@ impl NetworkApi {
BaseController::default(), BaseController::default(),
RunNetworkInstanceRequest { RunNetworkInstanceRequest {
inst_id: None, inst_id: None,
config: config.clone(), config: Some(config.clone()),
}, },
) )
.await .await
@ -155,8 +155,9 @@ impl NetworkApi {
.db() .db()
.insert_or_update_user_network_config( .insert_or_update_user_network_config(
auth_session.user.as_ref().unwrap().id(), auth_session.user.as_ref().unwrap().id(),
machine_id,
resp.inst_id.clone().unwrap_or_default().into(), resp.inst_id.clone().unwrap_or_default().into(),
config, serde_json::to_string(&config).unwrap(),
) )
.await .await
.map_err(convert_db_error)?; .map_err(convert_db_error)?;
@ -288,6 +289,36 @@ impl NetworkApi {
Ok(Json(ListMachineJsonResp { machines })) 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> { pub fn build_route(&mut self) -> Router<AppStateInner> {
Router::new() Router::new()
.route("/api/v1/machines", get(Self::handle_list_machines)) .route("/api/v1/machines", get(Self::handle_list_machines))
@ -311,5 +342,9 @@ impl NetworkApi {
"/api/v1/machines/:machine-id/networks/info/:inst-id", "/api/v1/machines/:machine-id/networks/info/:inst-id",
get(Self::handle_collect_one_network_info), 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,9 +120,6 @@ core_clap:
ipv6_listener: ipv6_listener:
en: "the url of the ipv6 listener, e.g.: tcp://[::]:11010, if not set, will listen on random udp port" 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端口上监听" 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: core_app:
panic_backtrace_save: panic_backtrace_save:

View File

@ -25,6 +25,8 @@ 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 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!( pub const EASYTIER_VERSION: &str = git_version::git_version!(
args = ["--abbrev=8", "--always", "--dirty=~"], args = ["--abbrev=8", "--always", "--dirty=~"],
prefix = concat!(env!("CARGO_PKG_VERSION"), "-"), prefix = concat!(env!("CARGO_PKG_VERSION"), "-"),

View File

@ -1,18 +1,13 @@
use std::{ use std::{
ffi::OsString, ffi::OsString, fmt::Write, net::SocketAddr, path::PathBuf, sync::Mutex, time::Duration, vec,
net::SocketAddr,
path::PathBuf,
sync::Mutex,
time::Duration,
vec
}; };
use anyhow::{Context, Ok}; use anyhow::{Context, Ok};
use clap::{command, Args, Parser, Subcommand}; use clap::{command, Args, Parser, Subcommand};
use humansize::format_size; use humansize::format_size;
use service_manager::*;
use tabled::settings::Style; use tabled::settings::Style;
use tokio::time::timeout; use tokio::time::timeout;
use service_manager::*;
use easytier::{ use easytier::{
common::{ common::{
@ -62,7 +57,7 @@ enum SubCommand {
PeerCenter, PeerCenter,
VpnPortal, VpnPortal,
Node(NodeArgs), Node(NodeArgs),
Service(ServiceArgs) Service(ServiceArgs),
} }
#[derive(Args, Debug)] #[derive(Args, Debug)]
@ -130,9 +125,12 @@ struct NodeArgs {
} }
#[derive(Args, Debug)] #[derive(Args, Debug)]
struct ServiceArgs{ struct ServiceArgs {
#[arg(short, long, default_value = env!("CARGO_PKG_NAME"), help = "service name")]
name: String,
#[command(subcommand)] #[command(subcommand)]
sub_command: ServiceSubCommand sub_command: ServiceSubCommand,
} }
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
@ -141,15 +139,28 @@ enum ServiceSubCommand {
Uninstall, Uninstall,
Status, Status,
Start, Start,
Stop Stop,
} }
#[derive(Args, Debug)] #[derive(Args, Debug)]
struct InstallArgs { 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)] #[arg(long)]
core_path: Option<PathBuf>, core_path: Option<PathBuf>,
#[arg(long)]
service_work_dir: Option<PathBuf>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)] #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
core_args: Option<Vec<OsString>> core_args: Option<Vec<OsString>>,
} }
type Error = anyhow::Error; type Error = anyhow::Error;
@ -508,48 +519,54 @@ impl CommandHandler {
} }
} }
pub struct Service{ 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 {
lable: ServiceLabel, lable: ServiceLabel,
service_manager: Box<dyn ServiceManager> kind: ServiceManagerKind,
service_manager: Box<dyn ServiceManager>,
} }
impl Service { impl Service {
pub fn new() -> Result<Self, Error> { pub fn new(name: String) -> Result<Self, Error> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let service_manager = Box::new( let service_manager = Box::new(crate::win_service_manager::WinServiceManager::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"))] #[cfg(not(target_os = "windows"))]
let service_manager = <dyn ServiceManager>::native()?; let service_manager = <dyn ServiceManager>::native()?;
let kind = ServiceManagerKind::native()?;
Ok(Self { Ok(Self {
lable: env!("CARGO_PKG_NAME").parse().unwrap(), lable: name.parse()?,
service_manager kind,
service_manager,
}) })
} }
pub fn install(&self, bin_path: std::path::PathBuf, bin_args: Vec<OsString>) -> Result<(), Error> { pub fn install(&self, options: &ServiceInstallOptions) -> Result<(), Error> {
let ctx = ServiceInstallCtx { let ctx = ServiceInstallCtx {
label: self.lable.clone(), label: self.lable.clone(),
contents: None, program: options.program.clone(),
program: bin_path, args: options.args.clone(),
args: bin_args, contents: self.make_install_content_option(options),
autostart: true, autostart: !options.disable_autostart,
username: None, username: None,
working_directory: None, working_directory: Some(options.work_directory.clone()),
environment: None, environment: None,
}; };
if self.status()? != ServiceStatus::NotInstalled { if self.status()? != ServiceStatus::NotInstalled {
return Err(anyhow::anyhow!("Service is already installed")); 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> { pub fn uninstall(&self) -> Result<(), Error> {
@ -568,7 +585,9 @@ 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> { pub fn status(&self) -> Result<ServiceStatus, Error> {
@ -587,16 +606,14 @@ impl Service {
let status = self.status()?; let status = self.status()?;
match status { match status {
ServiceStatus::Running => { ServiceStatus::Running => Err(anyhow::anyhow!("Service is already running")),
Err(anyhow::anyhow!("Service is already running"))
}
ServiceStatus::Stopped(_) => { 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(()) Ok(())
} }
ServiceStatus::NotInstalled => { ServiceStatus::NotInstalled => Err(anyhow::anyhow!("Service is not installed")),
Err(anyhow::anyhow!("Service is not installed"))
}
} }
} }
@ -608,18 +625,149 @@ impl Service {
match status { match status {
ServiceStatus::Running => { 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(()) Ok(())
} }
ServiceStatus::Stopped(_) => { ServiceStatus::Stopped(_) => Err(anyhow::anyhow!("Service is already stopped")),
Err(anyhow::anyhow!("Service is already stopped")) ServiceStatus::NotInstalled => Err(anyhow::anyhow!("Service is not installed")),
} }
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
} }
} }
} }
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] #[tokio::main]
@ -785,7 +933,7 @@ async fn main() -> Result<(), Error> {
} }
} }
SubCommand::Service(service_args) => { SubCommand::Service(service_args) => {
let service = Service::new()?; let service = Service::new(service_args.name)?;
match service_args.sub_command { match service_args.sub_command {
ServiceSubCommand::Install(install_args) => { ServiceSubCommand::Install(install_args) => {
let bin_path = install_args.core_path.unwrap_or_else(|| { let bin_path = install_args.core_path.unwrap_or_else(|| {
@ -805,7 +953,35 @@ async fn main() -> Result<(), Error> {
anyhow::anyhow!("failed to get easytier core application: {}", e) anyhow::anyhow!("failed to get easytier core application: {}", e)
})?; })?;
let bin_args = install_args.core_args.unwrap_or_default(); let bin_args = install_args.core_args.unwrap_or_default();
service.install(bin_path, bin_args)?; 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)?;
} }
ServiceSubCommand::Uninstall => { ServiceSubCommand::Uninstall => {
service.uninstall()?; service.uninstall()?;
@ -833,55 +1009,42 @@ async fn main() -> Result<(), Error> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
mod win_service_manager { mod win_service_manager {
use std::{ffi::OsStr, ffi::OsString, io, path::PathBuf};
use windows_service::{ use windows_service::{
service::{ service::{
ServiceAccess, ServiceDependency, ServiceErrorControl, ServiceInfo, ServiceStartType,
ServiceType, ServiceType,
ServiceErrorControl,
ServiceDependency,
ServiceInfo,
ServiceStartType,
ServiceAccess
}, },
service_manager::{ service_manager::{ServiceManager, ServiceManagerAccess},
ServiceManagerAccess,
ServiceManager
}
};
use std::{
io,
ffi::OsString,
ffi::OsStr
}; };
use service_manager::{ use service_manager::{
ServiceInstallCtx, ServiceInstallCtx, ServiceLevel, ServiceStartCtx, ServiceStatus, ServiceStatusCtx,
ServiceLevel, ServiceStopCtx, ServiceUninstallCtx,
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 { pub struct WinServiceManager {
service_manager: ServiceManager, service_manager: ServiceManager,
display_name: Option<OsString>,
description: Option<OsString>,
dependencies: Vec<OsString>
} }
impl WinServiceManager { impl WinServiceManager {
pub fn new(display_name: Option<OsString>, description: Option<OsString>, dependencies: Vec<OsString>,) -> Result<Self, crate::Error> { pub fn new() -> Result<Self, crate::Error> {
let service_manager = ServiceManager::local_computer( let service_manager =
None::<&str>, ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::ALL_ACCESS)?;
ServiceManagerAccess::ALL_ACCESS, Ok(Self { service_manager })
)?;
Ok(Self {
service_manager,
display_name,
description,
dependencies,
})
} }
} }
impl service_manager::ServiceManager for WinServiceManager { impl service_manager::ServiceManager for WinServiceManager {
@ -890,10 +1053,32 @@ mod win_service_manager {
} }
fn install(&self, ctx: ServiceInstallCtx) -> io::Result<()> { 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 srv_name = OsString::from(ctx.label.to_qualified_name());
let dis_name = self.display_name.clone().unwrap_or_else(|| srv_name.clone()); let mut dis_name = srv_name.clone();
let dependencies = self.dependencies.iter().map(|dep| ServiceDependency::Service(dep.clone())).collect::<Vec<_>>(); 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 service_info = ServiceInfo { let service_info = ServiceInfo {
name: srv_name, name: srv_name,
display_name: dis_name, display_name: dis_name,
@ -904,50 +1089,58 @@ mod win_service_manager {
launch_arguments: ctx.args, launch_arguments: ctx.args,
dependencies: dependencies.clone(), dependencies: dependencies.clone(),
account_name: None, account_name: None,
account_password: None account_password: None,
}; };
let service = self.service_manager.create_service(&service_info, ServiceAccess::ALL_ACCESS).map_err(|e| { let service = self
io::Error::new(io::ErrorKind::Other, e) .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 { if let Some(s) = description {
service.set_description(s.clone()).map_err(|e| { service
io::Error::new(io::ErrorKind::Other, e) .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(()) Ok(())
} }
fn uninstall(&self, ctx: ServiceUninstallCtx) -> io::Result<()> { 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|{ let service = self
io::Error::new(io::ErrorKind::Other, e) .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|{ service
io::Error::new(io::ErrorKind::Other, e) .delete()
}) .map_err(|e| io::Error::new(io::ErrorKind::Other, e))
} }
fn start(&self, ctx: ServiceStartCtx) -> io::Result<()> { 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|{ let service = self
io::Error::new(io::ErrorKind::Other, e) .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|{ service
io::Error::new(io::ErrorKind::Other, e) .start(&[] as &[&OsStr])
}) .map_err(|e| io::Error::new(io::ErrorKind::Other, e))
} }
fn stop(&self, ctx: ServiceStopCtx) -> io::Result<()> { 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|{ let service = self
io::Error::new(io::ErrorKind::Other, e) .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|{ _ = service
io::Error::new(io::ErrorKind::Other, e) .stop()
})?; .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
Ok(()) Ok(())
} }
@ -959,12 +1152,18 @@ mod win_service_manager {
fn set_level(&mut self, level: ServiceLevel) -> io::Result<()> { fn set_level(&mut self, level: ServiceLevel) -> io::Result<()> {
match level { match level {
ServiceLevel::System => Ok(()), 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> { 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, Ok(s) => s,
Err(e) => { Err(e) => {
if let windows_service::Error::Winapi(ref win_err) = e { if let windows_service::Error::Winapi(ref win_err) = e {
@ -976,9 +1175,9 @@ mod win_service_manager {
} }
}; };
let status = service.query_status().map_err(|e|{ let status = service
io::Error::new(io::ErrorKind::Other, e) .query_status()
})?; .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
match status.current_state { match status.current_state {
windows_service::service::ServiceState::Stopped => Ok(ServiceStatus::Stopped(None)), windows_service::service::ServiceState::Stopped => Ok(ServiceStatus::Stopped(None)),
@ -986,4 +1185,12 @@ mod win_service_manager {
} }
} }
} }
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,12 +289,6 @@ struct Cli {
help = t!("core_clap.ipv6_listener").to_string() help = t!("core_clap.ipv6_listener").to_string()
)] )]
ipv6_listener: Option<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"); rust_i18n::i18n!("locales", fallback = "en");
@ -363,7 +357,7 @@ impl Cli {
} }
return Ok("0.0.0.0:0".parse().unwrap()); return Ok("0.0.0.0:0".parse().unwrap());
} }
return Ok("0.0.0.0:0".parse().unwrap()); return Ok(format!("0.0.0.0:{}", port).parse().unwrap());
} }
Ok(rpc_portal.parse()?) Ok(rpc_portal.parse()?)
@ -380,28 +374,32 @@ impl TryFrom<&Cli> for TomlConfigLoader {
config_file config_file
); );
return Ok(TomlConfigLoader::new(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(); let cfg = TomlConfigLoader::default();
cfg.set_hostname(cli.hostname.clone()); 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); cfg.set_dhcp(cli.dhcp);
if let Some(ipv4) = &cli.ipv4 { if let Some(ipv4) = &cli.ipv4 {
cfg.set_ipv4(Some( cfg.set_ipv4(Some(ipv4.parse().with_context(|| {
ipv4.parse() format!("failed to parse ipv4 address: {}", ipv4)
.with_context(|| format!("failed to parse ipv4 address: {}", ipv4))? })?))
))
} }
let mut peers = Vec::<PeerConfig>::with_capacity(cli.peers.len()); let mut peers = Vec::<PeerConfig>::with_capacity(cli.peers.len());
for p in &cli.peers { for p in &cli.peers {
peers.push(PeerConfig { 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); cfg.set_peers(peers);
@ -416,22 +414,21 @@ impl TryFrom<&Cli> for TomlConfigLoader {
for n in cli.proxy_networks.iter() { for n in cli.proxy_networks.iter() {
cfg.add_proxy_cidr( cfg.add_proxy_cidr(
n.parse() 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(|| { cfg.set_rpc_portal(
format!("failed to parse rpc portal: {}", cli.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() { if let Some(external_nodes) = cli.external_node.as_ref() {
let mut old_peers = cfg.get_peers(); let mut old_peers = cfg.get_peers();
old_peers.push(PeerConfig { old_peers.push(PeerConfig {
uri: external_nodes uri: external_nodes.parse().with_context(|| {
.parse()
.with_context(|| {
format!("failed to parse external node uri: {}", external_nodes) format!("failed to parse external node uri: {}", external_nodes)
})? })?,
}); });
cfg.set_peers(old_peers); cfg.set_peers(old_peers);
} }
@ -456,11 +453,15 @@ impl TryFrom<&Cli> for TomlConfigLoader {
let url: url::Url = vpn_portal let url: url::Url = vpn_portal
.parse() .parse()
.with_context(|| format!("failed to parse vpn portal url: {}", vpn_portal))?; .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 host = url
let port = url.port().ok_or_else(|| anyhow::anyhow!("vpn portal url missing port"))?; .host_str()
let client_cidr = url.path()[1..] .ok_or_else(|| anyhow::anyhow!("vpn portal url missing host"))?;
.parse() let port = url
.with_context(|| format!("failed to parse vpn portal client cidr: {}", url.path()))?; .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(); let wireguard_listen: SocketAddr = format!("{}:{}", host, port).parse().unwrap();
cfg.set_vpn_portal_config(VpnPortalConfig { cfg.set_vpn_portal_config(VpnPortalConfig {
wireguard_listen, wireguard_listen,
@ -470,8 +471,11 @@ impl TryFrom<&Cli> for TomlConfigLoader {
if let Some(manual_routes) = cli.manual_routes.as_ref() { if let Some(manual_routes) = cli.manual_routes.as_ref() {
let mut routes = Vec::<cidr::Ipv4Cidr>::with_capacity(manual_routes.len()); let mut routes = Vec::<cidr::Ipv4Cidr>::with_capacity(manual_routes.len());
for r in manual_routes{ for r in manual_routes {
routes.push(r.parse().with_context(|| format!("failed to parse route: {}", r))?); routes.push(
r.parse()
.with_context(|| format!("failed to parse route: {}", r))?,
);
} }
cfg.set_routes(Some(routes)); cfg.set_routes(Some(routes));
} }
@ -640,14 +644,30 @@ 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")] #[cfg(target_os = "windows")]
fn win_service_event_loop( fn win_service_event_loop(
stop_notify: std::sync::Arc<tokio::sync::Notify>, stop_notify: std::sync::Arc<tokio::sync::Notify>,
cli: Cli, cli: Cli,
status_handle: windows_service::service_control_handler::ServiceStatusHandle, status_handle: windows_service::service_control_handler::ServiceStatusHandle,
) { ) {
use tokio::runtime::Runtime;
use std::time::Duration; use std::time::Duration;
use tokio::runtime::Runtime;
use windows_service::service::*; use windows_service::service::*;
let normal_status = ServiceStatus { let normal_status = ServiceStatus {
@ -669,12 +689,6 @@ fn win_service_event_loop(
process_id: None, 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 || { std::thread::spawn(move || {
let rt = Runtime::new().unwrap(); let rt = Runtime::new().unwrap();
rt.block_on(async move { rt.block_on(async move {
@ -701,13 +715,15 @@ fn win_service_event_loop(
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn win_service_main(_: Vec<std::ffi::OsString>) { fn win_service_main(arg: Vec<std::ffi::OsString>) {
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::sync::Notify; use tokio::sync::Notify;
use windows_service::service::*; use windows_service::service::*;
use windows_service::service_control_handler::*; use windows_service::service_control_handler::*;
_ = win_service_set_work_dir(&arg[0]);
let cli = Cli::parse(); let cli = Cli::parse();
let stop_notify_send = Arc::new(Notify::new()); let stop_notify_send = Arc::new(Notify::new());
@ -732,15 +748,16 @@ fn win_service_main(_: Vec<std::ffi::OsString>) {
wait_hint: Duration::default(), wait_hint: Duration::default(),
process_id: None, 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); win_service_event_loop(stop_notify_recv, cli, status_handle);
} }
async fn run_main(cli: Cli) -> anyhow::Result<()> { async fn run_main(cli: Cli) -> anyhow::Result<()> {
if let Some(dir) = cli.work_dir.as_ref() { let cfg = TomlConfigLoader::try_from(&cli)?;
std::env::set_current_dir(dir).map_err(|e| anyhow::anyhow!("failed to set work dir: {}", e))?; init_logger(&cfg, false)?;
}
if cli.config_server.is_some() { if cli.config_server.is_some() {
let config_server_url_s = cli.config_server.clone().unwrap(); let config_server_url_s = cli.config_server.clone().unwrap();
@ -776,9 +793,6 @@ async fn run_main(cli: Cli) -> anyhow::Result<()> {
return Ok(()); return Ok(());
} }
let cfg = TomlConfigLoader::try_from(&cli)?;
init_logger(&cfg, false)?;
println!("Starting easytier with config:"); println!("Starting easytier with config:");
println!("############### TOML ###############\n"); println!("############### TOML ###############\n");
println!("{}", cfg.dump()); println!("{}", cfg.dump());
@ -786,8 +800,8 @@ async fn run_main(cli: Cli) -> anyhow::Result<()> {
let mut l = launcher::NetworkInstance::new(cfg).set_fetch_node_info(false); let mut l = launcher::NetworkInstance::new(cfg).set_fetch_node_info(false);
let _t = ScopedTask::from(handle_event(l.start().unwrap())); let _t = ScopedTask::from(handle_event(l.start().unwrap()));
if let Some(e) = l.wait().await{ if let Some(e) = l.wait().await {
return Err(anyhow::anyhow!("launcher error: {}", e)); anyhow::bail!("launcher error: {}", e);
} }
Ok(()) Ok(())
} }
@ -801,11 +815,12 @@ async fn main() {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
match windows_service::service_dispatcher::start(String::new(), ffi_service_main) { match windows_service::service_dispatcher::start(String::new(), ffi_service_main) {
Ok(_) => std::thread::park(), Ok(_) => std::thread::park(),
Err(e) => Err(e) => {
{
let should_panic = if let windows_service::Error::Winapi(ref io_error) = 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 io_error.raw_os_error() != Some(0x427) // ERROR_FAILED_SERVICE_CONTROLLER_CONNECT
} else { true }; } else {
true
};
if should_panic { if should_panic {
panic!("SCM start an error: {}", e); panic!("SCM start an error: {}", e);

View File

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

View File

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

View File

@ -100,7 +100,10 @@ impl WebClientService for Controller {
_: BaseController, _: BaseController,
req: RunNetworkInstanceRequest, req: RunNetworkInstanceRequest,
) -> Result<RunNetworkInstanceResponse, rpc_types::error::Error> { ) -> Result<RunNetworkInstanceResponse, rpc_types::error::Error> {
let cfg = TomlConfigLoader::new_from_str(&req.config)?; if req.config.is_none() {
return Err(anyhow::anyhow!("config is required").into());
}
let cfg = req.config.unwrap().gen_config()?;
let id = cfg.get_id(); let id = cfg.get_id();
if let Some(inst_id) = req.inst_id { if let Some(inst_id) = req.inst_id {
cfg.set_id(inst_id.into()); 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-ping`
- `allow-start-vpn` - `allow-start-vpn`
### Permission Table ## Permission Table
<table> <table>
<tr> <tr>

View File

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