diff --git a/remote_backend/src/controllers/group_controller.rs b/remote_backend/src/controllers/group_controller.rs new file mode 100644 index 0000000..ad5deb7 --- /dev/null +++ b/remote_backend/src/controllers/group_controller.rs @@ -0,0 +1,79 @@ +use crate::controllers::HttpResult; +use crate::virtweb_client; +use crate::virtweb_client::{GroupID, VMUuid}; +use actix_web::{web, HttpResponse}; + +#[derive(serde::Deserialize)] +pub struct GroupIDInPath { + gid: GroupID, +} + +#[derive(serde::Deserialize)] +pub struct VMIDInQuery { + vm_id: Option, +} + +/// Get the state of one or all VM +pub async fn vm_state( + path: web::Path, + query: web::Query, +) -> HttpResult { + Ok(HttpResponse::Ok().json(virtweb_client::group_vm_state(&path.gid, query.vm_id).await?)) +} + +/// Start one or all VM +pub async fn vm_start( + path: web::Path, + query: web::Query, +) -> HttpResult { + Ok(HttpResponse::Ok().json(virtweb_client::group_vm_start(&path.gid, query.vm_id).await?)) +} + +/// Shutdown one or all VM +pub async fn vm_shutdown( + path: web::Path, + query: web::Query, +) -> HttpResult { + Ok(HttpResponse::Ok().json(virtweb_client::group_vm_shutdown(&path.gid, query.vm_id).await?)) +} + +/// Kill one or all VM +pub async fn vm_kill(path: web::Path, query: web::Query) -> HttpResult { + Ok(HttpResponse::Ok().json(virtweb_client::group_vm_kill(&path.gid, query.vm_id).await?)) +} + +/// Reset one or all VM +pub async fn vm_reset( + path: web::Path, + query: web::Query, +) -> HttpResult { + Ok(HttpResponse::Ok().json(virtweb_client::group_vm_reset(&path.gid, query.vm_id).await?)) +} + +/// Suspend one or all VM +pub async fn vm_suspend( + path: web::Path, + query: web::Query, +) -> HttpResult { + Ok(HttpResponse::Ok().json(virtweb_client::group_vm_suspend(&path.gid, query.vm_id).await?)) +} + +/// Resume one or all VM +pub async fn vm_resume( + path: web::Path, + query: web::Query, +) -> HttpResult { + Ok(HttpResponse::Ok().json(virtweb_client::group_vm_resume(&path.gid, query.vm_id).await?)) +} + +/// Screenshot one or all VM +pub async fn vm_screenshot( + path: web::Path, + query: web::Query, +) -> HttpResult { + let screenshot = virtweb_client::group_vm_screenshot(&path.gid, query.vm_id).await?; + + Ok(HttpResponse::Ok() + .insert_header(("content-type", "image/png")) + .body(screenshot)) +} diff --git a/remote_backend/src/controllers/mod.rs b/remote_backend/src/controllers/mod.rs index c0d541f..02ad6b9 100644 --- a/remote_backend/src/controllers/mod.rs +++ b/remote_backend/src/controllers/mod.rs @@ -6,6 +6,7 @@ use std::fmt::{Display, Formatter}; use std::io::ErrorKind; pub mod auth_controller; +pub mod group_controller; pub mod server_controller; pub mod static_controller; pub mod sys_info_controller; diff --git a/remote_backend/src/controllers/server_controller.rs b/remote_backend/src/controllers/server_controller.rs index 165174b..962dc05 100644 --- a/remote_backend/src/controllers/server_controller.rs +++ b/remote_backend/src/controllers/server_controller.rs @@ -2,7 +2,7 @@ use crate::app_config::AppConfig; use crate::controllers::HttpResult; use crate::extractors::auth_extractor::AuthExtractor; use crate::virtweb_client; -use crate::virtweb_client::VMUuid; +use crate::virtweb_client::{GroupID, VMCaps, VMInfo}; use actix_web::HttpResponse; #[derive(serde::Serialize)] @@ -20,54 +20,70 @@ pub async fn config(auth: AuthExtractor) -> HttpResult { #[derive(Default, Debug, serde::Serialize)] pub struct Rights { + groups: Vec, vms: Vec, sys_info: bool, } +#[derive(Debug, serde::Serialize)] +pub struct GroupInfo { + id: GroupID, + vms: Vec, + #[serde(flatten)] + caps: VMCaps, +} + #[derive(Debug, serde::Serialize)] pub struct VMInfoAndCaps { - uiid: VMUuid, - name: String, - description: Option, - architecture: String, - memory: usize, - number_vcpu: usize, - can_get_state: bool, - can_start: bool, - can_shutdown: bool, - can_kill: bool, - can_reset: bool, - can_suspend: bool, - can_resume: bool, - can_screenshot: bool, + #[serde(flatten)] + info: VMInfo, + #[serde(flatten)] + caps: VMCaps, } pub async fn rights() -> HttpResult { let rights = virtweb_client::get_token_info().await?; let mut res = Rights { + groups: vec![], vms: vec![], sys_info: rights.can_retrieve_system_info(), }; + for g in rights.list_groups() { + let group_vms = virtweb_client::group_vm_info(&g).await?; + + res.groups.push(GroupInfo { + id: g.clone(), + vms: group_vms, + caps: VMCaps { + can_get_state: rights.is_route_allowed("GET", &g.route_vm_state(None)), + can_start: rights.is_route_allowed("GET", &g.route_vm_start(None)), + can_shutdown: rights.is_route_allowed("GET", &g.route_vm_shutdown(None)), + can_kill: rights.is_route_allowed("GET", &g.route_vm_kill(None)), + can_reset: rights.is_route_allowed("GET", &g.route_vm_reset(None)), + can_suspend: rights.is_route_allowed("GET", &g.route_vm_suspend(None)), + can_resume: rights.is_route_allowed("GET", &g.route_vm_resume(None)), + can_screenshot: rights.is_route_allowed("GET", &g.route_vm_screenshot(None)), + }, + }) + } + for v in rights.list_vm() { let vm_info = virtweb_client::vm_info(v).await?; res.vms.push(VMInfoAndCaps { - uiid: vm_info.uuid, - name: vm_info.name, - description: vm_info.description.clone(), - architecture: vm_info.architecture.to_string(), - memory: vm_info.memory, - number_vcpu: vm_info.number_vcpu, - can_get_state: rights.is_route_allowed("GET", &v.route_state()), - can_start: rights.is_route_allowed("GET", &v.route_start()), - can_shutdown: rights.is_route_allowed("GET", &v.route_shutdown()), - can_kill: rights.is_route_allowed("GET", &v.route_kill()), - can_reset: rights.is_route_allowed("GET", &v.route_reset()), - can_suspend: rights.is_route_allowed("GET", &v.route_suspend()), - can_resume: rights.is_route_allowed("GET", &v.route_resume()), - can_screenshot: rights.is_route_allowed("GET", &v.route_screenshot()), + info: vm_info, + caps: VMCaps { + can_get_state: rights.is_route_allowed("GET", &v.route_state()), + can_start: rights.is_route_allowed("GET", &v.route_start()), + can_shutdown: rights.is_route_allowed("GET", &v.route_shutdown()), + can_kill: rights.is_route_allowed("GET", &v.route_kill()), + can_reset: rights.is_route_allowed("GET", &v.route_reset()), + can_suspend: rights.is_route_allowed("GET", &v.route_suspend()), + can_resume: rights.is_route_allowed("GET", &v.route_resume()), + can_screenshot: rights.is_route_allowed("GET", &v.route_screenshot()), + }, }) } diff --git a/remote_backend/src/main.rs b/remote_backend/src/main.rs index 90c0a4c..4febb40 100644 --- a/remote_backend/src/main.rs +++ b/remote_backend/src/main.rs @@ -12,7 +12,8 @@ use light_openid::basic_state_manager::BasicStateManager; use remote_backend::app_config::AppConfig; use remote_backend::constants; use remote_backend::controllers::{ - auth_controller, server_controller, static_controller, sys_info_controller, vm_controller, + auth_controller, group_controller, server_controller, static_controller, sys_info_controller, + vm_controller, }; use remote_backend::middlewares::auth_middleware::AuthChecker; use std::time::Duration; @@ -86,6 +87,39 @@ async fn main() -> std::io::Result<()> { "/api/server/rights", web::get().to(server_controller::rights), ) + // Groups routes + .route( + "/api/group/{gid}/vm/state", + web::get().to(group_controller::vm_state), + ) + .route( + "/api/group/{gid}/vm/start", + web::get().to(group_controller::vm_start), + ) + .route( + "/api/group/{gid}/vm/shutdown", + web::get().to(group_controller::vm_shutdown), + ) + .route( + "/api/group/{gid}/vm/kill", + web::get().to(group_controller::vm_kill), + ) + .route( + "/api/group/{gid}/vm/reset", + web::get().to(group_controller::vm_reset), + ) + .route( + "/api/group/{gid}/vm/suspend", + web::get().to(group_controller::vm_suspend), + ) + .route( + "/api/group/{gid}/vm/resume", + web::get().to(group_controller::vm_resume), + ) + .route( + "/api/group/{gid}/vm/screenshot", + web::get().to(group_controller::vm_screenshot), + ) // VM routes .route("/api/vm/{uid}/state", web::get().to(vm_controller::state)) .route("/api/vm/{uid}/start", web::get().to(vm_controller::start)) diff --git a/remote_backend/src/virtweb_client.rs b/remote_backend/src/virtweb_client.rs index 4b5c5ba..10748f9 100644 --- a/remote_backend/src/virtweb_client.rs +++ b/remote_backend/src/virtweb_client.rs @@ -1,6 +1,7 @@ use crate::app_config::AppConfig; use crate::utils::time; use lazy_regex::regex; +use std::collections::HashMap; use std::fmt::Display; use std::str::FromStr; use thiserror::Error; @@ -12,9 +13,105 @@ pub enum VirtWebClientError { InvalidStatusCode(u16), } -#[derive(Eq, PartialEq, Debug, Copy, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Eq, PartialEq, Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GroupID(String); + +impl GroupID { + pub fn route_vm_info(&self) -> String { + format!("/api/group/{}/vm/info", self.0) + } + + pub fn route_vm_state(&self, vm: Option) -> String { + format!( + "/api/group/{}/vm/state{}", + self.0, + match vm { + None => "".to_string(), + Some(id) => format!("?vm_id={}", id.0), + } + ) + } + pub fn route_vm_start(&self, vm: Option) -> String { + format!( + "/api/group/{}/vm/start{}", + self.0, + match vm { + None => "".to_string(), + Some(id) => format!("?vm_id={}", id.0), + } + ) + } + pub fn route_vm_shutdown(&self, vm: Option) -> String { + format!( + "/api/group/{}/vm/shutdown{}", + self.0, + match vm { + None => "".to_string(), + Some(id) => format!("?vm_id={}", id.0), + } + ) + } + pub fn route_vm_suspend(&self, vm: Option) -> String { + format!( + "/api/group/{}/vm/suspend{}", + self.0, + match vm { + None => "".to_string(), + Some(id) => format!("?vm_id={}", id.0), + } + ) + } + pub fn route_vm_resume(&self, vm: Option) -> String { + format!( + "/api/group/{}/vm/resume{}", + self.0, + match vm { + None => "".to_string(), + Some(id) => format!("?vm_id={}", id.0), + } + ) + } + pub fn route_vm_kill(&self, vm: Option) -> String { + format!( + "/api/group/{}/vm/kill{}", + self.0, + match vm { + None => "".to_string(), + Some(id) => format!("?vm_id={}", id.0), + } + ) + } + pub fn route_vm_reset(&self, vm: Option) -> String { + format!( + "/api/group/{}/vm/reset{}", + self.0, + match vm { + None => "".to_string(), + Some(id) => format!("?vm_id={}", id.0), + } + ) + } + pub fn route_vm_screenshot(&self, vm: Option) -> String { + format!( + "/api/group/{}/vm/screenshot{}", + self.0, + match vm { + None => "".to_string(), + Some(id) => format!("?vm_id={}", id.0), + } + ) + } +} + +#[derive(Eq, PartialEq, Debug, Copy, Clone, serde::Serialize, serde::Deserialize, Hash)] pub struct VMUuid(Uuid); +#[derive(Default, serde::Deserialize, serde::Serialize)] +pub struct TreatmentResult { + ok: usize, + failed: usize, +} + impl VMUuid { pub fn route_info(&self) -> String { format!("/api/vm/{}", self.0) @@ -69,7 +166,7 @@ pub struct TokenClaims { pub nonce: String, } -#[derive(serde::Deserialize, Debug)] +#[derive(serde::Deserialize, serde::Serialize, Debug)] pub struct VMInfo { pub uuid: VMUuid, pub name: String, @@ -79,6 +176,18 @@ pub struct VMInfo { pub number_vcpu: usize, } +#[derive(serde::Deserialize, serde::Serialize, Debug)] +pub struct VMCaps { + pub can_get_state: bool, + pub can_start: bool, + pub can_shutdown: bool, + pub can_kill: bool, + pub can_reset: bool, + pub can_suspend: bool, + pub can_resume: bool, + pub can_screenshot: bool, +} + #[derive(serde::Deserialize, serde::Serialize, Debug)] pub struct VMState { pub state: String, @@ -147,6 +256,16 @@ impl TokenInfo { false } + /// List the groups with access + pub fn list_groups(&self) -> Vec { + self.rights + .iter() + .filter(|r| r.verb == "GET") + .filter(|r| regex!("^/api/group/[^/]+/vm/info$").is_match(&r.path)) + .map(|r| GroupID(r.path.split("/").nth(3).unwrap().to_string())) + .collect::>() + } + /// List the virtual machines with access pub fn list_vm(&self) -> Vec { self.rights @@ -168,12 +287,13 @@ async fn request(uri: D) -> anyhow::Result { let url = format!("{}{}", AppConfig::get().virtweb_base_url, uri); log::debug!("Will query {uri}..."); + let uri = uri.to_string(); let jwt = TokenClaims { sub: AppConfig::get().virtweb_token_id.to_string(), iat: time() - 60 * 2, exp: time() + 60 * 3, verb: "GET".to_string(), - path: uri.to_string(), + path: uri.split_once('?').map(|s| s.0).unwrap_or(&uri).to_string(), nonce: Uuid::new_v4().to_string(), }; let jwt = AppConfig::get().token_private_key().sign_jwt(&jwt)?; @@ -260,6 +380,73 @@ pub async fn vm_screenshot(id: VMUuid) -> anyhow::Result> { .to_vec()) } +/// Get the VM of a group +pub async fn group_vm_info(id: &GroupID) -> anyhow::Result> { + json_request(id.route_vm_info()).await +} + +/// Get the state of one or all VMs of a group +pub async fn group_vm_state( + id: &GroupID, + vm_id: Option, +) -> anyhow::Result> { + json_request(id.route_vm_state(vm_id)).await +} + +/// Start one or all VMs of a group +pub async fn group_vm_start( + id: &GroupID, + vm_id: Option, +) -> anyhow::Result { + json_request(id.route_vm_start(vm_id)).await +} + +/// Shutdown one or all VMs of a group +pub async fn group_vm_shutdown( + id: &GroupID, + vm_id: Option, +) -> anyhow::Result { + json_request(id.route_vm_shutdown(vm_id)).await +} + +/// Kill one or all VMs of a group +pub async fn group_vm_kill(id: &GroupID, vm_id: Option) -> anyhow::Result { + json_request(id.route_vm_kill(vm_id)).await +} + +/// Reset one or all VMs of a group +pub async fn group_vm_reset( + id: &GroupID, + vm_id: Option, +) -> anyhow::Result { + json_request(id.route_vm_reset(vm_id)).await +} + +/// Suspend one or all VMs of a group +pub async fn group_vm_suspend( + id: &GroupID, + vm_id: Option, +) -> anyhow::Result { + json_request(id.route_vm_suspend(vm_id)).await +} + +/// Resume one or all VMs of a group +pub async fn group_vm_resume( + id: &GroupID, + vm_id: Option, +) -> anyhow::Result { + json_request(id.route_vm_resume(vm_id)).await +} + +/// Get the screenshot of one or all VMs of a group +pub async fn group_vm_screenshot(id: &GroupID, vm_id: Option) -> anyhow::Result> { + Ok(request(id.route_vm_screenshot(vm_id)) + .await? + .bytes() + .await? + .to_vec()) +} + /// Get current server information pub async fn get_server_info() -> anyhow::Result { json_request("/api/server/info").await diff --git a/remote_frontend/src/App.tsx b/remote_frontend/src/App.tsx index c65fc8f..a8951d7 100644 --- a/remote_frontend/src/App.tsx +++ b/remote_frontend/src/App.tsx @@ -5,6 +5,8 @@ import { typographyStyles, } from "@fluentui/react-components"; import { + AppsListDetailFilled, + AppsListDetailRegular, DesktopFilled, DesktopRegular, InfoFilled, @@ -18,6 +20,7 @@ import { AsyncWidget } from "./widgets/AsyncWidget"; import { MainMenu } from "./widgets/MainMenu"; import { SystemInfoWidget } from "./widgets/SystemInfoWidget"; import { VirtualMachinesWidget } from "./widgets/VirtualMachinesWidget"; +import { GroupsWidget } from "./widgets/GroupsWidget"; const useStyles = makeStyles({ title: typographyStyles.title2, @@ -27,6 +30,8 @@ const InfoIcon = bundleIcon(InfoFilled, InfoRegular); const DesktopIcon = bundleIcon(DesktopFilled, DesktopRegular); +const AppListIcon = bundleIcon(AppsListDetailFilled, AppsListDetailRegular); + export function App() { return ( ("vm"); + const [tab, setTab] = React.useState<"group" | "vm" | "info">("group"); const [rights, setRights] = React.useState(); @@ -82,6 +87,13 @@ function AuthenticatedApp(): React.ReactElement { selectedValue={tab} onTabSelect={(_, d) => setTab(d.value as any)} > + } + disabled={rights!.groups.length === 0} + > + Groups + } @@ -101,6 +113,7 @@ function AuthenticatedApp(): React.ReactElement { + {tab === "group" && } {tab === "vm" && } {tab === "info" && } diff --git a/remote_frontend/src/api/ServerApi.ts b/remote_frontend/src/api/ServerApi.ts index ad6b441..7c5abb6 100644 --- a/remote_frontend/src/api/ServerApi.ts +++ b/remote_frontend/src/api/ServerApi.ts @@ -1,5 +1,5 @@ import { APIClient } from "./ApiClient"; -import { VMInfo } from "./VMApi"; +import { VMCaps, VMInfo, VMInfoAndCaps } from "./VMApi"; export interface ServerConfig { authenticated: boolean; @@ -7,10 +7,18 @@ export interface ServerConfig { } export interface Rights { - vms: VMInfo[]; + groups: VMGroup[]; + vms: VMInfoAndCaps[]; sys_info: boolean; } +export type VMGroup = VMGroupInfo & VMCaps; + +export interface VMGroupInfo { + id: string; + vms: VMInfo[]; +} + let config: ServerConfig | null = null; export class ServerApi { diff --git a/remote_frontend/src/api/VMApi.ts b/remote_frontend/src/api/VMApi.ts index 03b0c9d..42902be 100644 --- a/remote_frontend/src/api/VMApi.ts +++ b/remote_frontend/src/api/VMApi.ts @@ -1,12 +1,15 @@ import { APIClient } from "./ApiClient"; export interface VMInfo { - uiid: string; + uuid: string; name: string; description?: string; architecture: string; memory: number; number_vcpu: number; +} + +export interface VMCaps { can_get_state: boolean; can_start: boolean; can_shutdown: boolean; @@ -17,6 +20,8 @@ export interface VMInfo { can_screenshot: boolean; } +export type VMInfoAndCaps = VMInfo & VMCaps; + export type VMState = | "NoState" | "Running" @@ -34,7 +39,7 @@ export class VMApi { */ static async State(vm: VMInfo): Promise { return ( - await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/state` }) + await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/state` }) ).data.state; } @@ -42,42 +47,42 @@ export class VMApi { * Request to start VM */ static async StartVM(vm: VMInfo): Promise { - await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/start` }); + await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/start` }); } /** * Request to suspend VM */ static async SuspendVM(vm: VMInfo): Promise { - await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/suspend` }); + await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/suspend` }); } /** * Request to resume VM */ static async ResumeVM(vm: VMInfo): Promise { - await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/resume` }); + await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/resume` }); } /** * Request to shutdown VM */ static async ShutdownVM(vm: VMInfo): Promise { - await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/shutdown` }); + await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/shutdown` }); } /** * Request to kill VM */ static async KillVM(vm: VMInfo): Promise { - await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/kill` }); + await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/kill` }); } /** * Request to reset VM */ static async ResetVM(vm: VMInfo): Promise { - await APIClient.exec({ method: "GET", uri: `/vm/${vm.uiid}/reset` }); + await APIClient.exec({ method: "GET", uri: `/vm/${vm.uuid}/reset` }); } /** @@ -86,7 +91,7 @@ export class VMApi { static async Screenshot(vm: VMInfo): Promise { return ( await APIClient.exec({ - uri: `/vm/${vm.uiid}/screenshot`, + uri: `/vm/${vm.uuid}/screenshot`, method: "GET", }) ).data; diff --git a/remote_frontend/src/widgets/GroupsWidget.tsx b/remote_frontend/src/widgets/GroupsWidget.tsx new file mode 100644 index 0000000..4f553e7 --- /dev/null +++ b/remote_frontend/src/widgets/GroupsWidget.tsx @@ -0,0 +1,5 @@ +import { Rights } from "../api/ServerApi"; + +export function GroupsWidget(p: { rights: Rights }): React.ReactElement { + return

TODO

; +} diff --git a/remote_frontend/src/widgets/VirtualMachinesWidget.tsx b/remote_frontend/src/widgets/VirtualMachinesWidget.tsx index 7fb047d..b0c78ca 100644 --- a/remote_frontend/src/widgets/VirtualMachinesWidget.tsx +++ b/remote_frontend/src/widgets/VirtualMachinesWidget.tsx @@ -22,7 +22,7 @@ import { import { filesize } from "filesize"; import React from "react"; import { Rights } from "../api/ServerApi"; -import { VMApi, VMInfo, VMState } from "../api/VMApi"; +import { VMApi, VMInfo, VMInfoAndCaps, VMState } from "../api/VMApi"; import { useConfirm } from "../hooks/providers/ConfirmDialogProvider"; import { useToast } from "../hooks/providers/ToastProvider"; import { VMLiveScreenshot } from "./VMLiveScreenshot"; @@ -54,7 +54,7 @@ export function VirtualMachinesWidget(p: { ); } -function VMWidget(p: { vm: VMInfo }): React.ReactElement { +function VMWidget(p: { vm: VMInfoAndCaps }): React.ReactElement { const toast = useToast(); const [state, setState] = React.useState(); @@ -189,7 +189,10 @@ function VMWidget(p: { vm: VMInfo }): React.ReactElement { ); } -function VMPreview(p: { vm: VMInfo; state?: VMState }): React.ReactElement { +function VMPreview(p: { + vm: VMInfoAndCaps; + state?: VMState; +}): React.ReactElement { const styles = useStyles(); if (!p.vm.can_screenshot || p.state !== "Running") { return (