diff --git a/Cargo.lock b/Cargo.lock index 252f03b..54cb749 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2006,6 +2006,7 @@ dependencies = [ "sea-orm", "sea-orm-migration", "serde", + "serde_json", "sqlx", "thiserror", "tokio", diff --git a/easytier-web/Cargo.toml b/easytier-web/Cargo.toml index 9d1cde5..1212a0a 100644 --- a/easytier-web/Cargo.toml +++ b/easytier-web/Cargo.toml @@ -43,6 +43,7 @@ clap = { version = "4.4.8", features = [ "wrap_help", ] } serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" uuid = { version = "1.5.0", features = [ "v4", "fast-rng", diff --git a/easytier-web/frontend-lib/package.json b/easytier-web/frontend-lib/package.json index b9a27b6..782334b 100644 --- a/easytier-web/frontend-lib/package.json +++ b/easytier-web/frontend-lib/package.json @@ -21,6 +21,7 @@ "@primevue/themes": "^4.2.1", "@vueuse/core": "^11.1.0", "aura": "link:@primevue\\themes\\aura", + "axios": "^1.7.7", "ip-num": "1.5.1", "primeicons": "^7.0.0", "primevue": "^4.2.1", diff --git a/easytier-web/frontend-lib/src/components/HumanEvent.vue b/easytier-web/frontend-lib/src/components/HumanEvent.vue new file mode 100644 index 0000000..b5ddc48 --- /dev/null +++ b/easytier-web/frontend-lib/src/components/HumanEvent.vue @@ -0,0 +1,35 @@ + + + diff --git a/easytier-web/frontend-lib/src/components/Status.vue b/easytier-web/frontend-lib/src/components/Status.vue index da84063..538ab2d 100644 --- a/easytier-web/frontend-lib/src/components/Status.vue +++ b/easytier-web/frontend-lib/src/components/Status.vue @@ -181,7 +181,7 @@ const myNodeInfoChips = computed(() => { const listeners = my_node_info.listeners for (const [idx, listener] of listeners?.entries()) { chips.push({ - label: `Listener ${idx}: ${listener}`, + label: `Listener ${idx}: ${listener.url}`, icon: '', } as Chip) } @@ -295,7 +295,7 @@ function showEventLogs() { if (!detail) return - dialogContent.value = detail.events + dialogContent.value = detail.events.map((event: string) => JSON.parse(event)) dialogHeader.value = 'event_log' dialogVisible.value = true } @@ -309,10 +309,11 @@ function showEventLogs() { diff --git a/easytier-web/frontend-lib/src/easytier-frontend-lib.ts b/easytier-web/frontend-lib/src/easytier-frontend-lib.ts index 697f047..ae3be67 100644 --- a/easytier-web/frontend-lib/src/easytier-frontend-lib.ts +++ b/easytier-web/frontend-lib/src/easytier-frontend-lib.ts @@ -7,6 +7,10 @@ import PrimeVue from 'primevue/config' import I18nUtils from './modules/i18n' import * as NetworkTypes from './types/network' +import HumanEvent from './components/HumanEvent.vue'; +import Tooltip from 'primevue/tooltip'; +import * as Api from './modules/api'; +import * as Utils from './modules/utils'; export default { install: (app: App) => { @@ -27,7 +31,9 @@ export default { app.component('Config', Config); app.component('Status', Status); + app.component('HumanEvent', HumanEvent); + app.directive('tooltip', Tooltip); } }; -export { Config, Status, I18nUtils, NetworkTypes }; +export { Config, Status, I18nUtils, NetworkTypes, Api, Utils }; diff --git a/easytier-web/frontend/src/modules/api.ts b/easytier-web/frontend-lib/src/modules/api.ts similarity index 60% rename from easytier-web/frontend/src/modules/api.ts rename to easytier-web/frontend-lib/src/modules/api.ts index 5d1c91b..a507fb0 100644 --- a/easytier-web/frontend/src/modules/api.ts +++ b/easytier-web/frontend-lib/src/modules/api.ts @@ -11,8 +11,8 @@ export interface LoginResponse { } export interface RegisterResponse { + success: boolean; message: string; - user: any; // 同上 } // 定义请求体数据结构 @@ -22,21 +22,27 @@ export interface Credential { } export interface RegisterData { - credential: Credential; + credentials: Credential; captcha: string; } -class ApiClient { - private client: AxiosInstance; +export interface Summary { + 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({ - baseURL: baseUrl, + baseURL: baseUrl + '/api/v1', withCredentials: true, // 如果需要支持跨域携带cookie headers: { 'Content-Type': 'application/json', }, }); + this.authFailedCb = authFailedCb; // 添加请求拦截器 this.client.interceptors.request.use((config: InternalAxiosRequestConfig) => { @@ -47,12 +53,18 @@ class ApiClient { // 添加响应拦截器 this.client.interceptors.response.use((response: AxiosResponse) => { - console.log('Axios Response:', response); + console.debug('Axios Response:', response); return response.data; // 假设服务器返回的数据都在data属性中 }, (error: any) => { if (error.response) { - // 请求已发出,但是服务器响应的状态码不在2xx范围 - console.error('Response Error:', error.response.data); + let response: AxiosResponse = error.response; + if (response.status == 401 && this.authFailedCb) { + console.error('Unauthorized:', response.data); + this.authFailedCb(); + } else { + // 请求已发出,但是服务器响应的状态码不在2xx范围 + console.error('Response Error:', error.response.data); + } } else if (error.request) { // 请求已发出,但是没有收到响应 console.error('Request Error:', error.request); @@ -64,6 +76,20 @@ class ApiClient { }); } + // 注册 + public async register(data: RegisterData): Promise { + try { + const response = await this.client.post('/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 { try { @@ -82,10 +108,24 @@ class ApiClient { } } - // 注册 - public async register(data: RegisterData): Promise { - const response = await this.client.post('/auth/register', data); - return response.data; + public async logout() { + await this.client.get('/auth/logout'); + if (this.authFailedCb) { + this.authFailedCb(); + } + } + + public async change_password(new_password: string) { + await this.client.put('/auth/password', { new_password: new_password }); + } + + public async check_login_status() { + try { + await this.client.get('/auth/check_login_status'); + return true; + } catch (error) { + return false; + } } public async list_session() { @@ -103,6 +143,11 @@ class ApiClient { return response.info.map; } + public async get_network_config(machine_id: string, inst_id: string): Promise { + const response = await this.client.get>('/machines/' + machine_id + '/networks/config/' + inst_id); + return response; + } + public async validate_config(machine_id: string, config: any): Promise { const response = await this.client.post(`/machines/${machine_id}/validate-config`, { config: config, @@ -110,7 +155,7 @@ class ApiClient { return response; } - public async run_network(machine_id: string, config: string): Promise { + public async run_network(machine_id: string, config: any): Promise { await this.client.post(`/machines/${machine_id}/networks`, { config: config, }); @@ -120,8 +165,13 @@ class ApiClient { await this.client.delete(`/machines/${machine_id}/networks/${inst_id}`); } + public async get_summary(): Promise { + const response = await this.client.get('/summary'); + return response; + } + public captcha_url() { - return this.client.defaults.baseURL + 'auth/captcha'; + return this.client.defaults.baseURL + '/auth/captcha'; } } diff --git a/easytier-web/frontend-lib/src/modules/utils.ts b/easytier-web/frontend-lib/src/modules/utils.ts index 85b1532..c2752f2 100644 --- a/easytier-web/frontend-lib/src/modules/utils.ts +++ b/easytier-web/frontend-lib/src/modules/utils.ts @@ -13,3 +13,89 @@ export function num2ipv6(ip: Ipv6Addr) { + BigInt(ip.part4), ) } + +function toHexString(uint64: bigint, padding = 9): string { + let hexString = uint64.toString(16); + while (hexString.length < padding) { + hexString = '0' + hexString; + } + return hexString; +} + +function uint32ToUuid(part1: number, part2: number, part3: number, part4: number): string { + // 将两个 uint64 转换为 16 进制字符串 + const part1Hex = toHexString(BigInt(part1), 8); + const part2Hex = toHexString(BigInt(part2), 8); + const part3Hex = toHexString(BigInt(part3), 8); + const part4Hex = toHexString(BigInt(part4), 8); + + // 构造 UUID 格式字符串 + const uuid = `${part1Hex.substring(0, 8)}-${part2Hex.substring(0, 4)}-${part2Hex.substring(4, 8)}-${part3Hex.substring(0, 4)}-${part3Hex.substring(4, 8)}${part4Hex.substring(0, 12)}`; + + return uuid; +} + +export interface UUID { + part1: number; + part2: number; + part3: number; + part4: number; +} + +export function UuidToStr(uuid: UUID): string { + return uint32ToUuid(uuid.part1, uuid.part2, uuid.part3, uuid.part4); +} + +export interface DeviceInfo { + hostname: string; + public_ip: string; + running_network_count: number; + report_time: string; + easytier_version: string; + running_network_instances?: Array; + 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) | undefined; + private timer: any; + + constructor(task: () => Promise, 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); + } +} diff --git a/easytier-web/frontend-lib/src/types/network.ts b/easytier-web/frontend-lib/src/types/network.ts index 3fbce7b..f509696 100644 --- a/easytier-web/frontend-lib/src/types/network.ts +++ b/easytier-web/frontend-lib/src/types/network.ts @@ -84,7 +84,7 @@ export interface NetworkInstance { export interface NetworkInstanceRunningInfo { dev_name: string my_node_info: NodeInfo - events: Record + events: Array, node_info: NodeInfo routes: Route[] peers: PeerInfo[] @@ -104,6 +104,10 @@ export interface Ipv6Addr { part4: number } +export interface Url { + url: string +} + export interface NodeInfo { virtual_ipv4: string hostname: string @@ -127,7 +131,7 @@ export interface NodeInfo { }[] } stun_info: StunInfo - listeners: string[] + listeners: Url[] vpn_portal_cfg?: string } diff --git a/easytier-web/frontend/index.html b/easytier-web/frontend/index.html index dde16aa..cc73f06 100644 --- a/easytier-web/frontend/index.html +++ b/easytier-web/frontend/index.html @@ -2,9 +2,9 @@ - + - Vite + Vue + TS + EasyTier Dashboard
diff --git a/easytier-web/frontend/package.json b/easytier-web/frontend/package.json index bd36fd5..730aa08 100644 --- a/easytier-web/frontend/package.json +++ b/easytier-web/frontend/package.json @@ -15,7 +15,8 @@ "easytier-frontend-lib": "workspace:*", "primevue": "^4.2.1", "tailwindcss-primeui": "^0.3.4", - "vue": "^3.5.12" + "vue": "^3.5.12", + "vue-router": "4" }, "devDependencies": { "@types/node": "^22.8.6", diff --git a/easytier-web/frontend/public/easytier.png b/easytier-web/frontend/public/easytier.png new file mode 100644 index 0000000..b59ea0c Binary files /dev/null and b/easytier-web/frontend/public/easytier.png differ diff --git a/easytier-web/frontend/public/vite.svg b/easytier-web/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/easytier-web/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/easytier-web/frontend/src/App.vue b/easytier-web/frontend/src/App.vue index 15b1486..86aeba9 100644 --- a/easytier-web/frontend/src/App.vue +++ b/easytier-web/frontend/src/App.vue @@ -2,12 +2,7 @@ import { I18nUtils } from 'easytier-frontend-lib' import { onMounted } from 'vue'; -import Login from './components/Login.vue' -import { Button } from 'primevue'; -import ApiClient from './modules/api'; -import DeviceList from './components/DeviceList.vue'; - -const api = new ApiClient('http://10.147.223.128:11211/api/v1/'); // Replace with actual API URL +import { Toast, DynamicDialog } from 'primevue'; onMounted(async () => { await I18nUtils.loadLanguageAsync('cn') @@ -18,109 +13,10 @@ onMounted(async () => { diff --git a/easytier-web/frontend/src/components/DeviceManagement.vue b/easytier-web/frontend/src/components/DeviceManagement.vue new file mode 100644 index 0000000..48e48de --- /dev/null +++ b/easytier-web/frontend/src/components/DeviceManagement.vue @@ -0,0 +1,197 @@ + + + \ No newline at end of file diff --git a/easytier-web/frontend/src/components/Login.vue b/easytier-web/frontend/src/components/Login.vue index 4cca0ba..4093092 100644 --- a/easytier-web/frontend/src/components/Login.vue +++ b/easytier-web/frontend/src/components/Login.vue @@ -1,3 +1,65 @@ + + @@ -52,42 +119,4 @@ - - - + \ No newline at end of file diff --git a/easytier-web/frontend/src/components/MainPage.vue b/easytier-web/frontend/src/components/MainPage.vue new file mode 100644 index 0000000..4163567 --- /dev/null +++ b/easytier-web/frontend/src/components/MainPage.vue @@ -0,0 +1,173 @@ + + + + + + diff --git a/easytier-web/frontend/src/main.ts b/easytier-web/frontend/src/main.ts index ecbdaed..1512c48 100644 --- a/easytier-web/frontend/src/main.ts +++ b/easytier-web/frontend/src/main.ts @@ -7,6 +7,72 @@ import PrimeVue from 'primevue/config' import Aura from '@primevue/themes/aura' import ConfirmationService from 'primevue/confirmationservice'; +import { createRouter, createWebHashHistory } from 'vue-router' +import MainPage from './components/MainPage.vue' +import Login from './components/Login.vue' +import DeviceList from './components/DeviceList.vue' +import DeviceManagement from './components/DeviceManagement.vue' +import Dashboard from './components/Dashboard.vue' +import DialogService from 'primevue/dialogservice'; +import ToastService from 'primevue/toastservice'; + +const routes = [ + { + path: '/auth', children: [ + { + name: 'login', + path: '', + component: Login, + alias: 'login', + props: { isRegistering: false } + }, + { + name: 'register', + path: 'register', + component: Login, + props: { isRegistering: true } + } + ] + }, + { + path: '/h/:apiHost', component: MainPage, children: [ + { + path: '', + alias: 'dashboard', + name: 'dashboard', + component: Dashboard, + }, + { + path: 'deviceList', + name: 'deviceList', + component: DeviceList, + children: [ + { + path: 'device/:deviceId/:instanceId?', + name: 'deviceManagement', + component: DeviceManagement, + } + ] + }, + ] + }, + { + path: '/:pathMatch(.*)*', name: 'notFound', redirect: () => { + let apiHost = localStorage.getItem('apiHost'); + if (apiHost) { + return { name: 'dashboard', params: { apiHost: apiHost } } + } else { + return { name: 'login' } + } + } + }, +] + +const router = createRouter({ + history: createWebHashHistory(), + routes, +}) + createApp(App).use(PrimeVue, { theme: { @@ -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') diff --git a/easytier-web/frontend/tsconfig.app.json b/easytier-web/frontend/tsconfig.app.json index cb88a5a..761f1e4 100644 --- a/easytier-web/frontend/tsconfig.app.json +++ b/easytier-web/frontend/tsconfig.app.json @@ -22,5 +22,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "../frontend-lib/src/modules/api.ts"] } diff --git a/easytier-web/src/client_manager/session.rs b/easytier-web/src/client_manager/session.rs index 2294947..5ad2a18 100644 --- a/easytier-web/src/client_manager/session.rs +++ b/easytier-web/src/client_manager/session.rs @@ -6,8 +6,9 @@ use easytier::{ rpc_impl::bidirect::BidirectRpcManager, rpc_types::{self, controller::BaseController}, web::{ - HeartbeatRequest, HeartbeatResponse, RunNetworkInstanceRequest, WebClientService, - WebClientServiceClientFactory, WebServerService, WebServerServiceServer, + HeartbeatRequest, HeartbeatResponse, NetworkConfig, RunNetworkInstanceRequest, + WebClientService, WebClientServiceClientFactory, WebServerService, + WebServerServiceServer, }, }, tunnel::Tunnel, @@ -160,7 +161,13 @@ impl Session { ); return; } + let req = req.unwrap(); + if req.machine_id.is_none() { + tracing::warn!(?req, "Machine id is not set, ignore"); + continue; + } + let running_inst_ids = req .running_network_instances .iter() @@ -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, Err(e) => { tracing::error!("Failed to list network configs, error: {:?}", e); @@ -206,7 +217,9 @@ impl Session { BaseController::default(), RunNetworkInstanceRequest { inst_id: Some(c.network_instance_id.clone().into()), - config: c.network_config, + config: Some( + serde_json::from_str::(&c.network_config).unwrap(), + ), }, ) .await; diff --git a/easytier-web/src/client_manager/storage.rs b/easytier-web/src/client_manager/storage.rs index 772fed0..5c974f3 100644 --- a/easytier-web/src/client_manager/storage.rs +++ b/easytier-web/src/client_manager/storage.rs @@ -16,7 +16,7 @@ pub struct StorageToken { pub struct StorageInner { // some map for indexing pub token_clients_map: DashMap>, - pub machine_client_url_map: DashMap, + pub machine_client_url_map: DashMap>, pub db: Db, } @@ -51,7 +51,9 @@ impl Storage { self.0 .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) { @@ -60,7 +62,12 @@ impl Storage { 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 { @@ -71,7 +78,8 @@ impl Storage { self.0 .machine_client_url_map .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 { diff --git a/easytier-web/src/db/entity/user_running_network_configs.rs b/easytier-web/src/db/entity/user_running_network_configs.rs index 0580e59..8a98d56 100644 --- a/easytier-web/src/db/entity/user_running_network_configs.rs +++ b/easytier-web/src/db/entity/user_running_network_configs.rs @@ -9,6 +9,8 @@ pub struct Model { #[sea_orm(primary_key)] pub id: i32, pub user_id: i32, + #[sea_orm(column_type = "Text")] + pub device_id: String, #[sea_orm(column_type = "Text", unique)] pub network_instance_id: String, #[sea_orm(column_type = "Text")] diff --git a/easytier-web/src/db/mod.rs b/easytier-web/src/db/mod.rs index 6db3dab..d2704ef 100644 --- a/easytier-web/src/db/mod.rs +++ b/easytier-web/src/db/mod.rs @@ -65,6 +65,7 @@ impl Db { pub async fn insert_or_update_user_network_config( &self, user_id: UserIdInDb, + device_id: uuid::Uuid, network_inst_id: uuid::Uuid, network_config: T, ) -> Result<(), DbErr> { @@ -81,6 +82,7 @@ impl Db { .to_owned(); let insert_m = urnc::ActiveModel { user_id: sea_orm::Set(user_id), + device_id: sea_orm::Set(device_id.to_string()), network_instance_id: sea_orm::Set(network_inst_id.to_string()), network_config: sea_orm::Set(network_config.to_string()), disabled: sea_orm::Set(false), @@ -116,6 +118,7 @@ impl Db { pub async fn list_network_configs( &self, user_id: UserIdInDb, + device_id: Option, only_enabled: bool, ) -> Result, DbErr> { use entity::user_running_network_configs as urnc; @@ -126,6 +129,11 @@ impl Db { } else { configs }; + let configs = if let Some(device_id) = device_id { + configs.filter(urnc::Column::DeviceId.eq(device_id.to_string())) + } else { + configs + }; let configs = configs.all(self.orm_db()).await?; @@ -167,8 +175,9 @@ mod tests { let user_id = 1; let network_config = "test_config"; let inst_id = uuid::Uuid::new_v4(); + let device_id = uuid::Uuid::new_v4(); - db.insert_or_update_user_network_config(user_id, inst_id, network_config) + db.insert_or_update_user_network_config(user_id, device_id, inst_id, network_config) .await .unwrap(); @@ -183,7 +192,7 @@ mod tests { // overwrite the config 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 .unwrap(); @@ -193,14 +202,17 @@ mod tests { .await .unwrap() .unwrap(); - println!("{:?}", result2); + println!("device: {}, {:?}", device_id, result2); assert_eq!(result2.network_config, network_config); assert_eq!(result.create_time, result2.create_time); assert_ne!(result.update_time, result2.update_time); assert_eq!( - db.list_network_configs(user_id, true).await.unwrap().len(), + db.list_network_configs(user_id, Some(device_id), true) + .await + .unwrap() + .len(), 1 ); diff --git a/easytier-web/src/migrator/m20241029_000001_init.rs b/easytier-web/src/migrator/m20241029_000001_init.rs index dc9b871..37d6e56 100644 --- a/easytier-web/src/migrator/m20241029_000001_init.rs +++ b/easytier-web/src/migrator/m20241029_000001_init.rs @@ -4,94 +4,6 @@ use sea_orm_migration::{prelude::*, schema::*}; pub struct Migration; -/* --- # Entity schema. - --- Create `users` table. -create table if not exists users ( - id integer primary key autoincrement, - username text not null unique, - password text not null -); - --- Create `groups` table. -create table if not exists groups ( - id integer primary key autoincrement, - name text not null unique -); - --- Create `permissions` table. -create table if not exists permissions ( - id integer primary key autoincrement, - name text not null unique -); - - --- # Join tables. - --- Create `users_groups` table for many-to-many relationships between users and groups. -create table if not exists users_groups ( - user_id integer references users(id), - group_id integer references groups(id), - primary key (user_id, group_id) -); - --- Create `groups_permissions` table for many-to-many relationships between groups and permissions. -create table if not exists groups_permissions ( - group_id integer references groups(id), - permission_id integer references permissions(id), - primary key (group_id, permission_id) -); - - --- # Fixture hydration. - --- Insert "user" user. password: "user" -insert into users (username, password) -values ( - 'user', - '$argon2i$v=19$m=16,t=2,p=1$dHJ5dXZkYmZkYXM$UkrNqWz0BbSVBq4ykLSuJw' -); - --- Insert "admin" user. password: "admin" -insert into users (username, password) -values ( - 'admin', - '$argon2i$v=19$m=16,t=2,p=1$Ymd1Y2FlcnQ$x0q4oZinW9S1ZB9BcaHEpQ' -); - --- Insert "users" and "superusers" groups. -insert into groups (name) values ('users'); -insert into groups (name) values ('superusers'); - --- Insert individual permissions. -insert into permissions (name) values ('sessions'); -insert into permissions (name) values ('devices'); - --- Insert group permissions. -insert into groups_permissions (group_id, permission_id) -values ( - (select id from groups where name = 'users'), - (select id from permissions where name = 'devices') -), ( - (select id from groups where name = 'superusers'), - (select id from permissions where name = 'sessions') -); - --- Insert users into groups. -insert into users_groups (user_id, group_id) -values ( - (select id from users where username = 'user'), - (select id from groups where name = 'users') -), ( - (select id from users where username = 'admin'), - (select id from groups where name = 'users') -), ( - (select id from users where username = 'admin'), - (select id from groups where name = 'superusers') -); - */ - impl MigrationName for Migration { fn name(&self) -> &str { "m20241029_000001_init" @@ -141,6 +53,7 @@ enum UserRunningNetworkConfigs { Table, Id, UserId, + DeviceId, NetworkInstanceId, NetworkConfig, Disabled, @@ -273,6 +186,7 @@ impl MigrationTrait for Migration { .table(UserRunningNetworkConfigs::Table) .col(pk_auto(UserRunningNetworkConfigs::Id).not_null()) .col(integer(UserRunningNetworkConfigs::UserId).not_null()) + .col(text(UserRunningNetworkConfigs::DeviceId).not_null()) .col( text(UserRunningNetworkConfigs::NetworkInstanceId) .unique_key() diff --git a/easytier-web/src/restful/auth.rs b/easytier-web/src/restful/auth.rs index 23068b7..8bb3aad 100644 --- a/easytier-web/src/restful/auth.rs +++ b/easytier-web/src/restful/auth.rs @@ -22,6 +22,10 @@ pub struct LoginResult { pub fn router() -> Router { let r = Router::new() .route("/api/v1/auth/password", put(self::put::change_password)) + .route( + "/api/v1/auth/check_login_status", + get(self::get::check_login_status), + ) .route_layer(login_required!(Backend)); Router::new() .merge(r) @@ -168,4 +172,17 @@ mod get { )), } } + + pub async fn check_login_status( + auth_session: AuthSession, + ) -> Result, HttpHandleError> { + if auth_session.user.is_some() { + Ok(Json(Void::default())) + } else { + Err(( + StatusCode::UNAUTHORIZED, + Json::from(other_error("Not logged in")), + )) + } + } } diff --git a/easytier-web/src/restful/mod.rs b/easytier-web/src/restful/mod.rs index 3c3f24d..bb6387a 100644 --- a/easytier-web/src/restful/mod.rs +++ b/easytier-web/src/restful/mod.rs @@ -11,7 +11,7 @@ use axum_login::tower_sessions::{ExpiredDeletion, SessionManagerLayer}; use axum_login::{login_required, AuthManagerLayerBuilder, AuthzBackend}; use axum_messages::MessagesManagerLayer; use easytier::common::scoped_task::ScopedTask; -use easytier::proto::{rpc_types}; +use easytier::proto::rpc_types; use network::NetworkApi; use sea_orm::DbErr; use tokio::net::TcpListener; @@ -43,6 +43,11 @@ type AppState = State; #[derive(Debug, serde::Deserialize, serde::Serialize)] struct ListSessionJsonResp(Vec); +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct GetSummaryJsonResp { + device_count: u32, +} + #[derive(Debug, serde::Deserialize, serde::Serialize)] pub struct Error { message: String, @@ -98,16 +103,32 @@ impl RestfulServer { auth_session: AuthSession, State(client_mgr): AppState, ) -> Result, HttpHandleError> { - let pers = auth_session + let perms = auth_session .backend .get_group_permissions(auth_session.user.as_ref().unwrap()) .await .unwrap(); - println!("{:?}", pers); + println!("{:?}", perms); let ret = client_mgr.list_sessions().await; Ok(ListSessionJsonResp(ret).into()) } + async fn handle_get_summary( + auth_session: AuthSession, + State(client_mgr): AppState, + ) -> Result, HttpHandleError> { + let Some(user) = auth_session.user else { + return Err((StatusCode::UNAUTHORIZED, other_error("No such user").into())); + }; + + let machines = client_mgr.list_machine_by_token(user.tokens[0].clone()); + + Ok(GetSummaryJsonResp { + device_count: machines.len() as u32, + } + .into()) + } + pub async fn start(&mut self) -> Result<(), anyhow::Error> { let listener = TcpListener::bind(self.bind_addr).await?; @@ -143,6 +164,7 @@ impl RestfulServer { let auth_layer = AuthManagerLayerBuilder::new(backend, session_layer).build(); let app = Router::new() + .route("/api/v1/summary", get(Self::handle_get_summary)) .route("/api/v1/sessions", get(Self::handle_list_all_sessions)) .merge(self.network_api.build_route()) .route_layer(login_required!(Backend)) diff --git a/easytier-web/src/restful/network.rs b/easytier-web/src/restful/network.rs index a74dc8f..83b1cf9 100644 --- a/easytier-web/src/restful/network.rs +++ b/easytier-web/src/restful/network.rs @@ -9,7 +9,7 @@ use dashmap::DashSet; use easytier::launcher::NetworkConfig; use easytier::proto::common::Void; use easytier::proto::rpc_types::controller::BaseController; -use easytier::proto::{web::*}; +use easytier::proto::web::*; use crate::client_manager::session::Session; use crate::client_manager::ClientManager; @@ -38,7 +38,7 @@ struct ValidateConfigJsonReq { #[derive(Debug, serde::Deserialize, serde::Serialize)] struct RunNetworkJsonReq { - config: String, + config: NetworkConfig, } #[derive(Debug, serde::Deserialize, serde::Serialize)] @@ -145,7 +145,7 @@ impl NetworkApi { BaseController::default(), RunNetworkInstanceRequest { inst_id: None, - config: config.clone(), + config: Some(config.clone()), }, ) .await @@ -155,8 +155,9 @@ impl NetworkApi { .db() .insert_or_update_user_network_config( auth_session.user.as_ref().unwrap().id(), + machine_id, resp.inst_id.clone().unwrap_or_default().into(), - config, + serde_json::to_string(&config).unwrap(), ) .await .map_err(convert_db_error)?; @@ -288,6 +289,36 @@ impl NetworkApi { Ok(Json(ListMachineJsonResp { machines })) } + async fn handle_get_network_config( + auth_session: AuthSession, + State(client_mgr): AppState, + Path((machine_id, inst_id)): Path<(uuid::Uuid, uuid::Uuid)>, + ) -> Result, 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::(&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 { Router::new() .route("/api/v1/machines", get(Self::handle_list_machines)) @@ -311,5 +342,9 @@ impl NetworkApi { "/api/v1/machines/:machine-id/networks/info/:inst-id", get(Self::handle_collect_one_network_info), ) + .route( + "/api/v1/machines/:machine-id/networks/config/:inst-id", + get(Self::handle_get_network_config), + ) } } diff --git a/easytier/src/launcher.rs b/easytier/src/launcher.rs index 9657bac..236b352 100644 --- a/easytier/src/launcher.rs +++ b/easytier/src/launcher.rs @@ -23,8 +23,14 @@ use tokio::{sync::broadcast, task::JoinSet}; pub type MyNodeInfo = crate::proto::web::MyNodeInfo; +#[derive(serde::Serialize, Clone)] +pub struct Event { + time: DateTime, + event: GlobalCtxEvent, +} + struct EasyTierData { - events: RwLock, GlobalCtxEvent)>>, + events: RwLock>, node_info: RwLock, routes: RwLock>, peers: RwLock>, @@ -79,9 +85,12 @@ impl EasyTierLauncher { async fn handle_easytier_event(event: GlobalCtxEvent, data: &EasyTierData) { let mut events = data.events.write().unwrap(); let _ = data.event_subscriber.read().unwrap().send(event.clone()); - events.push_back((chrono::Local::now(), event)); - if events.len() > 100 { - events.pop_front(); + events.push_front(Event { + time: chrono::Local::now(), + event: event, + }); + if events.len() > 20 { + events.pop_back(); } } @@ -267,7 +276,7 @@ impl EasyTierLauncher { self.data.tun_dev_name.read().unwrap().clone() } - pub fn get_events(&self) -> Vec<(DateTime, GlobalCtxEvent)> { + pub fn get_events(&self) -> Vec { let events = self.data.events.read().unwrap(); events.iter().cloned().collect() } @@ -341,7 +350,7 @@ impl NetworkInstance { events: launcher .get_events() .iter() - .map(|(t, e)| (t.to_string(), format!("{:?}", e))) + .map(|e| serde_json::to_string(e).unwrap()) .collect(), node_info: Some(launcher.get_node_info()), routes, diff --git a/easytier/src/proto/web.proto b/easytier/src/proto/web.proto index 0539bf2..1e1e972 100644 --- a/easytier/src/proto/web.proto +++ b/easytier/src/proto/web.proto @@ -55,7 +55,7 @@ message MyNodeInfo { message NetworkInstanceRunningInfo { string dev_name = 1; MyNodeInfo my_node_info = 2; - map events = 3; + repeated string events = 3; MyNodeInfo node_info = 4; repeated cli.Route routes = 5; repeated cli.PeerInfo peers = 6; @@ -97,7 +97,7 @@ message ValidateConfigResponse { message RunNetworkInstanceRequest { common.UUID inst_id = 1; - string config = 2; + NetworkConfig config = 2; } message RunNetworkInstanceResponse { diff --git a/easytier/src/web_client/controller.rs b/easytier/src/web_client/controller.rs index 40a2fa3..0d87d45 100644 --- a/easytier/src/web_client/controller.rs +++ b/easytier/src/web_client/controller.rs @@ -100,7 +100,10 @@ impl WebClientService for Controller { _: BaseController, req: RunNetworkInstanceRequest, ) -> Result { - 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(); if let Some(inst_id) = req.inst_id { cfg.set_id(inst_id.into()); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27a9b37..c04d90a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,6 +162,9 @@ importers: vue: specifier: ^3.5.12 version: 3.5.12(typescript@5.6.3) + vue-router: + specifier: '4' + version: 4.4.5(vue@3.5.12(typescript@5.6.3)) devDependencies: '@types/node': specifier: ^22.8.6 @@ -202,6 +205,9 @@ importers: aura: specifier: link:@primevue\themes\aura version: link:@primevue/themes/aura + axios: + specifier: ^1.7.7 + version: 1.7.7 ip-num: specifier: 1.5.1 version: 1.5.1 @@ -8091,6 +8097,11 @@ snapshots: '@vue/devtools-api': 6.6.4 vue: 3.4.38(typescript@5.6.3) + vue-router@4.4.5(vue@3.5.12(typescript@5.6.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.12(typescript@5.6.3) + vue-tsc@2.1.10(typescript@5.6.3): dependencies: '@volar/typescript': 2.4.8