Compare commits
	
		
			1 Commits
		
	
	
		
			master
			...
			1e208f9e76
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1e208f9e76 | 
| @@ -46,9 +46,8 @@ steps: | |||||||
|   - cd virtweb_backend |   - cd virtweb_backend | ||||||
|   - mv /tmp/web_build/dist static |   - mv /tmp/web_build/dist static | ||||||
|   - cargo build --release |   - cargo build --release | ||||||
|   - cargo build --release --example api_curl |   - ls -lah target/release/virtweb_backend | ||||||
|   - ls -lah target/release/virtweb_backend target/release/examples/api_curl |   - cp target/release/virtweb_backend /tmp/release | ||||||
|   - cp target/release/virtweb_backend target/release/examples/api_curl /tmp/release |  | ||||||
|  |  | ||||||
| - name: gitea_release | - name: gitea_release | ||||||
|   image: plugins/gitea-release |   image: plugins/gitea-release | ||||||
|   | |||||||
							
								
								
									
										563
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										563
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -6,9 +6,9 @@ edition = "2024" | |||||||
| # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||||||
|  |  | ||||||
| [dependencies] | [dependencies] | ||||||
| log = "0.4.28" | log = "0.4.27" | ||||||
| env_logger = "0.11.8" | env_logger = "0.11.8" | ||||||
| clap = { version = "4.5.50", features = ["derive", "env"] } | clap = { version = "4.5.38", features = ["derive", "env"] } | ||||||
| light-openid = { version = "1.0.4", features = ["crypto-wrapper"] } | light-openid = { version = "1.0.4", features = ["crypto-wrapper"] } | ||||||
| lazy_static = "1.5.0" | lazy_static = "1.5.0" | ||||||
| actix = "0.13.5" | actix = "0.13.5" | ||||||
| @@ -17,27 +17,26 @@ actix-remote-ip = "0.1.0" | |||||||
| actix-session = { version = "0.10.1", features = ["cookie-session"] } | actix-session = { version = "0.10.1", features = ["cookie-session"] } | ||||||
| actix-identity = "0.8.0" | actix-identity = "0.8.0" | ||||||
| actix-cors = "0.7.1" | actix-cors = "0.7.1" | ||||||
| actix-files = "0.6.8" | actix-files = "0.6.6" | ||||||
| actix-ws = "0.3.0" | actix-ws = "0.3.0" | ||||||
| actix-http = "3.11.2" | actix-http = "3.10.0" | ||||||
| serde = { version = "1.0.219", features = ["derive"] } | serde = { version = "1.0.219", features = ["derive"] } | ||||||
| serde_json = "1.0.145" | serde_json = "1.0.140" | ||||||
| serde_yml = "0.0.12" | quick-xml = { version = "0.37.5", features = ["serialize", "overlapped-lists"] } | ||||||
| quick-xml = { version = "0.38.3", features = ["serialize", "overlapped-lists"] } |  | ||||||
| futures-util = "0.3.31" | futures-util = "0.3.31" | ||||||
| anyhow = "1.0.100" | anyhow = "1.0.98" | ||||||
| actix-multipart = "0.7.2" | actix-multipart = "0.7.2" | ||||||
| tempfile = "3.20.0" | tempfile = "3.20.0" | ||||||
| reqwest = { version = "0.12.24", features = ["stream"] } | reqwest = { version = "0.12.15", features = ["stream"] } | ||||||
| url = "2.5.7" | url = "2.5.4" | ||||||
| virt = "0.4.3" | virt = "0.4.2" | ||||||
| sysinfo = { version = "0.36.1", features = ["serde"] } | sysinfo = { version = "0.35.1", features = ["serde"] } | ||||||
| uuid = { version = "1.17.0", features = ["v4", "serde"] } | uuid = { version = "1.16.0", features = ["v4", "serde"] } | ||||||
| lazy-regex = "3.4.1" | lazy-regex = "3.4.1" | ||||||
| thiserror = "2.0.17" | thiserror = "2.0.12" | ||||||
| image = "0.25.8" | image = "0.25.6" | ||||||
| rand = "0.9.2" | rand = "0.9.1" | ||||||
| tokio = { version = "1.47.1", features = ["rt", "time", "macros"] } | tokio = { version = "1.45.1", features = ["rt", "time", "macros"] } | ||||||
| futures = "0.3.31" | futures = "0.3.31" | ||||||
| ipnetwork = { version = "0.21.1", features = ["serde"] } | ipnetwork = { version = "0.21.1", features = ["serde"] } | ||||||
| num = "0.4.3" | num = "0.4.3" | ||||||
| @@ -45,5 +44,3 @@ rust-embed = { version = "8.7.2", features = ["mime-guess"] } | |||||||
| dotenvy = "0.15.7" | dotenvy = "0.15.7" | ||||||
| nix = { version = "0.30.1", features = ["net"] } | nix = { version = "0.30.1", features = ["net"] } | ||||||
| basic-jwt = "0.3.0" | basic-jwt = "0.3.0" | ||||||
| zip = "4.3.0" |  | ||||||
| chrono = "0.4.42" |  | ||||||
| @@ -27,7 +27,10 @@ impl LibVirtActor { | |||||||
|     /// Connect to hypervisor |     /// Connect to hypervisor | ||||||
|     pub async fn connect() -> anyhow::Result<Self> { |     pub async fn connect() -> anyhow::Result<Self> { | ||||||
|         let hypervisor_uri = AppConfig::get().hypervisor_uri.as_deref().unwrap_or(""); |         let hypervisor_uri = AppConfig::get().hypervisor_uri.as_deref().unwrap_or(""); | ||||||
|         log::info!("Will connect to hypvervisor at address '{hypervisor_uri}'",); |         log::info!( | ||||||
|  |             "Will connect to hypvervisor at address '{}'", | ||||||
|  |             hypervisor_uri | ||||||
|  |         ); | ||||||
|         let conn = Connect::open(Some(hypervisor_uri))?; |         let conn = Connect::open(Some(hypervisor_uri))?; | ||||||
|  |  | ||||||
|         Ok(Self { m: conn }) |         Ok(Self { m: conn }) | ||||||
| @@ -99,7 +102,7 @@ impl Handler<GetDomainXMLReq> for LibVirtActor { | |||||||
|         log::debug!("Get domain XML:\n{}", msg.0.as_string()); |         log::debug!("Get domain XML:\n{}", msg.0.as_string()); | ||||||
|         let domain = Domain::lookup_by_uuid_string(&self.m, &msg.0.as_string())?; |         let domain = Domain::lookup_by_uuid_string(&self.m, &msg.0.as_string())?; | ||||||
|         let xml = domain.get_xml_desc(VIR_DOMAIN_XML_SECURE)?; |         let xml = domain.get_xml_desc(VIR_DOMAIN_XML_SECURE)?; | ||||||
|         log::debug!("XML = {xml}"); |         log::debug!("XML = {}", xml); | ||||||
|         DomainXML::parse_xml(&xml) |         DomainXML::parse_xml(&xml) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -128,7 +131,7 @@ impl Handler<DefineDomainReq> for LibVirtActor { | |||||||
|     fn handle(&mut self, mut msg: DefineDomainReq, _ctx: &mut Self::Context) -> Self::Result { |     fn handle(&mut self, mut msg: DefineDomainReq, _ctx: &mut Self::Context) -> Self::Result { | ||||||
|         let xml = msg.1.as_xml()?; |         let xml = msg.1.as_xml()?; | ||||||
|  |  | ||||||
|         log::debug!("Define domain:\n{xml}"); |         log::debug!("Define domain:\n{}", xml); | ||||||
|         let domain = Domain::define_xml(&self.m, &xml)?; |         let domain = Domain::define_xml(&self.m, &xml)?; | ||||||
|         let uuid = XMLUuid::parse_from_str(&domain.get_uuid_string()?)?; |         let uuid = XMLUuid::parse_from_str(&domain.get_uuid_string()?)?; | ||||||
|  |  | ||||||
| @@ -179,13 +182,6 @@ impl Handler<DeleteDomainReq> for LibVirtActor { | |||||||
|             false => sys::VIR_DOMAIN_UNDEFINE_NVRAM, |             false => sys::VIR_DOMAIN_UNDEFINE_NVRAM, | ||||||
|         })?; |         })?; | ||||||
|  |  | ||||||
|         // Delete associated cloud init disk |  | ||||||
|         let cloud_init_disk = AppConfig::get().cloud_init_disk_path_for_vm(&domain_name); |  | ||||||
|         if cloud_init_disk.exists() { |  | ||||||
|             std::fs::remove_file(cloud_init_disk)?; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // If requested, delete block storage associated with the VM |  | ||||||
|         if !msg.keep_files { |         if !msg.keep_files { | ||||||
|             log::info!("Delete storage associated with the domain"); |             log::info!("Delete storage associated with the domain"); | ||||||
|             let path = AppConfig::get().vm_storage_path(msg.id); |             let path = AppConfig::get().vm_storage_path(msg.id); | ||||||
| @@ -443,7 +439,7 @@ impl Handler<GetNetworkXMLReq> for LibVirtActor { | |||||||
|         log::debug!("Get network XML:\n{}", msg.0.as_string()); |         log::debug!("Get network XML:\n{}", msg.0.as_string()); | ||||||
|         let network = Network::lookup_by_uuid_string(&self.m, &msg.0.as_string())?; |         let network = Network::lookup_by_uuid_string(&self.m, &msg.0.as_string())?; | ||||||
|         let xml = network.get_xml_desc(0)?; |         let xml = network.get_xml_desc(0)?; | ||||||
|         log::debug!("XML = {xml}"); |         log::debug!("XML = {}", xml); | ||||||
|         NetworkXML::parse_xml(&xml) |         NetworkXML::parse_xml(&xml) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -599,7 +595,7 @@ impl Handler<GetNWFilterXMLReq> for LibVirtActor { | |||||||
|         log::debug!("Get network filter XML:\n{}", msg.0.as_string()); |         log::debug!("Get network filter XML:\n{}", msg.0.as_string()); | ||||||
|         let filter = NWFilter::lookup_by_uuid_string(&self.m, &msg.0.as_string())?; |         let filter = NWFilter::lookup_by_uuid_string(&self.m, &msg.0.as_string())?; | ||||||
|         let xml = filter.get_xml_desc(0)?; |         let xml = filter.get_xml_desc(0)?; | ||||||
|         log::debug!("XML = {xml}"); |         log::debug!("XML = {}", xml); | ||||||
|         NetworkFilterXML::parse_xml(xml) |         NetworkFilterXML::parse_xml(xml) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -614,7 +610,7 @@ impl Handler<DefineNWFilterReq> for LibVirtActor { | |||||||
|     fn handle(&mut self, mut msg: DefineNWFilterReq, _ctx: &mut Self::Context) -> Self::Result { |     fn handle(&mut self, mut msg: DefineNWFilterReq, _ctx: &mut Self::Context) -> Self::Result { | ||||||
|         let xml = msg.1.into_xml()?; |         let xml = msg.1.into_xml()?; | ||||||
|  |  | ||||||
|         log::debug!("Define network filter:\n{xml}"); |         log::debug!("Define network filter:\n{}", xml); | ||||||
|         let filter = NWFilter::define_xml(&self.m, &xml)?; |         let filter = NWFilter::define_xml(&self.m, &xml)?; | ||||||
|         let uuid = XMLUuid::parse_from_str(&filter.get_uuid_string()?)?; |         let uuid = XMLUuid::parse_from_str(&filter.get_uuid_string()?)?; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -104,10 +104,10 @@ impl Token { | |||||||
|  |  | ||||||
|     /// Check whether a token is expired or not |     /// Check whether a token is expired or not | ||||||
|     pub fn is_expired(&self) -> bool { |     pub fn is_expired(&self) -> bool { | ||||||
|         if let Some(max_inactivity) = self.max_inactivity |         if let Some(max_inactivity) = self.max_inactivity { | ||||||
|             && max_inactivity + self.last_used < time() |             if max_inactivity + self.last_used < time() { | ||||||
|         { |                 return true; | ||||||
|             return true; |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         false |         false | ||||||
| @@ -188,10 +188,10 @@ impl NewToken { | |||||||
|             return Some(err); |             return Some(err); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if let Some(t) = self.max_inactivity |         if let Some(t) = self.max_inactivity { | ||||||
|             && t < 3600 |             if t < 3600 { | ||||||
|         { |                 return Some("API tokens shall be valid for at least 1 hour!"); | ||||||
|             return Some("API tokens shall be valid for at least 1 hour!"); |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         None |         None | ||||||
|   | |||||||
| @@ -250,19 +250,6 @@ impl AppConfig { | |||||||
|         self.storage_path().join("iso") |         self.storage_path().join("iso") | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Get the path where generated cloud init disk image are stored |  | ||||||
|     pub fn cloud_init_disk_storage_path(&self) -> PathBuf { |  | ||||||
|         self.storage_path().join("cloud_init_disks") |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// Get the path where the disk image of a VM is stored |  | ||||||
|     pub fn cloud_init_disk_path_for_vm(&self, name: &str) -> PathBuf { |  | ||||||
|         self.cloud_init_disk_storage_path().join(format!( |  | ||||||
|             "{}-{name}.iso", |  | ||||||
|             constants::CLOUD_INIT_IMAGE_PREFIX_NAME |  | ||||||
|         )) |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// Get disk images storage directory |     /// Get disk images storage directory | ||||||
|     pub fn disk_images_storage_path(&self) -> PathBuf { |     pub fn disk_images_storage_path(&self) -> PathBuf { | ||||||
|         self.storage_path().join("disk_images") |         self.storage_path().join("disk_images") | ||||||
| @@ -280,7 +267,7 @@ impl AppConfig { | |||||||
|  |  | ||||||
|     /// Get VM vnc sockets path for domain |     /// Get VM vnc sockets path for domain | ||||||
|     pub fn vnc_socket_for_domain(&self, name: &str) -> PathBuf { |     pub fn vnc_socket_for_domain(&self, name: &str) -> PathBuf { | ||||||
|         self.vnc_sockets_path().join(format!("vnc-{name}")) |         self.vnc_sockets_path().join(format!("vnc-{}", name)) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Get VM root disks storage directory |     /// Get VM root disks storage directory | ||||||
|   | |||||||
| @@ -30,9 +30,8 @@ pub const ALLOWED_ISO_MIME_TYPES: [&str; 4] = [ | |||||||
| pub const ISO_MAX_SIZE: FileSize = FileSize::from_gb(10); | pub const ISO_MAX_SIZE: FileSize = FileSize::from_gb(10); | ||||||
|  |  | ||||||
| /// Allowed uploaded disk images formats | /// Allowed uploaded disk images formats | ||||||
| pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 4] = [ | pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 3] = [ | ||||||
|     "application/x-qemu-disk", |     "application/x-qemu-disk", | ||||||
|     "application/x-raw-disk-image", |  | ||||||
|     "application/gzip", |     "application/gzip", | ||||||
|     "application/octet-stream", |     "application/octet-stream", | ||||||
| ]; | ]; | ||||||
| @@ -58,9 +57,6 @@ pub const DISK_SIZE_MIN: FileSize = FileSize::from_mb(50); | |||||||
| /// Disk size max (B) | /// Disk size max (B) | ||||||
| pub const DISK_SIZE_MAX: FileSize = FileSize::from_gb(20000); | pub const DISK_SIZE_MAX: FileSize = FileSize::from_gb(20000); | ||||||
|  |  | ||||||
| /// Cloud init generated disk image prefix |  | ||||||
| pub const CLOUD_INIT_IMAGE_PREFIX_NAME: &str = "virtweb-cloudinit-autogen-image"; |  | ||||||
|  |  | ||||||
| /// Net nat entry comment max size | /// Net nat entry comment max size | ||||||
| pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250; | pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250; | ||||||
|  |  | ||||||
| @@ -126,25 +122,19 @@ pub const API_TOKEN_DESCRIPTION_MAX_LENGTH: usize = 30; | |||||||
| pub const API_TOKEN_RIGHT_PATH_MAX_LENGTH: usize = 255; | pub const API_TOKEN_RIGHT_PATH_MAX_LENGTH: usize = 255; | ||||||
|  |  | ||||||
| /// Qemu image program path | /// Qemu image program path | ||||||
| pub const PROGRAM_QEMU_IMAGE: &str = "/usr/bin/qemu-img"; | pub const QEMU_IMAGE_PROGRAM: &str = "/usr/bin/qemu-img"; | ||||||
|  |  | ||||||
| /// IP program path | /// IP program path | ||||||
| pub const PROGRAM_IP: &str = "/usr/sbin/ip"; | pub const IP_PROGRAM: &str = "/usr/sbin/ip"; | ||||||
|  |  | ||||||
| /// Copy program path | /// Copy program path | ||||||
| pub const PROGRAM_COPY: &str = "/bin/cp"; | pub const COPY_PROGRAM: &str = "/bin/cp"; | ||||||
|  |  | ||||||
| /// Gzip program path | /// Gzip program path | ||||||
| pub const PROGRAM_GZIP: &str = "/usr/bin/gzip"; | pub const GZIP_PROGRAM: &str = "/usr/bin/gzip"; | ||||||
|  |  | ||||||
| /// XZ program path |  | ||||||
| pub const PROGRAM_XZ: &str = "/usr/bin/xz"; |  | ||||||
|  |  | ||||||
| /// Bash program | /// Bash program | ||||||
| pub const PROGRAM_BASH: &str = "/usr/bin/bash"; | pub const BASH_PROGRAM: &str = "/usr/bin/bash"; | ||||||
|  |  | ||||||
| /// DD program | /// DD program | ||||||
| pub const PROGRAM_DD: &str = "/usr/bin/dd"; | pub const DD_PROGRAM: &str = "/usr/bin/dd"; | ||||||
|  |  | ||||||
| /// cloud-localds program |  | ||||||
| pub const PROGRAM_CLOUD_LOCALDS: &str = "/usr/bin/cloud-localds"; |  | ||||||
|   | |||||||
| @@ -31,12 +31,13 @@ pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>) | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Check file mime type |     // Check file mime type | ||||||
|     if let Some(mime_type) = file.content_type |     if let Some(mime_type) = file.content_type { | ||||||
|         && !constants::ALLOWED_DISK_IMAGES_MIME_TYPES.contains(&mime_type.as_ref()) |         if !constants::ALLOWED_DISK_IMAGES_MIME_TYPES.contains(&mime_type.as_ref()) { | ||||||
|     { |             return Ok(HttpResponse::BadRequest().json(format!( | ||||||
|         return Ok(HttpResponse::BadRequest().json(format!( |                 "Unsupported file type for disk upload: {}", | ||||||
|             "Unsupported file type for disk upload: {mime_type}" |                 mime_type | ||||||
|         ))); |             ))); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Extract and check file name |     // Extract and check file name | ||||||
| @@ -54,15 +55,7 @@ pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>) | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Copy the file to the destination |     // Copy the file to the destination | ||||||
|     file.file.persist(&dest_path)?; |     file.file.persist(dest_path)?; | ||||||
|  |  | ||||||
|     // Check if file information can be loaded |  | ||||||
|     if let Err(e) = DiskFileInfo::load_file(&dest_path) { |  | ||||||
|         log::error!("Failed to get information about uploaded disk file! {e}"); |  | ||||||
|         std::fs::remove_file(&dest_path)?; |  | ||||||
|         return Ok(HttpResponse::InternalServerError() |  | ||||||
|             .json(format!("Unable to process uploaded file! {e}"))); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     Ok(HttpResponse::Ok().json("Successfully uploaded disk image!")) |     Ok(HttpResponse::Ok().json("Successfully uploaded disk image!")) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -31,11 +31,11 @@ pub async fn upload_file(MultipartForm(mut form): MultipartForm<UploadIsoForm>) | |||||||
|         return Ok(HttpResponse::BadRequest().json("File is too large!")); |         return Ok(HttpResponse::BadRequest().json("File is too large!")); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if let Some(m) = &file.content_type |     if let Some(m) = &file.content_type { | ||||||
|         && !constants::ALLOWED_ISO_MIME_TYPES.contains(&m.to_string().as_str()) |         if !constants::ALLOWED_ISO_MIME_TYPES.contains(&m.to_string().as_str()) { | ||||||
|     { |             log::error!("Uploaded ISO file has an invalid mimetype!"); | ||||||
|         log::error!("Uploaded ISO file has an invalid mimetype!"); |             return Ok(HttpResponse::BadRequest().json("Invalid mimetype!")); | ||||||
|         return Ok(HttpResponse::BadRequest().json("Invalid mimetype!")); |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     let file_name = match &file.file_name { |     let file_name = match &file.file_name { | ||||||
| @@ -52,7 +52,7 @@ pub async fn upload_file(MultipartForm(mut form): MultipartForm<UploadIsoForm>) | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     let dest_file = AppConfig::get().iso_storage_path().join(file_name); |     let dest_file = AppConfig::get().iso_storage_path().join(file_name); | ||||||
|     log::info!("Will save ISO file {dest_file:?}"); |     log::info!("Will save ISO file {:?}", dest_file); | ||||||
|  |  | ||||||
|     if dest_file.exists() { |     if dest_file.exists() { | ||||||
|         log::error!("Conflict with uploaded iso file name!"); |         log::error!("Conflict with uploaded iso file name!"); | ||||||
| @@ -87,16 +87,16 @@ pub async fn upload_from_url(req: web::Json<DownloadFromURLReq>) -> HttpResult { | |||||||
|  |  | ||||||
|     let response = reqwest::get(&req.url).await?; |     let response = reqwest::get(&req.url).await?; | ||||||
|  |  | ||||||
|     if let Some(len) = response.content_length() |     if let Some(len) = response.content_length() { | ||||||
|         && len > constants::ISO_MAX_SIZE.as_bytes() as u64 |         if len > constants::ISO_MAX_SIZE.as_bytes() as u64 { | ||||||
|     { |             return Ok(HttpResponse::BadRequest().json("File is too large!")); | ||||||
|         return Ok(HttpResponse::BadRequest().json("File is too large!")); |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     if let Some(ct) = response.headers().get("content-type") |     if let Some(ct) = response.headers().get("content-type") { | ||||||
|         && !constants::ALLOWED_ISO_MIME_TYPES.contains(&ct.to_str()?) |         if !constants::ALLOWED_ISO_MIME_TYPES.contains(&ct.to_str()?) { | ||||||
|     { |             return Ok(HttpResponse::BadRequest().json("Invalid file mimetype!")); | ||||||
|         return Ok(HttpResponse::BadRequest().json("Invalid file mimetype!")); |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     let mut stream = response.bytes_stream(); |     let mut stream = response.bytes_stream(); | ||||||
|   | |||||||
| @@ -4,7 +4,6 @@ use actix_web::body::BoxBody; | |||||||
| use actix_web::{HttpResponse, web}; | use actix_web::{HttpResponse, web}; | ||||||
| use std::error::Error; | use std::error::Error; | ||||||
| use std::fmt::{Display, Formatter}; | use std::fmt::{Display, Formatter}; | ||||||
| use zip::result::ZipError; |  | ||||||
|  |  | ||||||
| pub mod api_tokens_controller; | pub mod api_tokens_controller; | ||||||
| pub mod auth_controller; | pub mod auth_controller; | ||||||
| @@ -43,7 +42,7 @@ impl actix_web::error::ResponseError for HttpErr { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|     fn error_response(&self) -> HttpResponse<BoxBody> { |     fn error_response(&self) -> HttpResponse<BoxBody> { | ||||||
|         log::error!("Error while processing request! {self}"); |         log::error!("Error while processing request! {}", self); | ||||||
|  |  | ||||||
|         HttpResponse::InternalServerError().body("Failed to execute request!") |         HttpResponse::InternalServerError().body("Failed to execute request!") | ||||||
|     } |     } | ||||||
| @@ -103,12 +102,6 @@ impl From<actix_web::Error> for HttpErr { | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| impl From<ZipError> for HttpErr { |  | ||||||
|     fn from(value: ZipError) -> Self { |  | ||||||
|         HttpErr::Err(std::io::Error::other(value.to_string()).into()) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| impl From<HttpResponse> for HttpErr { | impl From<HttpResponse> for HttpErr { | ||||||
|     fn from(value: HttpResponse) -> Self { |     fn from(value: HttpResponse) -> Self { | ||||||
|         HttpErr::HTTPResponse(value) |         HttpErr::HTTPResponse(value) | ||||||
|   | |||||||
| @@ -1,24 +1,14 @@ | |||||||
| use crate::actors::vnc_tokens_actor::VNC_TOKEN_LIFETIME; | use crate::actors::vnc_tokens_actor::VNC_TOKEN_LIFETIME; | ||||||
| use crate::app_config::AppConfig; | use crate::app_config::AppConfig; | ||||||
|  | use crate::constants; | ||||||
| use crate::constants::{DISK_NAME_MAX_LEN, DISK_NAME_MIN_LEN, DISK_SIZE_MAX, DISK_SIZE_MIN}; | use crate::constants::{DISK_NAME_MAX_LEN, DISK_NAME_MIN_LEN, DISK_SIZE_MAX, DISK_SIZE_MIN}; | ||||||
| use crate::controllers::{HttpResult, LibVirtReq}; | use crate::controllers::{HttpResult, LibVirtReq}; | ||||||
| use crate::extractors::local_auth_extractor::LocalAuthEnabled; | use crate::extractors::local_auth_extractor::LocalAuthEnabled; | ||||||
| use crate::libvirt_rest_structures::hypervisor::HypervisorInfo; | use crate::libvirt_rest_structures::hypervisor::HypervisorInfo; | ||||||
| use crate::libvirt_rest_structures::net::NetworkInfo; |  | ||||||
| use crate::libvirt_rest_structures::nw_filter::NetworkFilter; |  | ||||||
| use crate::libvirt_rest_structures::vm::VMInfo; |  | ||||||
| use crate::nat::nat_hook; | use crate::nat::nat_hook; | ||||||
| use crate::utils::net_utils; | use crate::utils::net_utils; | ||||||
| use crate::utils::time_utils::{format_date, time}; | use actix_web::{HttpResponse, Responder}; | ||||||
| use crate::{api_tokens, constants}; |  | ||||||
| use actix_files::NamedFile; |  | ||||||
| use actix_web::{HttpRequest, HttpResponse, Responder}; |  | ||||||
| use serde::Serialize; |  | ||||||
| use std::fs::File; |  | ||||||
| use std::io::Write; |  | ||||||
| use sysinfo::{Components, Disks, Networks, System}; | use sysinfo::{Components, Disks, Networks, System}; | ||||||
| use zip::ZipWriter; |  | ||||||
| use zip::write::SimpleFileOptions; |  | ||||||
|  |  | ||||||
| #[derive(serde::Serialize)] | #[derive(serde::Serialize)] | ||||||
| struct StaticConfig { | struct StaticConfig { | ||||||
| @@ -209,85 +199,3 @@ pub async fn networks_list() -> HttpResult { | |||||||
| pub async fn bridges_list() -> HttpResult { | pub async fn bridges_list() -> HttpResult { | ||||||
|     Ok(HttpResponse::Ok().json(net_utils::bridges_list()?)) |     Ok(HttpResponse::Ok().json(net_utils::bridges_list()?)) | ||||||
| } | } | ||||||
|  |  | ||||||
| /// Add JSON file to ZIP |  | ||||||
| fn zip_json<E: Serialize, F>( |  | ||||||
|     zip: &mut ZipWriter<File>, |  | ||||||
|     dir: &str, |  | ||||||
|     content: &Vec<E>, |  | ||||||
|     file_name: F, |  | ||||||
| ) -> anyhow::Result<()> |  | ||||||
| where |  | ||||||
|     F: Fn(&E) -> String, |  | ||||||
| { |  | ||||||
|     for entry in content { |  | ||||||
|         let file_encoded = serde_json::to_string(&entry)?; |  | ||||||
|  |  | ||||||
|         let options = SimpleFileOptions::default() |  | ||||||
|             .compression_method(zip::CompressionMethod::Deflated) |  | ||||||
|             .unix_permissions(0o750); |  | ||||||
|  |  | ||||||
|         zip.start_file(format!("{dir}/{}.json", file_name(entry)), options)?; |  | ||||||
|         zip.write_all(file_encoded.as_bytes())?; |  | ||||||
|     } |  | ||||||
|     Ok(()) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /// Export all configuration elements at once |  | ||||||
| pub async fn export_all_configs(req: HttpRequest, client: LibVirtReq) -> HttpResult { |  | ||||||
|     // Perform extractions |  | ||||||
|     let vms = client |  | ||||||
|         .get_full_domains_list() |  | ||||||
|         .await? |  | ||||||
|         .into_iter() |  | ||||||
|         .map(VMInfo::from_domain) |  | ||||||
|         .collect::<Result<Vec<_>, _>>()?; |  | ||||||
|     let networks = client |  | ||||||
|         .get_full_networks_list() |  | ||||||
|         .await? |  | ||||||
|         .into_iter() |  | ||||||
|         .map(NetworkInfo::from_xml) |  | ||||||
|         .collect::<Result<Vec<_>, _>>()?; |  | ||||||
|     let nw_filters = client |  | ||||||
|         .get_full_network_filters_list() |  | ||||||
|         .await? |  | ||||||
|         .into_iter() |  | ||||||
|         .map(NetworkFilter::lib2rest) |  | ||||||
|         .collect::<Result<Vec<_>, _>>()?; |  | ||||||
|     let tokens = api_tokens::full_list().await?; |  | ||||||
|  |  | ||||||
|     // Create ZIP file |  | ||||||
|     let dest_dir = tempfile::tempdir_in(&AppConfig::get().temp_dir)?; |  | ||||||
|     let zip_path = dest_dir.path().join("export.zip"); |  | ||||||
|  |  | ||||||
|     let file = File::create(&zip_path)?; |  | ||||||
|     let mut zip = ZipWriter::new(file); |  | ||||||
|  |  | ||||||
|     // Encode entities to JSON |  | ||||||
|     zip_json(&mut zip, "vms", &vms, |v| v.name.to_string())?; |  | ||||||
|     zip_json(&mut zip, "networks", &networks, |v| v.name.0.to_string())?; |  | ||||||
|     zip_json( |  | ||||||
|         &mut zip, |  | ||||||
|         "nw_filters", |  | ||||||
|         &nw_filters, |  | ||||||
|         |v| match constants::BUILTIN_NETWORK_FILTER_RULES.contains(&v.name.0.as_str()) { |  | ||||||
|             true => format!("builtin/{}", v.name.0), |  | ||||||
|             false => v.name.0.to_string(), |  | ||||||
|         }, |  | ||||||
|     )?; |  | ||||||
|     zip_json(&mut zip, "tokens", &tokens, |v| v.id.0.to_string())?; |  | ||||||
|  |  | ||||||
|     // Finalize ZIP and return response |  | ||||||
|     zip.finish()?; |  | ||||||
|     let file = File::open(zip_path)?; |  | ||||||
|  |  | ||||||
|     let file = NamedFile::from_file( |  | ||||||
|         file, |  | ||||||
|         format!( |  | ||||||
|             "export_{}.zip", |  | ||||||
|             format_date(time() as i64).unwrap().replace('/', "-") |  | ||||||
|         ), |  | ||||||
|     )?; |  | ||||||
|  |  | ||||||
|     Ok(file.into_response(&req)) |  | ||||||
| } |  | ||||||
|   | |||||||
| @@ -109,28 +109,6 @@ pub async fn get_single_src_def(client: LibVirtReq, id: web::Path<SingleVMUUidRe | |||||||
|         .body(info)) |         .body(info)) | ||||||
| } | } | ||||||
|  |  | ||||||
| /// Get the generated cloud init configuration disk of a vm |  | ||||||
| pub async fn get_cloud_init_disk(client: LibVirtReq, id: web::Path<SingleVMUUidReq>) -> HttpResult { |  | ||||||
|     let info = match client.get_single_domain(id.uid).await { |  | ||||||
|         Ok(i) => i, |  | ||||||
|         Err(e) => { |  | ||||||
|             log::error!("Failed to get domain information! {e}"); |  | ||||||
|             return Ok(HttpResponse::InternalServerError().json(e.to_string())); |  | ||||||
|         } |  | ||||||
|     }; |  | ||||||
|  |  | ||||||
|     let vm = VMInfo::from_domain(info)?; |  | ||||||
|     let disk = vm.cloud_init.generate_nocloud_disk()?; |  | ||||||
|  |  | ||||||
|     Ok(HttpResponse::Ok() |  | ||||||
|         .content_type("application/x-iso9660-image") |  | ||||||
|         .insert_header(( |  | ||||||
|             "Content-Disposition", |  | ||||||
|             format!("attachment; filename=\"cloud_init_{}.iso\"", vm.name), |  | ||||||
|         )) |  | ||||||
|         .body(disk)) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /// Update a VM information | /// Update a VM information | ||||||
| pub async fn update( | pub async fn update( | ||||||
|     client: LibVirtReq, |     client: LibVirtReq, | ||||||
|   | |||||||
| @@ -128,21 +128,21 @@ impl FromRequest for ApiAuthExtractor { | |||||||
|                 )); |                 )); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             if let Some(ip) = token.ip_restriction |             if let Some(ip) = token.ip_restriction { | ||||||
|                 && !ip.contains(remote_ip.0) |                 if !ip.contains(remote_ip.0) { | ||||||
|             { |                     log::error!( | ||||||
|                 log::error!( |                         "Attempt to use a token for an unauthorized IP! {remote_ip:?} token_id={}", | ||||||
|                     "Attempt to use a token for an unauthorized IP! {remote_ip:?} token_id={}", |                         token.id.0 | ||||||
|                     token.id.0 |                     ); | ||||||
|                 ); |                     return Err(ErrorUnauthorized("Token cannot be used from this IP!")); | ||||||
|                 return Err(ErrorUnauthorized("Token cannot be used from this IP!")); |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             if token.should_update_last_activity() |             if token.should_update_last_activity() { | ||||||
|                 && let Err(e) = api_tokens::refresh_last_used(token.id).await |                 if let Err(e) = api_tokens::refresh_last_used(token.id).await { | ||||||
|             { |                     log::error!("Could not update token last activity! {e}"); | ||||||
|                 log::error!("Could not update token last activity! {e}"); |                     return Err(ErrorBadRequest("Couldn't refresh token last activity!")); | ||||||
|                 return Err(ErrorBadRequest("Couldn't refresh token last activity!")); |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             Ok(ApiAuthExtractor { token, claims }) |             Ok(ApiAuthExtractor { token, claims }) | ||||||
|   | |||||||
| @@ -1,5 +1,4 @@ | |||||||
| use crate::libvirt_lib_structures::XMLUuid; | use crate::libvirt_lib_structures::XMLUuid; | ||||||
| use crate::utils::cloud_init_utils::CloudInitConfig; |  | ||||||
|  |  | ||||||
| /// VirtWeb specific metadata | /// VirtWeb specific metadata | ||||||
| #[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)] | #[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)] | ||||||
| @@ -9,8 +8,6 @@ pub struct DomainMetadataVirtWebXML { | |||||||
|     pub ns: String, |     pub ns: String, | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |     #[serde(skip_serializing_if = "Option::is_none")] | ||||||
|     pub group: Option<String>, |     pub group: Option<String>, | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |  | ||||||
|     pub cloud_init: Option<CloudInitConfig>, |  | ||||||
| } | } | ||||||
|  |  | ||||||
| /// Domain metadata | /// Domain metadata | ||||||
|   | |||||||
| @@ -13,6 +13,4 @@ enum LibVirtStructError { | |||||||
|     ParseFilteringChain(String), |     ParseFilteringChain(String), | ||||||
|     #[error("NetworkFilterExtractionError: {0}")] |     #[error("NetworkFilterExtractionError: {0}")] | ||||||
|     NetworkFilterExtraction(String), |     NetworkFilterExtraction(String), | ||||||
|     #[error("CloudInitConfigurationError: {0}")] |  | ||||||
|     CloudInitConfiguration(String), |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -96,28 +96,28 @@ impl NetworkInfo { | |||||||
|             return Err(StructureExtraction("network name is invalid!").into()); |             return Err(StructureExtraction("network name is invalid!").into()); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if let Some(n) = &self.title |         if let Some(n) = &self.title { | ||||||
|             && n.contains('\n') |             if n.contains('\n') { | ||||||
|         { |                 return Err(StructureExtraction("Network title contain newline char!").into()); | ||||||
|             return Err(StructureExtraction("Network title contain newline char!").into()); |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if let Some(dev) = &self.device |         if let Some(dev) = &self.device { | ||||||
|             && !regex!("^[a-zA-Z0-9]+$").is_match(dev) |             if !regex!("^[a-zA-Z0-9]+$").is_match(dev) { | ||||||
|         { |                 return Err(StructureExtraction("Network device name is invalid!").into()); | ||||||
|             return Err(StructureExtraction("Network device name is invalid!").into()); |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if let Some(bridge) = &self.bridge_name |         if let Some(bridge) = &self.bridge_name { | ||||||
|             && !regex!("^[a-zA-Z0-9]+$").is_match(bridge) |             if !regex!("^[a-zA-Z0-9]+$").is_match(bridge) { | ||||||
|         { |                 return Err(StructureExtraction("Network bridge name is invalid!").into()); | ||||||
|             return Err(StructureExtraction("Network bridge name is invalid!").into()); |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if let Some(domain) = &self.domain |         if let Some(domain) = &self.domain { | ||||||
|             && !regex!("^[a-zA-Z0-9.]+$").is_match(domain) |             if !regex!("^[a-zA-Z0-9.]+$").is_match(domain) { | ||||||
|         { |                 return Err(StructureExtraction("Domain name is invalid!").into()); | ||||||
|             return Err(StructureExtraction("Domain name is invalid!").into()); |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         let mut ips = Vec::with_capacity(2); |         let mut ips = Vec::with_capacity(2); | ||||||
| @@ -303,16 +303,16 @@ impl NetworkInfo { | |||||||
|  |  | ||||||
|     /// Check if at least one NAT definition was specified on this interface |     /// Check if at least one NAT definition was specified on this interface | ||||||
|     pub fn has_nat_def(&self) -> bool { |     pub fn has_nat_def(&self) -> bool { | ||||||
|         if let Some(ipv4) = &self.ip_v4 |         if let Some(ipv4) = &self.ip_v4 { | ||||||
|             && ipv4.nat.is_some() |             if ipv4.nat.is_some() { | ||||||
|         { |                 return true; | ||||||
|             return true; |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if let Some(ipv6) = &self.ip_v6 |         if let Some(ipv6) = &self.ip_v6 { | ||||||
|             && ipv6.nat.is_some() |             if ipv6.nat.is_some() { | ||||||
|         { |                 return true; | ||||||
|             return true; |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         false |         false | ||||||
|   | |||||||
| @@ -43,12 +43,14 @@ impl From<&String> for NetworkFilterMacAddressOrVar { | |||||||
| fn extract_mac_address_or_var( | fn extract_mac_address_or_var( | ||||||
|     n: &Option<NetworkFilterMacAddressOrVar>, |     n: &Option<NetworkFilterMacAddressOrVar>, | ||||||
| ) -> anyhow::Result<Option<String>> { | ) -> anyhow::Result<Option<String>> { | ||||||
|     if let Some(mac) = n |     if let Some(mac) = n { | ||||||
|         && !mac.is_valid() |         if !mac.is_valid() { | ||||||
|     { |             return Err(NetworkFilterExtraction(format!( | ||||||
|         return Err( |                 "Invalid mac address or variable! {}", | ||||||
|             NetworkFilterExtraction(format!("Invalid mac address or variable! {}", mac.0)).into(), |                 mac.0 | ||||||
|         ); |             )) | ||||||
|  |             .into()); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     Ok(n.as_ref().map(|n| n.0.to_string())) |     Ok(n.as_ref().map(|n| n.0.to_string())) | ||||||
| @@ -81,34 +83,34 @@ impl<const V: usize> From<&String> for NetworkFilterIPOrVar<V> { | |||||||
| fn extract_ip_or_var<const V: usize>( | fn extract_ip_or_var<const V: usize>( | ||||||
|     n: &Option<NetworkFilterIPOrVar<V>>, |     n: &Option<NetworkFilterIPOrVar<V>>, | ||||||
| ) -> anyhow::Result<Option<String>> { | ) -> anyhow::Result<Option<String>> { | ||||||
|     if let Some(ip) = n |     if let Some(ip) = n { | ||||||
|         && !ip.is_valid() |         if !ip.is_valid() { | ||||||
|     { |             return Err(NetworkFilterExtraction(format!( | ||||||
|         return Err(NetworkFilterExtraction(format!( |                 "Invalid IPv{V} address or variable! {}", | ||||||
|             "Invalid IPv{V} address or variable! {}", |                 ip.0 | ||||||
|             ip.0 |             )) | ||||||
|         )) |             .into()); | ||||||
|         .into()); |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     Ok(n.as_ref().map(|n| n.0.to_string())) |     Ok(n.as_ref().map(|n| n.0.to_string())) | ||||||
| } | } | ||||||
|  |  | ||||||
| fn extract_ip_mask<const V: usize>(n: Option<u8>) -> anyhow::Result<Option<u8>> { | fn extract_ip_mask<const V: usize>(n: Option<u8>) -> anyhow::Result<Option<u8>> { | ||||||
|     if let Some(mask) = n |     if let Some(mask) = n { | ||||||
|         && !net_utils::is_mask_valid(V, mask) |         if !net_utils::is_mask_valid(V, mask) { | ||||||
|     { |             return Err(NetworkFilterExtraction(format!("Invalid IPv{V} mask! {mask}")).into()); | ||||||
|         return Err(NetworkFilterExtraction(format!("Invalid IPv{V} mask! {mask}")).into()); |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     Ok(n) |     Ok(n) | ||||||
| } | } | ||||||
|  |  | ||||||
| fn extract_nw_filter_comment(n: &Option<String>) -> anyhow::Result<Option<String>> { | fn extract_nw_filter_comment(n: &Option<String>) -> anyhow::Result<Option<String>> { | ||||||
|     if let Some(comment) = n |     if let Some(comment) = n { | ||||||
|         && (comment.len() > 256 || comment.contains('\"') || comment.contains('\n')) |         if comment.len() > 256 || comment.contains('\"') || comment.contains('\n') { | ||||||
|     { |             return Err(NetworkFilterExtraction(format!("Invalid comment! {}", comment)).into()); | ||||||
|         return Err(NetworkFilterExtraction(format!("Invalid comment! {comment}")).into()); |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     Ok(n.clone()) |     Ok(n.clone()) | ||||||
| @@ -867,10 +869,12 @@ impl NetworkFilter { | |||||||
|             ); |             ); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if let Some(priority) = self.priority |         if let Some(priority) = self.priority { | ||||||
|             && !(-1000..=1000).contains(&priority) |             if !(-1000..=1000).contains(&priority) { | ||||||
|         { |                 return Err( | ||||||
|             return Err(NetworkFilterExtraction("Network priority is invalid!".to_string()).into()); |                     NetworkFilterExtraction("Network priority is invalid!".to_string()).into(), | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         for fref in &self.join_filters { |         for fref in &self.join_filters { | ||||||
|   | |||||||
| @@ -3,10 +3,7 @@ use crate::constants; | |||||||
| use crate::libvirt_lib_structures::XMLUuid; | use crate::libvirt_lib_structures::XMLUuid; | ||||||
| use crate::libvirt_lib_structures::domain::*; | use crate::libvirt_lib_structures::domain::*; | ||||||
| use crate::libvirt_rest_structures::LibVirtStructError; | use crate::libvirt_rest_structures::LibVirtStructError; | ||||||
| use crate::libvirt_rest_structures::LibVirtStructError::{ | use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction; | ||||||
|     CloudInitConfiguration, StructureExtraction, |  | ||||||
| }; |  | ||||||
| use crate::utils::cloud_init_utils::CloudInitConfig; |  | ||||||
| use crate::utils::file_size_utils::FileSize; | use crate::utils::file_size_utils::FileSize; | ||||||
| use crate::utils::files_utils; | use crate::utils::files_utils; | ||||||
| use crate::utils::vm_file_disks_utils::{VMDiskBus, VMDiskFormat, VMFileDisk}; | use crate::utils::vm_file_disks_utils::{VMDiskBus, VMDiskFormat, VMFileDisk}; | ||||||
| @@ -97,9 +94,6 @@ pub struct VMInfo { | |||||||
|     pub tpm_module: bool, |     pub tpm_module: bool, | ||||||
|     /// Strings injected as OEM Strings in SMBios configuration |     /// Strings injected as OEM Strings in SMBios configuration | ||||||
|     pub oem_strings: Vec<String>, |     pub oem_strings: Vec<String>, | ||||||
|     /// Cloud init configuration |  | ||||||
|     #[serde(default)] |  | ||||||
|     pub cloud_init: CloudInitConfig, |  | ||||||
| } | } | ||||||
|  |  | ||||||
| impl VMInfo { | impl VMInfo { | ||||||
| @@ -118,22 +112,22 @@ impl VMInfo { | |||||||
|             XMLUuid::new_random() |             XMLUuid::new_random() | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         if let Some(n) = &self.genid |         if let Some(n) = &self.genid { | ||||||
|             && !n.is_valid() |             if !n.is_valid() { | ||||||
|         { |                 return Err(StructureExtraction("VM genid is invalid!").into()); | ||||||
|             return Err(StructureExtraction("VM genid is invalid!").into()); |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if let Some(n) = &self.title |         if let Some(n) = &self.title { | ||||||
|             && n.contains('\n') |             if n.contains('\n') { | ||||||
|         { |                 return Err(StructureExtraction("VM title contain newline char!").into()); | ||||||
|             return Err(StructureExtraction("VM title contain newline char!").into()); |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if let Some(group) = &self.group |         if let Some(group) = &self.group { | ||||||
|             && !regex!("^[a-zA-Z0-9]+$").is_match(&group.0) |             if !regex!("^[a-zA-Z0-9]+$").is_match(&group.0) { | ||||||
|         { |                 return Err(StructureExtraction("VM group name is invalid!").into()); | ||||||
|             return Err(StructureExtraction("VM group name is invalid!").into()); |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if self.memory < constants::MIN_VM_MEMORY || self.memory > constants::MAX_VM_MEMORY { |         if self.memory < constants::MIN_VM_MEMORY || self.memory > constants::MAX_VM_MEMORY { | ||||||
| @@ -144,26 +138,9 @@ impl VMInfo { | |||||||
|             return Err(StructureExtraction("Invalid number of vCPU specified!").into()); |             return Err(StructureExtraction("Invalid number of vCPU specified!").into()); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if let Some(e) = self.cloud_init.check_error() { |         let mut disks = vec![]; | ||||||
|             return Err(CloudInitConfiguration(e).into()); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         let mut iso_absolute_files = vec![]; |         // Add ISO files | ||||||
|  |  | ||||||
|         // Process cloud init image |  | ||||||
|         if self.cloud_init.attach_config { |  | ||||||
|             let cloud_init_disk_path = AppConfig::get().cloud_init_disk_path_for_vm(&self.name); |  | ||||||
|  |  | ||||||
|             // Apply latest cloud init configuration |  | ||||||
|             std::fs::write( |  | ||||||
|                 &cloud_init_disk_path, |  | ||||||
|                 self.cloud_init.generate_nocloud_disk()?, |  | ||||||
|             )?; |  | ||||||
|  |  | ||||||
|             iso_absolute_files.push(cloud_init_disk_path); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Process uploaded ISO files |  | ||||||
|         for iso_file in &self.iso_files { |         for iso_file in &self.iso_files { | ||||||
|             if !files_utils::check_file_name(iso_file) { |             if !files_utils::check_file_name(iso_file) { | ||||||
|                 return Err(StructureExtraction("ISO filename is invalid!").into()); |                 return Err(StructureExtraction("ISO filename is invalid!").into()); | ||||||
| @@ -175,13 +152,6 @@ impl VMInfo { | |||||||
|                 return Err(StructureExtraction("Specified ISO file does not exists!").into()); |                 return Err(StructureExtraction("Specified ISO file does not exists!").into()); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             iso_absolute_files.push(path); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         let mut disks = vec![]; |  | ||||||
|  |  | ||||||
|         // Add ISO disk files |  | ||||||
|         for iso_path in iso_absolute_files { |  | ||||||
|             disks.push(DiskXML { |             disks.push(DiskXML { | ||||||
|                 r#type: "file".to_string(), |                 r#type: "file".to_string(), | ||||||
|                 device: "cdrom".to_string(), |                 device: "cdrom".to_string(), | ||||||
| @@ -191,7 +161,7 @@ impl VMInfo { | |||||||
|                     cache: "none".to_string(), |                     cache: "none".to_string(), | ||||||
|                 }, |                 }, | ||||||
|                 source: DiskSourceXML { |                 source: DiskSourceXML { | ||||||
|                     file: iso_path.to_string_lossy().to_string(), |                     file: path.to_string_lossy().to_string(), | ||||||
|                 }, |                 }, | ||||||
|                 target: DiskTargetXML { |                 target: DiskTargetXML { | ||||||
|                     dev: format!( |                     dev: format!( | ||||||
| @@ -208,7 +178,6 @@ impl VMInfo { | |||||||
|             }) |             }) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Configure VNC access, if requested |  | ||||||
|         let (vnc_graphics, vnc_video) = match self.vnc_access { |         let (vnc_graphics, vnc_video) = match self.vnc_access { | ||||||
|             true => ( |             true => ( | ||||||
|                 Some(GraphicsXML { |                 Some(GraphicsXML { | ||||||
| @@ -371,7 +340,6 @@ impl VMInfo { | |||||||
|                 virtweb: DomainMetadataVirtWebXML { |                 virtweb: DomainMetadataVirtWebXML { | ||||||
|                     ns: "https://virtweb.communiquons.org".to_string(), |                     ns: "https://virtweb.communiquons.org".to_string(), | ||||||
|                     group: self.group.clone().map(|g| g.0), |                     group: self.group.clone().map(|g| g.0), | ||||||
|                     cloud_init: Some(self.cloud_init.clone()), |  | ||||||
|                 }, |                 }, | ||||||
|             }), |             }), | ||||||
|             os: OSXML { |             os: OSXML { | ||||||
| @@ -522,7 +490,6 @@ impl VMInfo { | |||||||
|                 .iter() |                 .iter() | ||||||
|                 .filter(|d| d.device == "cdrom") |                 .filter(|d| d.device == "cdrom") | ||||||
|                 .map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string()) |                 .map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string()) | ||||||
|                 .filter(|d| !d.starts_with(constants::CLOUD_INIT_IMAGE_PREFIX_NAME)) |  | ||||||
|                 .collect(), |                 .collect(), | ||||||
|  |  | ||||||
|             file_disks: domain |             file_disks: domain | ||||||
| @@ -615,13 +582,6 @@ impl VMInfo { | |||||||
|                 .and_then(|s| s.oem_strings) |                 .and_then(|s| s.oem_strings) | ||||||
|                 .map(|s| s.entries.iter().map(|o| o.content.to_string()).collect()) |                 .map(|s| s.entries.iter().map(|o| o.content.to_string()).collect()) | ||||||
|                 .unwrap_or_default(), |                 .unwrap_or_default(), | ||||||
|             cloud_init: domain |  | ||||||
|                 .metadata |  | ||||||
|                 .clone() |  | ||||||
|                 .unwrap_or_default() |  | ||||||
|                 .virtweb |  | ||||||
|                 .cloud_init |  | ||||||
|                 .unwrap_or_default(), |  | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -47,22 +47,16 @@ async fn main() -> std::io::Result<()> { | |||||||
|  |  | ||||||
|     log::debug!("Checking for required programs"); |     log::debug!("Checking for required programs"); | ||||||
|     exec_utils::check_program( |     exec_utils::check_program( | ||||||
|         constants::PROGRAM_QEMU_IMAGE, |         constants::QEMU_IMAGE_PROGRAM, | ||||||
|         "QEMU disk image utility is required to manipulate QCow2 files!", |         "QEMU disk image utility is required to manipulate QCow2 files!", | ||||||
|     ); |     ); | ||||||
|     exec_utils::check_program( |     exec_utils::check_program( | ||||||
|         constants::PROGRAM_IP, |         constants::IP_PROGRAM, | ||||||
|         "ip is required to access bridges information!", |         "ip is required to access bridges information!", | ||||||
|     ); |     ); | ||||||
|     exec_utils::check_program( |  | ||||||
|         constants::PROGRAM_CLOUD_LOCALDS, |  | ||||||
|         "cloud-localds from package cloud-image-utils is required to build cloud-init images!", |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     log::debug!("Create required directory, if missing"); |     log::debug!("Create required directory, if missing"); | ||||||
|     files_utils::create_directory_if_missing(AppConfig::get().iso_storage_path()).unwrap(); |     files_utils::create_directory_if_missing(AppConfig::get().iso_storage_path()).unwrap(); | ||||||
|     files_utils::create_directory_if_missing(AppConfig::get().cloud_init_disk_storage_path()) |  | ||||||
|         .unwrap(); |  | ||||||
|     files_utils::create_directory_if_missing(AppConfig::get().disk_images_storage_path()).unwrap(); |     files_utils::create_directory_if_missing(AppConfig::get().disk_images_storage_path()).unwrap(); | ||||||
|     files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap(); |     files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap(); | ||||||
|     files_utils::set_file_permission(AppConfig::get().vnc_sockets_path(), 0o777).unwrap(); |     files_utils::set_file_permission(AppConfig::get().vnc_sockets_path(), 0o777).unwrap(); | ||||||
| @@ -157,10 +151,6 @@ async fn main() -> std::io::Result<()> { | |||||||
|                 "/api/server/bridges", |                 "/api/server/bridges", | ||||||
|                 web::get().to(server_controller::bridges_list), |                 web::get().to(server_controller::bridges_list), | ||||||
|             ) |             ) | ||||||
|             .route( |  | ||||||
|                 "/api/server/export_configs", |  | ||||||
|                 web::get().to(server_controller::export_all_configs), |  | ||||||
|             ) |  | ||||||
|             // Auth controller |             // Auth controller | ||||||
|             .route( |             .route( | ||||||
|                 "/api/auth/local", |                 "/api/auth/local", | ||||||
| @@ -208,10 +198,6 @@ async fn main() -> std::io::Result<()> { | |||||||
|                 "/api/vm/{uid}/src", |                 "/api/vm/{uid}/src", | ||||||
|                 web::get().to(vm_controller::get_single_src_def), |                 web::get().to(vm_controller::get_single_src_def), | ||||||
|             ) |             ) | ||||||
|             .route( |  | ||||||
|                 "/api/vm/{uid}/cloud_init_disk", |  | ||||||
|                 web::get().to(vm_controller::get_cloud_init_disk), |  | ||||||
|             ) |  | ||||||
|             .route( |             .route( | ||||||
|                 "/api/vm/{uid}/autostart", |                 "/api/vm/{uid}/autostart", | ||||||
|                 web::get().to(vm_controller::get_autostart), |                 web::get().to(vm_controller::get_autostart), | ||||||
|   | |||||||
| @@ -69,7 +69,8 @@ where | |||||||
|  |  | ||||||
|             if !AppConfig::get().is_allowed_ip(remote_ip.0) { |             if !AppConfig::get().is_allowed_ip(remote_ip.0) { | ||||||
|                 log::error!( |                 log::error!( | ||||||
|                     "An attempt to access VirtWeb from an unauthorized network has been intercepted! {remote_ip:?}" |                     "An attempt to access VirtWeb from an unauthorized network has been intercepted! {:?}", | ||||||
|  |                     remote_ip | ||||||
|                 ); |                 ); | ||||||
|                 return Ok(req |                 return Ok(req | ||||||
|                     .into_response( |                     .into_response( | ||||||
|   | |||||||
| @@ -60,10 +60,10 @@ pub struct Nat<IPv> { | |||||||
|  |  | ||||||
| impl<IPv> Nat<IPv> { | impl<IPv> Nat<IPv> { | ||||||
|     pub fn check(&self) -> anyhow::Result<()> { |     pub fn check(&self) -> anyhow::Result<()> { | ||||||
|         if let NatSourceIP::Interface { name } = &self.host_ip |         if let NatSourceIP::Interface { name } = &self.host_ip { | ||||||
|             && !net_utils::is_net_interface_name_valid(name) |             if !net_utils::is_net_interface_name_valid(name) { | ||||||
|         { |                 return Err(NatDefError::InvalidNatDef("Invalid nat interface name!").into()); | ||||||
|             return Err(NatDefError::InvalidNatDef("Invalid nat interface name!").into()); |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if let NatHostPort::Range { start, end } = &self.host_port { |         if let NatHostPort::Range { start, end } = &self.host_port { | ||||||
| @@ -84,10 +84,10 @@ impl<IPv> Nat<IPv> { | |||||||
|             return Err(NatDefError::InvalidNatDef("Invalid guest port!").into()); |             return Err(NatDefError::InvalidNatDef("Invalid guest port!").into()); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         if let Some(comment) = &self.comment |         if let Some(comment) = &self.comment { | ||||||
|             && comment.len() > constants::NET_NAT_COMMENT_MAX_SIZE |             if comment.len() > constants::NET_NAT_COMMENT_MAX_SIZE { | ||||||
|         { |                 return Err(NatDefError::InvalidNatDef("Comment is too large!").into()); | ||||||
|             return Err(NatDefError::InvalidNatDef("Comment is too large!").into()); |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         Ok(()) |         Ok(()) | ||||||
|   | |||||||
| @@ -1,117 +0,0 @@ | |||||||
| use crate::app_config::AppConfig; |  | ||||||
| use crate::constants; |  | ||||||
| use std::process::Command; |  | ||||||
|  |  | ||||||
| /// Cloud init DS Mode |  | ||||||
| #[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)] |  | ||||||
| pub enum CloudInitDSMode { |  | ||||||
|     /// Networking is required |  | ||||||
|     Net, |  | ||||||
|     /// Does not require networking to be up before user-data actions are run |  | ||||||
|     Local, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /// VM Cloud Init configuration |  | ||||||
| /// |  | ||||||
| /// RedHat documentation: https://docs.redhat.com/fr/documentation/red_hat_enterprise_linux/9/html/configuring_and_managing_cloud-init_for_rhel_9/configuring-cloud-init_cloud-content |  | ||||||
| /// cloud-localds source code: https://github.com/canonical/cloud-utils/blob/main/bin/cloud-localds |  | ||||||
| #[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)] |  | ||||||
| pub struct CloudInitConfig { |  | ||||||
|     pub attach_config: bool, |  | ||||||
|     /// Main user data |  | ||||||
|     pub user_data: String, |  | ||||||
|     /// Instance ID, set in metadata file |  | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |  | ||||||
|     pub instance_id: Option<String>, |  | ||||||
|     /// Local hostname, set in metadata file |  | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |  | ||||||
|     pub local_hostname: Option<String>, |  | ||||||
|     /// Data source mode |  | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |  | ||||||
|     pub dsmode: Option<CloudInitDSMode>, |  | ||||||
|     /// Network configuration |  | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |  | ||||||
|     pub network_configuration: Option<String>, |  | ||||||
| } |  | ||||||
|  |  | ||||||
| impl CloudInitConfig { |  | ||||||
|     /// Check cloud init configuration |  | ||||||
|     pub fn check_error(&self) -> Option<String> { |  | ||||||
|         if !self.user_data.is_empty() { |  | ||||||
|             // Check YAML content |  | ||||||
|             if let Err(e) = serde_yml::from_str::<serde_json::Value>(&self.user_data) { |  | ||||||
|                 return Some(format!( |  | ||||||
|                     "user data is an invalid YAML file! Deserialization error: {e}" |  | ||||||
|                 )); |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             // Check first line |  | ||||||
|             if !self.user_data.starts_with("#cloud-config\n") { |  | ||||||
|                 return Some( |  | ||||||
|                     "user data file MUST start with '#cloud-config' as first line!".to_string(), |  | ||||||
|                 ); |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         None |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// Generate disk image for nocloud usage |  | ||||||
|     pub fn generate_nocloud_disk(&self) -> anyhow::Result<Vec<u8>> { |  | ||||||
|         let temp_path = tempfile::tempdir_in(&AppConfig::get().temp_dir)?; |  | ||||||
|  |  | ||||||
|         let mut cmd = Command::new(constants::PROGRAM_CLOUD_LOCALDS); |  | ||||||
|  |  | ||||||
|         // ISO destination path |  | ||||||
|         let temp_iso = temp_path.path().join("disk.iso"); |  | ||||||
|         cmd.arg(&temp_iso); |  | ||||||
|  |  | ||||||
|         // Process network configuration |  | ||||||
|         if let Some(net_conf) = &self.network_configuration { |  | ||||||
|             let net_conf_path = temp_path.path().join("network"); |  | ||||||
|             std::fs::write(&net_conf_path, net_conf)?; |  | ||||||
|             cmd.arg("--network-config").arg(&net_conf_path); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Process user data |  | ||||||
|         let user_data_path = temp_path.path().join("user-data"); |  | ||||||
|         std::fs::write(&user_data_path, &self.user_data)?; |  | ||||||
|         cmd.arg(user_data_path); |  | ||||||
|  |  | ||||||
|         // Process metadata |  | ||||||
|         let mut metadatas = vec![]; |  | ||||||
|         if let Some(inst_id) = &self.instance_id { |  | ||||||
|             metadatas.push(format!("instance-id: {inst_id}")); |  | ||||||
|         } |  | ||||||
|         if let Some(local_hostname) = &self.local_hostname { |  | ||||||
|             metadatas.push(format!("local-hostname: {local_hostname}")); |  | ||||||
|         } |  | ||||||
|         if let Some(dsmode) = &self.dsmode { |  | ||||||
|             metadatas.push(format!( |  | ||||||
|                 "dsmode: {}", |  | ||||||
|                 match dsmode { |  | ||||||
|                     CloudInitDSMode::Net => "net", |  | ||||||
|                     CloudInitDSMode::Local => "local", |  | ||||||
|                 } |  | ||||||
|             )); |  | ||||||
|         } |  | ||||||
|         let meta_data_path = temp_path.path().join("meta-data"); |  | ||||||
|         std::fs::write(&meta_data_path, metadatas.join("\n"))?; |  | ||||||
|         cmd.arg(meta_data_path); |  | ||||||
|  |  | ||||||
|         // Execute command |  | ||||||
|         let output = cmd.output()?; |  | ||||||
|         if !output.status.success() { |  | ||||||
|             anyhow::bail!( |  | ||||||
|                 "{} exited with status {}!\nStdout: {}\nStderr: {}", |  | ||||||
|                 constants::PROGRAM_CLOUD_LOCALDS, |  | ||||||
|                 output.status, |  | ||||||
|                 String::from_utf8_lossy(&output.stdout), |  | ||||||
|                 String::from_utf8_lossy(&output.stderr) |  | ||||||
|             ); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Read generated ISO file |  | ||||||
|         Ok(std::fs::read(temp_iso)?) |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -28,10 +28,8 @@ pub enum DiskFileFormat { | |||||||
|         #[serde(default)] |         #[serde(default)] | ||||||
|         virtual_size: FileSize, |         virtual_size: FileSize, | ||||||
|     }, |     }, | ||||||
|     GzCompressedRaw, |     CompressedRaw, | ||||||
|     GzCompressedQCow2, |     CompressedQCow2, | ||||||
|     XzCompressedRaw, |  | ||||||
|     XzCompressedQCow2, |  | ||||||
| } | } | ||||||
|  |  | ||||||
| impl DiskFileFormat { | impl DiskFileFormat { | ||||||
| @@ -39,10 +37,8 @@ impl DiskFileFormat { | |||||||
|         match self { |         match self { | ||||||
|             DiskFileFormat::Raw { .. } => &["raw", ""], |             DiskFileFormat::Raw { .. } => &["raw", ""], | ||||||
|             DiskFileFormat::QCow2 { .. } => &["qcow2"], |             DiskFileFormat::QCow2 { .. } => &["qcow2"], | ||||||
|             DiskFileFormat::GzCompressedRaw => &["raw.gz"], |             DiskFileFormat::CompressedRaw => &["raw.gz"], | ||||||
|             DiskFileFormat::GzCompressedQCow2 => &["qcow2.gz"], |             DiskFileFormat::CompressedQCow2 => &["qcow2.gz"], | ||||||
|             DiskFileFormat::XzCompressedRaw => &["raw.xz"], |  | ||||||
|             DiskFileFormat::XzCompressedQCow2 => &["qcow2.xz"], |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -85,14 +81,9 @@ impl DiskFileInfo { | |||||||
|             }, |             }, | ||||||
|             "gz" if name.ends_with(".qcow2") => { |             "gz" if name.ends_with(".qcow2") => { | ||||||
|                 name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string(); |                 name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string(); | ||||||
|                 DiskFileFormat::GzCompressedQCow2 |                 DiskFileFormat::CompressedQCow2 | ||||||
|             } |             } | ||||||
|             "gz" => DiskFileFormat::GzCompressedRaw, |             "gz" => DiskFileFormat::CompressedRaw, | ||||||
|             "xz" if name.ends_with(".qcow2") => { |  | ||||||
|                 name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string(); |  | ||||||
|                 DiskFileFormat::XzCompressedQCow2 |  | ||||||
|             } |  | ||||||
|             "xz" => DiskFileFormat::XzCompressedRaw, |  | ||||||
|             _ => anyhow::bail!("Unsupported disk extension: {ext}!"), |             _ => anyhow::bail!("Unsupported disk extension: {ext}!"), | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
| @@ -133,7 +124,7 @@ impl DiskFileInfo { | |||||||
|             } |             } | ||||||
|  |  | ||||||
|             DiskFileFormat::QCow2 { virtual_size } => { |             DiskFileFormat::QCow2 { virtual_size } => { | ||||||
|                 let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); |                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||||
|                 cmd.arg("create") |                 cmd.arg("create") | ||||||
|                     .arg("-f") |                     .arg("-f") | ||||||
|                     .arg("qcow2") |                     .arg("qcow2") | ||||||
| @@ -168,9 +159,9 @@ impl DiskFileInfo { | |||||||
|  |  | ||||||
|         // Prepare the conversion |         // Prepare the conversion | ||||||
|         let mut cmd = match (self.format, dest_format) { |         let mut cmd = match (self.format, dest_format) { | ||||||
|             // Decompress QCow2 (GZIP) |             // Decompress QCow2 | ||||||
|             (DiskFileFormat::GzCompressedQCow2, DiskFileFormat::QCow2 { .. }) => { |             (DiskFileFormat::CompressedQCow2, DiskFileFormat::QCow2 { .. }) => { | ||||||
|                 let mut cmd = Command::new(constants::PROGRAM_GZIP); |                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||||
|                 cmd.arg("--keep") |                 cmd.arg("--keep") | ||||||
|                     .arg("--decompress") |                     .arg("--decompress") | ||||||
|                     .arg("--to-stdout") |                     .arg("--to-stdout") | ||||||
| @@ -179,30 +170,9 @@ impl DiskFileInfo { | |||||||
|                 cmd |                 cmd | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Decompress QCow2 (XZ) |             // Compress QCow2 | ||||||
|             (DiskFileFormat::XzCompressedQCow2, DiskFileFormat::QCow2 { .. }) => { |             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::CompressedQCow2) => { | ||||||
|                 let mut cmd = Command::new(constants::PROGRAM_XZ); |                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||||
|                 cmd.arg("--stdout") |  | ||||||
|                     .arg("--keep") |  | ||||||
|                     .arg("--decompress") |  | ||||||
|                     .arg(&self.file_path) |  | ||||||
|                     .stdout(File::create(&temp_file)?); |  | ||||||
|                 cmd |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             // Compress QCow2 (Gzip) |  | ||||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::GzCompressedQCow2) => { |  | ||||||
|                 let mut cmd = Command::new(constants::PROGRAM_GZIP); |  | ||||||
|                 cmd.arg("--keep") |  | ||||||
|                     .arg("--to-stdout") |  | ||||||
|                     .arg(&self.file_path) |  | ||||||
|                     .stdout(File::create(&temp_file)?); |  | ||||||
|                 cmd |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             // Compress QCow2 (Xz) |  | ||||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::XzCompressedQCow2) => { |  | ||||||
|                 let mut cmd = Command::new(constants::PROGRAM_XZ); |  | ||||||
|                 cmd.arg("--keep") |                 cmd.arg("--keep") | ||||||
|                     .arg("--to-stdout") |                     .arg("--to-stdout") | ||||||
|                     .arg(&self.file_path) |                     .arg(&self.file_path) | ||||||
| @@ -212,7 +182,7 @@ impl DiskFileInfo { | |||||||
|  |  | ||||||
|             // Convert QCow2 to Raw file |             // Convert QCow2 to Raw file | ||||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::Raw { is_sparse }) => { |             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::Raw { is_sparse }) => { | ||||||
|                 let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); |                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||||
|                 cmd.arg("convert") |                 cmd.arg("convert") | ||||||
|                     .arg("-f") |                     .arg("-f") | ||||||
|                     .arg("qcow2") |                     .arg("qcow2") | ||||||
| @@ -231,7 +201,7 @@ impl DiskFileInfo { | |||||||
|             // Clone a QCow file, using qemu-image instead of cp might improve "sparsification" of |             // Clone a QCow file, using qemu-image instead of cp might improve "sparsification" of | ||||||
|             // file |             // file | ||||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::QCow2 { .. }) => { |             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::QCow2 { .. }) => { | ||||||
|                 let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); |                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||||
|                 cmd.arg("convert") |                 cmd.arg("convert") | ||||||
|                     .arg("-f") |                     .arg("-f") | ||||||
|                     .arg("qcow2") |                     .arg("qcow2") | ||||||
| @@ -244,7 +214,7 @@ impl DiskFileInfo { | |||||||
|  |  | ||||||
|             // Convert Raw to QCow2 file |             // Convert Raw to QCow2 file | ||||||
|             (DiskFileFormat::Raw { .. }, DiskFileFormat::QCow2 { .. }) => { |             (DiskFileFormat::Raw { .. }, DiskFileFormat::QCow2 { .. }) => { | ||||||
|                 let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); |                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||||
|                 cmd.arg("convert") |                 cmd.arg("convert") | ||||||
|                     .arg("-f") |                     .arg("-f") | ||||||
|                     .arg("raw") |                     .arg("raw") | ||||||
| @@ -258,7 +228,7 @@ impl DiskFileInfo { | |||||||
|  |  | ||||||
|             // Render raw file non sparse |             // Render raw file non sparse | ||||||
|             (DiskFileFormat::Raw { is_sparse: true }, DiskFileFormat::Raw { is_sparse: false }) => { |             (DiskFileFormat::Raw { is_sparse: true }, DiskFileFormat::Raw { is_sparse: false }) => { | ||||||
|                 let mut cmd = Command::new(constants::PROGRAM_COPY); |                 let mut cmd = Command::new(constants::COPY_PROGRAM); | ||||||
|                 cmd.arg("--sparse=never") |                 cmd.arg("--sparse=never") | ||||||
|                     .arg(&self.file_path) |                     .arg(&self.file_path) | ||||||
|                     .arg(&temp_file); |                     .arg(&temp_file); | ||||||
| @@ -267,16 +237,16 @@ impl DiskFileInfo { | |||||||
|  |  | ||||||
|             // Render raw file sparse |             // Render raw file sparse | ||||||
|             (DiskFileFormat::Raw { is_sparse: false }, DiskFileFormat::Raw { is_sparse: true }) => { |             (DiskFileFormat::Raw { is_sparse: false }, DiskFileFormat::Raw { is_sparse: true }) => { | ||||||
|                 let mut cmd = Command::new(constants::PROGRAM_DD); |                 let mut cmd = Command::new(constants::DD_PROGRAM); | ||||||
|                 cmd.arg("conv=sparse") |                 cmd.arg("conv=sparse") | ||||||
|                     .arg(format!("if={}", self.file_path.display())) |                     .arg(format!("if={}", self.file_path.display())) | ||||||
|                     .arg(format!("of={}", temp_file.display())); |                     .arg(format!("of={}", temp_file.display())); | ||||||
|                 cmd |                 cmd | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Compress Raw (Gz) |             // Compress Raw | ||||||
|             (DiskFileFormat::Raw { .. }, DiskFileFormat::GzCompressedRaw) => { |             (DiskFileFormat::Raw { .. }, DiskFileFormat::CompressedRaw) => { | ||||||
|                 let mut cmd = Command::new(constants::PROGRAM_GZIP); |                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||||
|                 cmd.arg("--keep") |                 cmd.arg("--keep") | ||||||
|                     .arg("--to-stdout") |                     .arg("--to-stdout") | ||||||
|                     .arg(&self.file_path) |                     .arg(&self.file_path) | ||||||
| @@ -284,29 +254,9 @@ impl DiskFileInfo { | |||||||
|                 cmd |                 cmd | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Compress Raw (Xz) |             // Decompress Raw to not sparse file | ||||||
|             (DiskFileFormat::Raw { .. }, DiskFileFormat::XzCompressedRaw) => { |             (DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => { | ||||||
|                 let mut cmd = Command::new(constants::PROGRAM_XZ); |                 let mut cmd = Command::new(constants::GZIP_PROGRAM); | ||||||
|                 cmd.arg("--keep") |  | ||||||
|                     .arg("--to-stdout") |  | ||||||
|                     .arg(&self.file_path) |  | ||||||
|                     .stdout(File::create(&temp_file)?); |  | ||||||
|                 cmd |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             // Decompress Raw (Gz) to not sparse file |  | ||||||
|             (DiskFileFormat::GzCompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => { |  | ||||||
|                 let mut cmd = Command::new(constants::PROGRAM_GZIP); |  | ||||||
|                 cmd.arg("--keep") |  | ||||||
|                     .arg("--decompress") |  | ||||||
|                     .arg("--to-stdout") |  | ||||||
|                     .arg(&self.file_path) |  | ||||||
|                     .stdout(File::create(&temp_file)?); |  | ||||||
|                 cmd |  | ||||||
|             } |  | ||||||
|             // Decompress Raw (Xz) to not sparse file |  | ||||||
|             (DiskFileFormat::XzCompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => { |  | ||||||
|                 let mut cmd = Command::new(constants::PROGRAM_XZ); |  | ||||||
|                 cmd.arg("--keep") |                 cmd.arg("--keep") | ||||||
|                     .arg("--decompress") |                     .arg("--decompress") | ||||||
|                     .arg("--to-stdout") |                     .arg("--to-stdout") | ||||||
| @@ -315,29 +265,15 @@ impl DiskFileInfo { | |||||||
|                 cmd |                 cmd | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Decompress Raw (Gz) to sparse file |             // Decompress Raw to sparse file | ||||||
|             // https://benou.fr/www/ben/decompressing-sparse-files.html |             // https://benou.fr/www/ben/decompressing-sparse-files.html | ||||||
|             (DiskFileFormat::GzCompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => { |             (DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => { | ||||||
|                 let mut cmd = Command::new(constants::PROGRAM_BASH); |                 let mut cmd = Command::new(constants::BASH_PROGRAM); | ||||||
|                 cmd.arg("-c").arg(format!( |                 cmd.arg("-c").arg(format!( | ||||||
|                     "{} --decompress --to-stdout {} | {} conv=sparse of={}", |                     "{} -d -c {} | {} conv=sparse of={}", | ||||||
|                     constants::PROGRAM_GZIP, |                     constants::GZIP_PROGRAM, | ||||||
|                     self.file_path.display(), |                     self.file_path.display(), | ||||||
|                     constants::PROGRAM_DD, |                     constants::DD_PROGRAM, | ||||||
|                     temp_file.display() |  | ||||||
|                 )); |  | ||||||
|                 cmd |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             // Decompress Raw (XZ) to sparse file |  | ||||||
|             // https://benou.fr/www/ben/decompressing-sparse-files.html |  | ||||||
|             (DiskFileFormat::XzCompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => { |  | ||||||
|                 let mut cmd = Command::new(constants::PROGRAM_BASH); |  | ||||||
|                 cmd.arg("-c").arg(format!( |  | ||||||
|                     "{} --decompress --to-stdout {} | {} conv=sparse of={}", |  | ||||||
|                     constants::PROGRAM_XZ, |  | ||||||
|                     self.file_path.display(), |  | ||||||
|                     constants::PROGRAM_DD, |  | ||||||
|                     temp_file.display() |                     temp_file.display() | ||||||
|                 )); |                 )); | ||||||
|                 cmd |                 cmd | ||||||
| @@ -345,7 +281,7 @@ impl DiskFileInfo { | |||||||
|  |  | ||||||
|             // Dumb copy of file |             // Dumb copy of file | ||||||
|             (a, b) if a == b => { |             (a, b) if a == b => { | ||||||
|                 let mut cmd = Command::new(constants::PROGRAM_COPY); |                 let mut cmd = Command::new(constants::COPY_PROGRAM); | ||||||
|                 cmd.arg("--sparse=auto") |                 cmd.arg("--sparse=auto") | ||||||
|                     .arg(&self.file_path) |                     .arg(&self.file_path) | ||||||
|                     .arg(&temp_file); |                     .arg(&temp_file); | ||||||
| @@ -394,44 +330,6 @@ impl DiskFileInfo { | |||||||
|  |  | ||||||
|         Ok(()) |         Ok(()) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// Get disk virtual size, if available |  | ||||||
|     pub fn virtual_size(&self) -> Option<FileSize> { |  | ||||||
|         match self.format { |  | ||||||
|             DiskFileFormat::Raw { .. } => Some(self.file_size), |  | ||||||
|             DiskFileFormat::QCow2 { virtual_size } => Some(virtual_size), |  | ||||||
|             _ => None, |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /// Resize disk |  | ||||||
|     pub fn resize(&self, new_size: FileSize) -> anyhow::Result<()> { |  | ||||||
|         if new_size <= self.virtual_size().unwrap_or(new_size) { |  | ||||||
|             anyhow::bail!("Shrinking disk image file is not supported!"); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); |  | ||||||
|         cmd.arg("resize") |  | ||||||
|             .arg("-f") |  | ||||||
|             .arg(match self.format { |  | ||||||
|                 DiskFileFormat::QCow2 { .. } => "qcow2", |  | ||||||
|                 DiskFileFormat::Raw { .. } => "raw", |  | ||||||
|                 f => anyhow::bail!("Unsupported disk format for resize: {f:?}"), |  | ||||||
|             }) |  | ||||||
|             .arg(&self.file_path) |  | ||||||
|             .arg(new_size.as_bytes().to_string()); |  | ||||||
|  |  | ||||||
|         let output = cmd.output()?; |  | ||||||
|         if !output.status.success() { |  | ||||||
|             anyhow::bail!( |  | ||||||
|                 "{} info failed, status: {}, stderr: {}", |  | ||||||
|                 constants::PROGRAM_QEMU_IMAGE, |  | ||||||
|                 output.status, |  | ||||||
|                 String::from_utf8_lossy(&output.stderr) |  | ||||||
|             ); |  | ||||||
|         } |  | ||||||
|         Ok(()) |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| #[derive(serde::Deserialize)] | #[derive(serde::Deserialize)] | ||||||
| @@ -443,7 +341,7 @@ struct QCowInfoOutput { | |||||||
| /// Get QCow2 virtual size | /// Get QCow2 virtual size | ||||||
| fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> { | fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> { | ||||||
|     // Run qemu-img |     // Run qemu-img | ||||||
|     let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); |     let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); | ||||||
|     cmd.args([ |     cmd.args([ | ||||||
|         "info", |         "info", | ||||||
|         path.to_str().unwrap_or(""), |         path.to_str().unwrap_or(""), | ||||||
| @@ -455,7 +353,7 @@ fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> { | |||||||
|     if !output.status.success() { |     if !output.status.success() { | ||||||
|         anyhow::bail!( |         anyhow::bail!( | ||||||
|             "{} info failed, status: {}, stderr: {}", |             "{} info failed, status: {}, stderr: {}", | ||||||
|             constants::PROGRAM_QEMU_IMAGE, |             constants::QEMU_IMAGE_PROGRAM, | ||||||
|             output.status, |             output.status, | ||||||
|             String::from_utf8_lossy(&output.stderr) |             String::from_utf8_lossy(&output.stderr) | ||||||
|         ); |         ); | ||||||
|   | |||||||
| @@ -1,4 +1,3 @@ | |||||||
| pub mod cloud_init_utils; |  | ||||||
| pub mod exec_utils; | pub mod exec_utils; | ||||||
| pub mod file_disks_utils; | pub mod file_disks_utils; | ||||||
| pub mod file_size_utils; | pub mod file_size_utils; | ||||||
|   | |||||||
| @@ -145,13 +145,13 @@ struct IPBridgeInfo { | |||||||
|  |  | ||||||
| /// Get the list of bridge interfaces | /// Get the list of bridge interfaces | ||||||
| pub fn bridges_list() -> anyhow::Result<Vec<String>> { | pub fn bridges_list() -> anyhow::Result<Vec<String>> { | ||||||
|     let mut cmd = Command::new(constants::PROGRAM_IP); |     let mut cmd = Command::new(constants::IP_PROGRAM); | ||||||
|     cmd.args(["-json", "link", "show", "type", "bridge"]); |     cmd.args(["-json", "link", "show", "type", "bridge"]); | ||||||
|     let output = cmd.output()?; |     let output = cmd.output()?; | ||||||
|     if !output.status.success() { |     if !output.status.success() { | ||||||
|         anyhow::bail!( |         anyhow::bail!( | ||||||
|             "{} failed, status: {}, stderr: {}", |             "{} failed, status: {}, stderr: {}", | ||||||
|             constants::PROGRAM_IP, |             constants::IP_PROGRAM, | ||||||
|             output.status, |             output.status, | ||||||
|             String::from_utf8_lossy(&output.stderr) |             String::from_utf8_lossy(&output.stderr) | ||||||
|         ); |         ); | ||||||
|   | |||||||
| @@ -1,4 +1,3 @@ | |||||||
| use chrono::Datelike; |  | ||||||
| use std::time::{SystemTime, UNIX_EPOCH}; | use std::time::{SystemTime, UNIX_EPOCH}; | ||||||
|  |  | ||||||
| /// Get the current time since epoch | /// Get the current time since epoch | ||||||
| @@ -14,15 +13,3 @@ pub fn time() -> u64 { | |||||||
|         .unwrap() |         .unwrap() | ||||||
|         .as_secs() |         .as_secs() | ||||||
| } | } | ||||||
|  |  | ||||||
| /// Format given UNIX time in a simple format |  | ||||||
| pub fn format_date(time: i64) -> anyhow::Result<String> { |  | ||||||
|     let date = chrono::DateTime::from_timestamp(time, 0).ok_or(anyhow::anyhow!("invalid date"))?; |  | ||||||
|  |  | ||||||
|     Ok(format!( |  | ||||||
|         "{:0>2}/{:0>2}/{}", |  | ||||||
|         date.day(), |  | ||||||
|         date.month(), |  | ||||||
|         date.year() |  | ||||||
|     )) |  | ||||||
| } |  | ||||||
|   | |||||||
| @@ -44,9 +44,6 @@ pub struct VMFileDisk { | |||||||
|     /// When creating a new disk, specify the disk image template to use |     /// When creating a new disk, specify the disk image template to use | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |     #[serde(skip_serializing_if = "Option::is_none")] | ||||||
|     pub from_image: Option<String>, |     pub from_image: Option<String>, | ||||||
|     /// Set this variable to true to resize disk image |  | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |  | ||||||
|     pub resize: Option<bool>, |  | ||||||
|     /// Set this variable to true to delete the disk |     /// Set this variable to true to delete the disk | ||||||
|     pub delete: bool, |     pub delete: bool, | ||||||
| } | } | ||||||
| @@ -81,7 +78,6 @@ impl VMFileDisk { | |||||||
|  |  | ||||||
|             delete: false, |             delete: false, | ||||||
|             from_image: None, |             from_image: None, | ||||||
|             resize: None, |  | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -148,40 +144,28 @@ impl VMFileDisk { | |||||||
|  |  | ||||||
|         if file.exists() { |         if file.exists() { | ||||||
|             log::debug!("File {file:?} does not exists, so it was not touched"); |             log::debug!("File {file:?} does not exists, so it was not touched"); | ||||||
|  |             return Ok(()); | ||||||
|         } |         } | ||||||
|         // Create disk if required |  | ||||||
|         else { |  | ||||||
|             // Determine file format |  | ||||||
|             let format = match self.format { |  | ||||||
|                 VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse }, |  | ||||||
|                 VMDiskFormat::QCow2 => DiskFileFormat::QCow2 { |  | ||||||
|                     virtual_size: self.size, |  | ||||||
|                 }, |  | ||||||
|             }; |  | ||||||
|  |  | ||||||
|             // Create / Restore disk file |         let format = match self.format { | ||||||
|             match &self.from_image { |             VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse }, | ||||||
|                 // Create disk file |             VMDiskFormat::QCow2 => DiskFileFormat::QCow2 { | ||||||
|                 None => { |                 virtual_size: self.size, | ||||||
|                     DiskFileInfo::create(&file, format, self.size)?; |             }, | ||||||
|                 } |         }; | ||||||
|  |  | ||||||
|                 // Restore disk image template |         // Create / Restore disk file | ||||||
|                 Some(disk_img) => { |         match &self.from_image { | ||||||
|                     let src_file = |             // Create disk file | ||||||
|                         DiskFileInfo::load_file(&AppConfig::get().disk_images_file_path(disk_img))?; |             None => { | ||||||
|                     src_file.convert(&file, format)?; |                 DiskFileInfo::create(&file, format, self.size)?; | ||||||
|                 } |  | ||||||
|             } |             } | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Resize disk file if requested |             // Restore disk image template | ||||||
|         if self.resize == Some(true) { |             Some(disk_img) => { | ||||||
|             let disk = DiskFileInfo::load_file(&file)?; |                 let src_file = | ||||||
|  |                     DiskFileInfo::load_file(&AppConfig::get().disk_images_file_path(disk_img))?; | ||||||
|             // Can only increase disk size |                 src_file.convert(&file, format)?; | ||||||
|             if let Err(e) = disk.resize(self.size) { |  | ||||||
|                 log::error!("Failed to resize disk file {}: {e:?}", self.name); |  | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,9 +5,9 @@ | |||||||
| sudo apt install libvirt-dev | sudo apt install libvirt-dev | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| 2. Libvirt and cloud image utilities must also be installed: | 2. Libvirt must also be installed: | ||||||
| ```bash | ```bash | ||||||
| sudo apt install qemu-kvm libvirt-daemon-system cloud-image-utils | sudo apt install qemu-kvm libvirt-daemon-system | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| 3. Allow the current user to manage VMs: | 3. Allow the current user to manage VMs: | ||||||
|   | |||||||
| @@ -12,10 +12,10 @@ The release file will be available in `virtweb_backend/target/release/virtweb_ba | |||||||
| This is the only artifact that must be copied to the server. It is recommended to copy it to the `/usr/local/bin` directory. | This is the only artifact that must be copied to the server. It is recommended to copy it to the `/usr/local/bin` directory. | ||||||
|  |  | ||||||
| ## Install requirements | ## Install requirements | ||||||
| In order to work properly, VirtWeb relies on `libvirt`, `qemu`, `kvm` and `cloud-localds`: | In order to work properly, VirtWeb relies on `libvirt`, `qemu` and `kvm`: | ||||||
|  |  | ||||||
| ```bash | ```bash | ||||||
| sudo apt install qemu-kvm libvirt-daemon-system libvirt0 libvirt-clients libvirt-daemon bridge-utils cloud-image-utils | sudo apt install qemu-kvm libvirt-daemon-system libvirt0 libvirt-clients libvirt-daemon bridge-utils | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| ## Dedicated user | ## Dedicated user | ||||||
|   | |||||||
							
								
								
									
										2197
									
								
								virtweb_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2197
									
								
								virtweb_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -11,46 +11,42 @@ | |||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@emotion/react": "^11.14.0", |     "@emotion/react": "^11.14.0", | ||||||
|     "@emotion/styled": "^11.14.1", |     "@emotion/styled": "^11.14.0", | ||||||
|     "@fontsource/roboto": "^5.2.8", |     "@fontsource/roboto": "^5.2.5", | ||||||
|     "@mdi/js": "^7.4.47", |     "@mdi/js": "^7.4.47", | ||||||
|     "@mdi/react": "^1.6.1", |     "@mdi/react": "^1.6.1", | ||||||
|     "@monaco-editor/react": "^4.7.0", |     "@mui/icons-material": "^7.1.0", | ||||||
|     "@mui/icons-material": "^7.3.4", |     "@mui/material": "^7.1.0", | ||||||
|     "@mui/material": "^7.3.4", |  | ||||||
|     "@mui/x-charts": "^8.3.1", |     "@mui/x-charts": "^8.3.1", | ||||||
|     "@mui/x-data-grid": "^8.11.3", |     "@mui/x-data-grid": "^8.3.1", | ||||||
|     "date-and-time": "^3.6.0", |     "date-and-time": "^3.6.0", | ||||||
|     "filesize": "^10.1.6", |     "filesize": "^10.1.6", | ||||||
|     "humanize-duration": "^3.33.1", |     "humanize-duration": "^3.32.2", | ||||||
|     "monaco-editor": "^0.52.2", |     "react": "^19.1.0", | ||||||
|     "monaco-yaml": "^5.4.0", |     "react-dom": "^19.1.0", | ||||||
|     "react": "^19.2.0", |     "react-router-dom": "^7.6.0", | ||||||
|     "react-dom": "^19.2.0", |     "react-syntax-highlighter": "^15.6.1", | ||||||
|     "react-router-dom": "^7.9.5", |  | ||||||
|     "react-syntax-highlighter": "^15.6.6", |  | ||||||
|     "react-vnc": "^3.1.0", |     "react-vnc": "^3.1.0", | ||||||
|     "uuid": "^11.1.0", |     "uuid": "^11.1.0", | ||||||
|     "xml-formatter": "^3.6.6", |     "xml-formatter": "^3.6.6" | ||||||
|     "yaml": "^2.8.1" |  | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@eslint/js": "^9.35.0", |     "@eslint/js": "^9.27.0", | ||||||
|     "@types/humanize-duration": "^3.27.4", |     "@types/humanize-duration": "^3.27.4", | ||||||
|     "@types/jest": "^30.0.0", |     "@types/jest": "^29.5.14", | ||||||
|     "@types/react": "^19.2.2", |     "@types/react": "^19.1.6", | ||||||
|     "@types/react-dom": "^19.2.2", |     "@types/react-dom": "^19.1.5", | ||||||
|     "@types/react-syntax-highlighter": "^15.5.13", |     "@types/react-syntax-highlighter": "^15.5.13", | ||||||
|     "@types/uuid": "^10.0.0", |     "@types/uuid": "^10.0.0", | ||||||
|     "@vitejs/plugin-react": "^4.7.0", |     "@vitejs/plugin-react": "^4.4.1", | ||||||
|     "eslint": "^9.35.0", |     "eslint": "^9.27.0", | ||||||
|     "eslint-plugin-react-dom": "^1.53.1", |     "eslint-plugin-react-dom": "^1.49.0", | ||||||
|     "eslint-plugin-react-hooks": "^5.2.0", |     "eslint-plugin-react-hooks": "^5.2.0", | ||||||
|     "eslint-plugin-react-refresh": "^0.4.24", |     "eslint-plugin-react-refresh": "^0.4.20", | ||||||
|     "eslint-plugin-react-x": "^1.53.1", |     "eslint-plugin-react-x": "^1.49.0", | ||||||
|     "globals": "^16.3.0", |     "globals": "^16.1.0", | ||||||
|     "typescript": "^5.9.3", |     "typescript": "^5.8.3", | ||||||
|     "typescript-eslint": "^8.43.0", |     "typescript-eslint": "^8.32.1", | ||||||
|     "vite": "^6.3.6" |     "vite": "^6.3.5" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -4,10 +4,8 @@ import { VMFileDisk, VMInfo } from "./VMApi"; | |||||||
| export type DiskImageFormat = | export type DiskImageFormat = | ||||||
|   | { format: "Raw"; is_sparse: boolean } |   | { format: "Raw"; is_sparse: boolean } | ||||||
|   | { format: "QCow2"; virtual_size?: number } |   | { format: "QCow2"; virtual_size?: number } | ||||||
|   | { format: "GzCompressedQCow2" } |   | { format: "CompressedQCow2" } | ||||||
|   | { format: "GzCompressedRaw" } |   | { format: "CompressedRaw" }; | ||||||
|   | { format: "XzCompressedQCow2" } |  | ||||||
|   | { format: "XzCompressedRaw" }; |  | ||||||
|  |  | ||||||
| export type DiskImage = { | export type DiskImage = { | ||||||
|   file_size: number; |   file_size: number; | ||||||
|   | |||||||
| @@ -232,16 +232,4 @@ export class ServerApi { | |||||||
|       }) |       }) | ||||||
|     ).data; |     ).data; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |  | ||||||
|    * Export all server configs |  | ||||||
|    */ |  | ||||||
|   static async ExportServerConfigs(): Promise<Blob> { |  | ||||||
|     return ( |  | ||||||
|       await APIClient.exec({ |  | ||||||
|         method: "GET", |  | ||||||
|         uri: "/server/export_configs", |  | ||||||
|       }) |  | ||||||
|     ).data; |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -31,12 +31,8 @@ export interface BaseFileVMDisk { | |||||||
|   // For new disk only |   // For new disk only | ||||||
|   from_image?: string; |   from_image?: string; | ||||||
|  |  | ||||||
|   // Resize disk image after clone |  | ||||||
|   resize?: boolean; |  | ||||||
|  |  | ||||||
|   // application attributes |   // application attributes | ||||||
|   new?: boolean; |   new?: boolean; | ||||||
|   originalSize?: number; |  | ||||||
|   deleteType?: "keepfile" | "deletefile"; |   deleteType?: "keepfile" | "deletefile"; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -86,15 +82,6 @@ export interface VMNetBridge { | |||||||
|   bridge: string; |   bridge: string; | ||||||
| } | } | ||||||
|  |  | ||||||
| export interface VMCloudInit { |  | ||||||
|   attach_config: boolean; |  | ||||||
|   user_data: string; |  | ||||||
|   instance_id?: string; |  | ||||||
|   local_hostname?: string; |  | ||||||
|   dsmode?: "Net" | "Local"; |  | ||||||
|   network_configuration?: string; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export type VMBootType = "UEFI" | "UEFISecureBoot" | "Legacy"; | export type VMBootType = "UEFI" | "UEFISecureBoot" | "Legacy"; | ||||||
|  |  | ||||||
| interface VMInfoInterface { | interface VMInfoInterface { | ||||||
| @@ -114,7 +101,6 @@ interface VMInfoInterface { | |||||||
|   networks: VMNetInterface[]; |   networks: VMNetInterface[]; | ||||||
|   tpm_module: boolean; |   tpm_module: boolean; | ||||||
|   oem_strings: string[]; |   oem_strings: string[]; | ||||||
|   cloud_init: VMCloudInit; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| export class VMInfo implements VMInfoInterface { | export class VMInfo implements VMInfoInterface { | ||||||
| @@ -134,7 +120,6 @@ export class VMInfo implements VMInfoInterface { | |||||||
|   networks: VMNetInterface[]; |   networks: VMNetInterface[]; | ||||||
|   tpm_module: boolean; |   tpm_module: boolean; | ||||||
|   oem_strings: string[]; |   oem_strings: string[]; | ||||||
|   cloud_init: VMCloudInit; |  | ||||||
|  |  | ||||||
|   constructor(int: VMInfoInterface) { |   constructor(int: VMInfoInterface) { | ||||||
|     this.name = int.name; |     this.name = int.name; | ||||||
| @@ -153,7 +138,6 @@ export class VMInfo implements VMInfoInterface { | |||||||
|     this.networks = int.networks; |     this.networks = int.networks; | ||||||
|     this.tpm_module = int.tpm_module; |     this.tpm_module = int.tpm_module; | ||||||
|     this.oem_strings = int.oem_strings; |     this.oem_strings = int.oem_strings; | ||||||
|     this.cloud_init = int.cloud_init; |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   static NewEmpty(): VMInfo { |   static NewEmpty(): VMInfo { | ||||||
| @@ -169,7 +153,6 @@ export class VMInfo implements VMInfoInterface { | |||||||
|       networks: [], |       networks: [], | ||||||
|       tpm_module: true, |       tpm_module: true, | ||||||
|       oem_strings: [], |       oem_strings: [], | ||||||
|       cloud_init: { attach_config: false, user_data: "" }, |  | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -42,15 +42,13 @@ export function ConvertDiskImageDialog( | |||||||
|     setFormat({ format: value ?? ("QCow2" as any) }); |     setFormat({ format: value ?? ("QCow2" as any) }); | ||||||
|  |  | ||||||
|     if (value === "QCow2") setFilename(`${origFilename}.qcow2`); |     if (value === "QCow2") setFilename(`${origFilename}.qcow2`); | ||||||
|     if (value === "GzCompressedQCow2") setFilename(`${origFilename}.qcow2.gz`); |     if (value === "CompressedQCow2") setFilename(`${origFilename}.qcow2.gz`); | ||||||
|     if (value === "XzCompressedQCow2") setFilename(`${origFilename}.qcow2.xz`); |  | ||||||
|     if (value === "Raw") { |     if (value === "Raw") { | ||||||
|       setFilename(`${origFilename}.raw`); |       setFilename(`${origFilename}.raw`); | ||||||
|       // Check sparse checkbox by default |       // Check sparse checkbox by default | ||||||
|       setFormat({ format: "Raw", is_sparse: true }); |       setFormat({ format: "Raw", is_sparse: true }); | ||||||
|     } |     } | ||||||
|     if (value === "GzCompressedRaw") setFilename(`${origFilename}.raw.gz`); |     if (value === "CompressedRaw") setFilename(`${origFilename}.raw.gz`); | ||||||
|     if (value === "XzCompressedRaw") setFilename(`${origFilename}.raw.xz`); |  | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   const handleSubmit = async () => { |   const handleSubmit = async () => { | ||||||
| @@ -106,10 +104,8 @@ export function ConvertDiskImageDialog( | |||||||
|           options={[ |           options={[ | ||||||
|             { value: "QCow2" }, |             { value: "QCow2" }, | ||||||
|             { value: "Raw" }, |             { value: "Raw" }, | ||||||
|             { value: "GzCompressedRaw" }, |             { value: "CompressedRaw" }, | ||||||
|             { value: "XzCompressedRaw" }, |             { value: "CompressedQCow2" }, | ||||||
|             { value: "GzCompressedQCow2" }, |  | ||||||
|             { value: "XzCompressedQCow2" }, |  | ||||||
|           ]} |           ]} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,44 +3,16 @@ import "@fontsource/roboto/400.css"; | |||||||
| import "@fontsource/roboto/500.css"; | import "@fontsource/roboto/500.css"; | ||||||
| import "@fontsource/roboto/700.css"; | import "@fontsource/roboto/700.css"; | ||||||
|  |  | ||||||
| import { ThemeProvider, createTheme } from "@mui/material"; |  | ||||||
| import React from "react"; | import React from "react"; | ||||||
| import ReactDOM from "react-dom/client"; | import ReactDOM from "react-dom/client"; | ||||||
| import { App } from "./App"; | import { App } from "./App"; | ||||||
| import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider"; |  | ||||||
| import { ConfirmDialogProvider } from "./hooks/providers/ConfirmDialogProvider"; |  | ||||||
| import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider"; |  | ||||||
| import { SnackbarProvider } from "./hooks/providers/SnackbarProvider"; |  | ||||||
| import "./index.css"; | import "./index.css"; | ||||||
| import { LoadServerConfig } from "./widgets/LoadServerConfig"; | import { LoadServerConfig } from "./widgets/LoadServerConfig"; | ||||||
|  | import { ThemeProvider, createTheme } from "@mui/material"; | ||||||
| import { loader } from "@monaco-editor/react"; | import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider"; | ||||||
| import * as monaco from "monaco-editor"; | import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider"; | ||||||
| import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; | import { SnackbarProvider } from "./hooks/providers/SnackbarProvider"; | ||||||
| import { configureMonacoYaml } from "monaco-yaml"; | import { ConfirmDialogProvider } from "./hooks/providers/ConfirmDialogProvider"; | ||||||
| import YamlWorker from "monaco-yaml/yaml.worker?worker"; |  | ||||||
|  |  | ||||||
| // This allows to use a self hosted instance of Monaco editor |  | ||||||
| loader.config({ monaco }); |  | ||||||
|  |  | ||||||
| // Add YAML support to Monaco |  | ||||||
| configureMonacoYaml(monaco, { |  | ||||||
|   enableSchemaRequest: false, |  | ||||||
| }); |  | ||||||
|  |  | ||||||
| /// YAML worker |  | ||||||
| window.MonacoEnvironment = { |  | ||||||
|   getWorker(_moduleId, label) { |  | ||||||
|     switch (label) { |  | ||||||
|       case "editorWorkerService": |  | ||||||
|         return new EditorWorker(); |  | ||||||
|       case "yaml": |  | ||||||
|         return new YamlWorker(); |  | ||||||
|       default: |  | ||||||
|         throw new Error(`Unknown label ${label}`); |  | ||||||
|     } |  | ||||||
|   }, |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| const darkTheme = createTheme({ | const darkTheme = createTheme({ | ||||||
|   palette: { |   palette: { | ||||||
| @@ -48,7 +20,9 @@ const darkTheme = createTheme({ | |||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const root = ReactDOM.createRoot(document.getElementById("root")!); | const root = ReactDOM.createRoot( | ||||||
|  |   document.getElementById("root")! | ||||||
|  | ); | ||||||
| root.render( | root.render( | ||||||
|   <React.StrictMode> |   <React.StrictMode> | ||||||
|     <ThemeProvider theme={darkTheme}> |     <ThemeProvider theme={darkTheme}> | ||||||
|   | |||||||
| @@ -9,21 +9,18 @@ import { | |||||||
| import Icon from "@mdi/react"; | import Icon from "@mdi/react"; | ||||||
| import { | import { | ||||||
|   Box, |   Box, | ||||||
|   IconButton, |  | ||||||
|   LinearProgress, |   LinearProgress, | ||||||
|   Table, |   Table, | ||||||
|   TableBody, |   TableBody, | ||||||
|   TableCell, |   TableCell, | ||||||
|   TableHead, |   TableHead, | ||||||
|   TableRow, |   TableRow, | ||||||
|   Tooltip, |  | ||||||
|   Typography, |   Typography, | ||||||
| } from "@mui/material"; | } from "@mui/material"; | ||||||
| import Grid from "@mui/material/Grid"; | import Grid from "@mui/material/Grid"; | ||||||
| import { PieChart } from "@mui/x-charts"; | import { PieChart } from "@mui/x-charts"; | ||||||
| import { filesize } from "filesize"; | import { filesize } from "filesize"; | ||||||
| import humanizeDuration from "humanize-duration"; | import humanizeDuration from "humanize-duration"; | ||||||
| import IosShareIcon from "@mui/icons-material/IosShare"; |  | ||||||
| import React from "react"; | import React from "react"; | ||||||
| import { | import { | ||||||
|   DiskInfo, |   DiskInfo, | ||||||
| @@ -34,8 +31,6 @@ import { | |||||||
| import { AsyncWidget } from "../widgets/AsyncWidget"; | import { AsyncWidget } from "../widgets/AsyncWidget"; | ||||||
| import { VirtWebPaper } from "../widgets/VirtWebPaper"; | import { VirtWebPaper } from "../widgets/VirtWebPaper"; | ||||||
| import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; | import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; | ||||||
| import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider"; |  | ||||||
| import { useAlert } from "../hooks/providers/AlertDialogProvider"; |  | ||||||
|  |  | ||||||
| export function SysInfoRoute(): React.ReactElement { | export function SysInfoRoute(): React.ReactElement { | ||||||
|   const [info, setInfo] = React.useState<ServerSystemInfo>(); |   const [info, setInfo] = React.useState<ServerSystemInfo>(); | ||||||
| @@ -57,23 +52,6 @@ export function SysInfoRoute(): React.ReactElement { | |||||||
| export function SysInfoRouteInner(p: { | export function SysInfoRouteInner(p: { | ||||||
|   info: ServerSystemInfo; |   info: ServerSystemInfo; | ||||||
| }): React.ReactElement { | }): React.ReactElement { | ||||||
|   const alert = useAlert(); |  | ||||||
|   const loadingMessage = useLoadingMessage(); |  | ||||||
|   const downloadAllConfig = async () => { |  | ||||||
|     try { |  | ||||||
|       loadingMessage.show("Downloading server config..."); |  | ||||||
|       const res = await ServerApi.ExportServerConfigs(); |  | ||||||
|  |  | ||||||
|       const url = URL.createObjectURL(res); |  | ||||||
|       window.location.href = url; |  | ||||||
|     } catch (e) { |  | ||||||
|       console.error("Failed to download server config!", e); |  | ||||||
|       alert(`Failed to download server config! ${e}`); |  | ||||||
|     } finally { |  | ||||||
|       loadingMessage.hide(); |  | ||||||
|     } |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   const sumDiskUsage = p.info.disks.reduce( |   const sumDiskUsage = p.info.disks.reduce( | ||||||
|     (prev, disk) => { |     (prev, disk) => { | ||||||
|       return { |       return { | ||||||
| @@ -85,16 +63,7 @@ export function SysInfoRouteInner(p: { | |||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <VirtWebRouteContainer |     <VirtWebRouteContainer label="Sysinfo"> | ||||||
|       label="Sysinfo" |  | ||||||
|       actions={ |  | ||||||
|         <Tooltip title="Export all server configs"> |  | ||||||
|           <IconButton onClick={downloadAllConfig}> |  | ||||||
|             <IosShareIcon /> |  | ||||||
|           </IconButton> |  | ||||||
|         </Tooltip> |  | ||||||
|       } |  | ||||||
|     > |  | ||||||
|       <Grid container spacing={2}> |       <Grid container spacing={2}> | ||||||
|         {/* Memory */} |         {/* Memory */} | ||||||
|         <Grid size={{ xs: 4 }}> |         <Grid size={{ xs: 4 }}> | ||||||
| @@ -319,7 +288,7 @@ function DiskDetailsTable(p: { disks: DiskInfo[] }): React.ReactElement { | |||||||
|           {p.disks.map((e, c) => ( |           {p.disks.map((e, c) => ( | ||||||
|             <TableRow hover key={c}> |             <TableRow hover key={c}> | ||||||
|               <TableCell>{e.name}</TableCell> |               <TableCell>{e.name}</TableCell> | ||||||
|               <TableCell>{String(e.DiskKind)}</TableCell> |               <TableCell>{e.DiskKind}</TableCell> | ||||||
|               <TableCell>{e.mount_point}</TableCell> |               <TableCell>{e.mount_point}</TableCell> | ||||||
|               <TableCell>{filesize(e.total_space)}</TableCell> |               <TableCell>{filesize(e.total_space)}</TableCell> | ||||||
|               <TableCell>{filesize(e.available_space)}</TableCell> |               <TableCell>{filesize(e.available_space)}</TableCell> | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import VisibilityIcon from "@mui/icons-material/Visibility"; | import VisibilityIcon from '@mui/icons-material/Visibility'; | ||||||
| import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; | import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; | ||||||
| import { | import { | ||||||
|   Alert, |   Alert, | ||||||
|   CircularProgress, |   CircularProgress, | ||||||
| @@ -36,9 +36,7 @@ export function LoginRoute(): React.ReactElement { | |||||||
|   const canSubmit = username.length > 0 && password.length > 0; |   const canSubmit = username.length > 0 && password.length > 0; | ||||||
|  |  | ||||||
|   const [showPassword, setShowPassword] = React.useState(false); |   const [showPassword, setShowPassword] = React.useState(false); | ||||||
|   const handleClickShowPassword = () => { |   const handleClickShowPassword = () => { setShowPassword((show) => !show); }; | ||||||
|     setShowPassword((show) => !show); |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   const handleMouseDownPassword = ( |   const handleMouseDownPassword = ( | ||||||
|     event: React.MouseEvent<HTMLButtonElement> |     event: React.MouseEvent<HTMLButtonElement> | ||||||
| @@ -107,14 +105,12 @@ export function LoginRoute(): React.ReactElement { | |||||||
|               label="Username" |               label="Username" | ||||||
|               name="username" |               name="username" | ||||||
|               value={username} |               value={username} | ||||||
|               onChange={(e) => { |               onChange={(e) => { setUsername(e.target.value); }} | ||||||
|                 setUsername(e.target.value); |  | ||||||
|               }} |  | ||||||
|               autoComplete="username" |               autoComplete="username" | ||||||
|               autoFocus |               autoFocus | ||||||
|             /> |             /> | ||||||
|  |  | ||||||
|             <FormControl required fullWidth variant="outlined"> |             <FormControl fullWidth variant="outlined"> | ||||||
|               <InputLabel htmlFor="password">Password</InputLabel> |               <InputLabel htmlFor="password">Password</InputLabel> | ||||||
|               <OutlinedInput |               <OutlinedInput | ||||||
|                 required |                 required | ||||||
| @@ -124,9 +120,7 @@ export function LoginRoute(): React.ReactElement { | |||||||
|                 type={showPassword ? "text" : "password"} |                 type={showPassword ? "text" : "password"} | ||||||
|                 id="password" |                 id="password" | ||||||
|                 value={password} |                 value={password} | ||||||
|                 onChange={(e) => { |                 onChange={(e) => { setPassword(e.target.value); }} | ||||||
|                   setPassword(e.target.value); |  | ||||||
|                 }} |  | ||||||
|                 autoComplete="current-password" |                 autoComplete="current-password" | ||||||
|                 endAdornment={ |                 endAdornment={ | ||||||
|                   <InputAdornment position="end"> |                   <InputAdornment position="end"> | ||||||
| @@ -137,11 +131,7 @@ export function LoginRoute(): React.ReactElement { | |||||||
|                         onMouseDown={handleMouseDownPassword} |                         onMouseDown={handleMouseDownPassword} | ||||||
|                         edge="end" |                         edge="end" | ||||||
|                       > |                       > | ||||||
|                         {showPassword ? ( |                         {showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />} | ||||||
|                           <VisibilityOffIcon /> |  | ||||||
|                         ) : ( |  | ||||||
|                           <VisibilityIcon /> |  | ||||||
|                         )} |  | ||||||
|                       </IconButton> |                       </IconButton> | ||||||
|                     </Tooltip> |                     </Tooltip> | ||||||
|                   </InputAdornment> |                   </InputAdornment> | ||||||
|   | |||||||
| @@ -17,9 +17,7 @@ export function CheckboxInput(p: { | |||||||
|         <Checkbox |         <Checkbox | ||||||
|           disabled={!p.editable} |           disabled={!p.editable} | ||||||
|           checked={p.checked} |           checked={p.checked} | ||||||
|           onChange={(e) => { |           onChange={(e) => { p.onValueChange(e.target.checked); }} | ||||||
|             p.onValueChange(e.target.checked); |  | ||||||
|           }} |  | ||||||
|         /> |         /> | ||||||
|       } |       } | ||||||
|       label={p.label} |       label={p.label} | ||||||
|   | |||||||
| @@ -1,341 +0,0 @@ | |||||||
| /* eslint-disable @typescript-eslint/no-base-to-string */ |  | ||||||
|  |  | ||||||
| import Editor from "@monaco-editor/react"; |  | ||||||
| import BookIcon from "@mui/icons-material/Book"; |  | ||||||
| import RefreshIcon from "@mui/icons-material/Refresh"; |  | ||||||
| import { Grid, IconButton, InputAdornment, Tooltip } from "@mui/material"; |  | ||||||
| import React from "react"; |  | ||||||
| import { v4 as uuidv4 } from "uuid"; |  | ||||||
| import YAML from "yaml"; |  | ||||||
| import { VMInfo } from "../../api/VMApi"; |  | ||||||
| import { RouterLink } from "../RouterLink"; |  | ||||||
| import { CheckboxInput } from "./CheckboxInput"; |  | ||||||
| import { EditSection } from "./EditSection"; |  | ||||||
| import { SelectInput } from "./SelectInput"; |  | ||||||
| import { TextInput } from "./TextInput"; |  | ||||||
|  |  | ||||||
| interface CloudInitProps { |  | ||||||
|   vm: VMInfo; |  | ||||||
|   onChange?: () => void; |  | ||||||
|   editable: boolean; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export function CloudInitEditor(p: CloudInitProps): React.ReactElement { |  | ||||||
|   return ( |  | ||||||
|     <> |  | ||||||
|       <EditSection> |  | ||||||
|         {/* Attach cloud init disk */} |  | ||||||
|         <CheckboxInput |  | ||||||
|           {...p} |  | ||||||
|           label="Attach Cloud Init disk" |  | ||||||
|           checked={p.vm.cloud_init.attach_config} |  | ||||||
|           onValueChange={(v) => { |  | ||||||
|             p.vm.cloud_init.attach_config = v; |  | ||||||
|             p.onChange?.(); |  | ||||||
|           }} |  | ||||||
|         /> |  | ||||||
|       </EditSection> |  | ||||||
|       <Grid container spacing={2}> |  | ||||||
|         <CloudInitMetadata |  | ||||||
|           {...p} |  | ||||||
|           editable={p.editable && p.vm.cloud_init.attach_config} |  | ||||||
|         /> |  | ||||||
|         <CloudInitRawUserData |  | ||||||
|           {...p} |  | ||||||
|           editable={p.editable && p.vm.cloud_init.attach_config} |  | ||||||
|         /> |  | ||||||
|         <CloudInitNetworkConfig |  | ||||||
|           {...p} |  | ||||||
|           editable={p.editable && p.vm.cloud_init.attach_config} |  | ||||||
|         /> |  | ||||||
|         <CloudInitUserDataAssistant |  | ||||||
|           {...p} |  | ||||||
|           editable={p.editable && p.vm.cloud_init.attach_config} |  | ||||||
|         /> |  | ||||||
|       </Grid> |  | ||||||
|     </> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function CloudInitMetadata(p: CloudInitProps): React.ReactElement { |  | ||||||
|   // Regenerate instance id |  | ||||||
|   const reGenerateInstanceId = () => { |  | ||||||
|     p.vm.cloud_init.instance_id = uuidv4(); |  | ||||||
|     p.onChange?.(); |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   return ( |  | ||||||
|     <EditSection title="Metadata"> |  | ||||||
|       {/* Instance ID */} |  | ||||||
|       <TextInput |  | ||||||
|         {...p} |  | ||||||
|         label="Instance ID" |  | ||||||
|         value={p.vm.cloud_init.instance_id} |  | ||||||
|         onValueChange={(v) => { |  | ||||||
|           p.vm.cloud_init.instance_id = v; |  | ||||||
|           p.onChange?.(); |  | ||||||
|         }} |  | ||||||
|         endAdornment={ |  | ||||||
|           p.editable ? ( |  | ||||||
|             <InputAdornment position="end"> |  | ||||||
|               <Tooltip title="Generate a new instance ID"> |  | ||||||
|                 <IconButton onClick={reGenerateInstanceId}> |  | ||||||
|                   <RefreshIcon /> |  | ||||||
|                 </IconButton> |  | ||||||
|               </Tooltip> |  | ||||||
|             </InputAdornment> |  | ||||||
|           ) : ( |  | ||||||
|             <></> |  | ||||||
|           ) |  | ||||||
|         } |  | ||||||
|       /> |  | ||||||
|  |  | ||||||
|       {/* Instance hostname */} |  | ||||||
|       <TextInput |  | ||||||
|         {...p} |  | ||||||
|         label="Local hostname" |  | ||||||
|         value={p.vm.cloud_init.local_hostname} |  | ||||||
|         onValueChange={(v) => { |  | ||||||
|           p.vm.cloud_init.local_hostname = v; |  | ||||||
|           p.onChange?.(); |  | ||||||
|         }} |  | ||||||
|       /> |  | ||||||
|  |  | ||||||
|       {/* Data source mode */} |  | ||||||
|       <SelectInput |  | ||||||
|         {...p} |  | ||||||
|         label="Data source mode" |  | ||||||
|         value={p.vm.cloud_init.dsmode} |  | ||||||
|         onValueChange={(v) => { |  | ||||||
|           p.vm.cloud_init.dsmode = v as any; |  | ||||||
|           p.onChange?.(); |  | ||||||
|         }} |  | ||||||
|         options={[ |  | ||||||
|           { label: "None", value: undefined }, |  | ||||||
|           { value: "Net" }, |  | ||||||
|           { value: "Local" }, |  | ||||||
|         ]} |  | ||||||
|       /> |  | ||||||
|     </EditSection> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function CloudInitRawUserData(p: CloudInitProps): React.ReactElement { |  | ||||||
|   return ( |  | ||||||
|     <EditSection |  | ||||||
|       title="User data" |  | ||||||
|       actions={ |  | ||||||
|         <RouterLink |  | ||||||
|           target="_blank" |  | ||||||
|           to="https://cloudinit.readthedocs.io/en/latest/reference/index.html" |  | ||||||
|         > |  | ||||||
|           <Tooltip title="Official reference"> |  | ||||||
|             <IconButton size="small"> |  | ||||||
|               <BookIcon /> |  | ||||||
|             </IconButton> |  | ||||||
|           </Tooltip> |  | ||||||
|         </RouterLink> |  | ||||||
|       } |  | ||||||
|     > |  | ||||||
|       <Editor |  | ||||||
|         theme="vs-dark" |  | ||||||
|         options={{ |  | ||||||
|           readOnly: !p.editable, |  | ||||||
|           quickSuggestions: { other: true, comments: true, strings: true }, |  | ||||||
|           wordWrap: "on", |  | ||||||
|         }} |  | ||||||
|         language="yaml" |  | ||||||
|         height={"30vh"} |  | ||||||
|         value={p.vm.cloud_init.user_data} |  | ||||||
|         onChange={(v) => { |  | ||||||
|           p.vm.cloud_init.user_data = v ?? ""; |  | ||||||
|           p.onChange?.(); |  | ||||||
|         }} |  | ||||||
|       /> |  | ||||||
|     </EditSection> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function CloudInitNetworkConfig(p: CloudInitProps): React.ReactElement { |  | ||||||
|   if (!p.editable && !p.vm.cloud_init.network_configuration) return <></>; |  | ||||||
|   return ( |  | ||||||
|     <EditSection |  | ||||||
|       title="Network configuration" |  | ||||||
|       actions={ |  | ||||||
|         <RouterLink |  | ||||||
|           target="_blank" |  | ||||||
|           to="https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v2.html" |  | ||||||
|         > |  | ||||||
|           <Tooltip title="Official network configuration reference"> |  | ||||||
|             <IconButton size="small"> |  | ||||||
|               <BookIcon /> |  | ||||||
|             </IconButton> |  | ||||||
|           </Tooltip> |  | ||||||
|         </RouterLink> |  | ||||||
|       } |  | ||||||
|     > |  | ||||||
|       <Editor |  | ||||||
|         theme="vs-dark" |  | ||||||
|         options={{ |  | ||||||
|           readOnly: !p.editable, |  | ||||||
|           quickSuggestions: { other: true, comments: true, strings: true }, |  | ||||||
|           wordWrap: "on", |  | ||||||
|         }} |  | ||||||
|         language="yaml" |  | ||||||
|         height={"30vh"} |  | ||||||
|         value={p.vm.cloud_init.network_configuration ?? ""} |  | ||||||
|         onChange={(v) => { |  | ||||||
|           if (v && v !== "") p.vm.cloud_init.network_configuration = v; |  | ||||||
|           else p.vm.cloud_init.network_configuration = undefined; |  | ||||||
|           p.onChange?.(); |  | ||||||
|         }} |  | ||||||
|       /> |  | ||||||
|     </EditSection> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function CloudInitUserDataAssistant(p: CloudInitProps): React.ReactElement { |  | ||||||
|   const user_data = React.useMemo(() => { |  | ||||||
|     return YAML.parseDocument(p.vm.cloud_init.user_data); |  | ||||||
|   }, [p.vm.cloud_init.user_data]); |  | ||||||
|  |  | ||||||
|   const onChange = () => { |  | ||||||
|     p.vm.cloud_init.user_data = user_data.toString(); |  | ||||||
|  |  | ||||||
|     if (!p.vm.cloud_init.user_data.startsWith("#cloud-config")) |  | ||||||
|       p.vm.cloud_init.user_data = `#cloud-config\n${p.vm.cloud_init.user_data}`; |  | ||||||
|  |  | ||||||
|     p.onChange?.(); |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   const SYSTEMD_NOT_SERIAL = `/bin/sh -c "rm -f /etc/default/grub.d/50-cloudimg-settings.cfg && sed -i 's/quiet splash//g' /etc/default/grub && update-grub"`; |  | ||||||
|  |  | ||||||
|   return ( |  | ||||||
|     <EditSection title="User data assistant"> |  | ||||||
|       <CloudInitTextInput |  | ||||||
|         editable={p.editable} |  | ||||||
|         name="Default user name" |  | ||||||
|         refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords" |  | ||||||
|         attrPath={["user", "name"]} |  | ||||||
|         onChange={onChange} |  | ||||||
|         yaml={user_data} |  | ||||||
|       /> |  | ||||||
|       <CloudInitTextInput |  | ||||||
|         editable={p.editable} |  | ||||||
|         name="Default user password" |  | ||||||
|         refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords" |  | ||||||
|         attrPath={["password"]} |  | ||||||
|         onChange={onChange} |  | ||||||
|         yaml={user_data} |  | ||||||
|       /> |  | ||||||
|       <CloudInitBooleanInput |  | ||||||
|         editable={p.editable} |  | ||||||
|         name="Expire password to require new password on next login" |  | ||||||
|         yaml={user_data} |  | ||||||
|         attrPath={["chpasswd", "expire"]} |  | ||||||
|         onChange={onChange} |  | ||||||
|         refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords" |  | ||||||
|       /> |  | ||||||
|       <br /> |  | ||||||
|       <CloudInitBooleanInput |  | ||||||
|         editable={p.editable} |  | ||||||
|         name="Enable SSH password auth" |  | ||||||
|         yaml={user_data} |  | ||||||
|         attrPath={["ssh_pwauth"]} |  | ||||||
|         onChange={onChange} |  | ||||||
|         refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords" |  | ||||||
|       /> |  | ||||||
|       <CloudInitTextInput |  | ||||||
|         editable={p.editable} |  | ||||||
|         name="Keyboard layout" |  | ||||||
|         refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#keyboard" |  | ||||||
|         attrPath={["keyboard", "layout"]} |  | ||||||
|         onChange={onChange} |  | ||||||
|         yaml={user_data} |  | ||||||
|       /> |  | ||||||
|       <CloudInitTextInput |  | ||||||
|         editable={p.editable} |  | ||||||
|         name="Final message" |  | ||||||
|         refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#final-message" |  | ||||||
|         attrPath={["final_message"]} |  | ||||||
|         onChange={onChange} |  | ||||||
|         yaml={user_data} |  | ||||||
|       /> |  | ||||||
|       {/* /bin/sh -c "rm -f /etc/default/grub.d/50-cloudimg-settings.cfg && update-grub" */} |  | ||||||
|       <CheckboxInput |  | ||||||
|         editable={p.editable} |  | ||||||
|         label="Show all startup messages on tty1, not serial" |  | ||||||
|         checked={ |  | ||||||
|           !!(user_data.get("runcmd") as any)?.items.find( |  | ||||||
|             (a: any) => a.value === SYSTEMD_NOT_SERIAL |  | ||||||
|           ) |  | ||||||
|         } |  | ||||||
|         onValueChange={(c) => { |  | ||||||
|           if (!user_data.getIn(["runcmd"])) user_data.addIn(["runcmd"], []); |  | ||||||
|  |  | ||||||
|           const runcmd = user_data.getIn(["runcmd"]) as any; |  | ||||||
|  |  | ||||||
|           if (c) { |  | ||||||
|             runcmd.addIn([], SYSTEMD_NOT_SERIAL); |  | ||||||
|           } else { |  | ||||||
|             const idx = runcmd.items.findIndex( |  | ||||||
|               (o: any) => o.value === SYSTEMD_NOT_SERIAL |  | ||||||
|             ); |  | ||||||
|             runcmd.items.splice(idx, 1); |  | ||||||
|           } |  | ||||||
|           onChange(); |  | ||||||
|         }} |  | ||||||
|       /> |  | ||||||
|     </EditSection> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function CloudInitTextInput(p: { |  | ||||||
|   editable: boolean; |  | ||||||
|   name: string; |  | ||||||
|   refUrl: string; |  | ||||||
|   attrPath: Iterable<unknown>; |  | ||||||
|   yaml: YAML.Document; |  | ||||||
|   onChange?: () => void; |  | ||||||
| }): React.ReactElement { |  | ||||||
|   return ( |  | ||||||
|     <TextInput |  | ||||||
|       editable={p.editable} |  | ||||||
|       label={p.name} |  | ||||||
|       value={String(p.yaml.getIn(p.attrPath) ?? "")} |  | ||||||
|       onValueChange={(v) => { |  | ||||||
|         if (v !== undefined) p.yaml.setIn(p.attrPath, v); |  | ||||||
|         else p.yaml.deleteIn(p.attrPath); |  | ||||||
|         p.onChange?.(); |  | ||||||
|       }} |  | ||||||
|       endAdornment={ |  | ||||||
|         <RouterLink to={p.refUrl} target="_blank"> |  | ||||||
|           <IconButton size="small"> |  | ||||||
|             <BookIcon /> |  | ||||||
|           </IconButton> |  | ||||||
|         </RouterLink> |  | ||||||
|       } |  | ||||||
|     /> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function CloudInitBooleanInput(p: { |  | ||||||
|   editable: boolean; |  | ||||||
|   name: string; |  | ||||||
|   refUrl: string; |  | ||||||
|   attrPath: Iterable<unknown>; |  | ||||||
|   yaml: YAML.Document; |  | ||||||
|   onChange?: () => void; |  | ||||||
| }): React.ReactElement { |  | ||||||
|   return ( |  | ||||||
|     <CheckboxInput |  | ||||||
|       editable={p.editable} |  | ||||||
|       label={p.name} |  | ||||||
|       checked={p.yaml.getIn(p.attrPath) === true} |  | ||||||
|       onValueChange={(v) => { |  | ||||||
|         p.yaml.setIn(p.attrPath, v); |  | ||||||
|         p.onChange?.(); |  | ||||||
|       }} |  | ||||||
|     /> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
| @@ -1,25 +0,0 @@ | |||||||
| import { ServerApi } from "../../api/ServerApi"; |  | ||||||
| import { TextInput } from "./TextInput"; |  | ||||||
|  |  | ||||||
| export function DiskSizeInput(p: { |  | ||||||
|   editable: boolean; |  | ||||||
|   label?: string; |  | ||||||
|   value: number; |  | ||||||
|   onChange?: (size: number) => void; |  | ||||||
| }): React.ReactElement { |  | ||||||
|   return ( |  | ||||||
|     <TextInput |  | ||||||
|       editable={p.editable} |  | ||||||
|       label={p.label ?? "Disk size (GB)"} |  | ||||||
|       size={{ |  | ||||||
|         min: ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000), |  | ||||||
|         max: ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000), |  | ||||||
|       }} |  | ||||||
|       value={(p.value / (1000 * 1000 * 1000)).toString()} |  | ||||||
|       onValueChange={(v) => { |  | ||||||
|         p.onChange?.(Number(v ?? "0") * 1000 * 1000 * 1000); |  | ||||||
|       }} |  | ||||||
|       type="number" |  | ||||||
|     /> |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
| @@ -19,10 +19,13 @@ export function EditSection( | |||||||
|               display: "flex", |               display: "flex", | ||||||
|               justifyContent: "space-between", |               justifyContent: "space-between", | ||||||
|               alignItems: "center", |               alignItems: "center", | ||||||
|               marginBottom: "15px", |  | ||||||
|             }} |             }} | ||||||
|           > |           > | ||||||
|             {p.title && <Typography variant="h5">{p.title}</Typography>} |             {p.title && ( | ||||||
|  |               <Typography variant="h5" style={{ marginBottom: "15px" }}> | ||||||
|  |                 {p.title} | ||||||
|  |               </Typography> | ||||||
|  |             )} | ||||||
|             {p.actions} |             {p.actions} | ||||||
|           </span> |           </span> | ||||||
|         )} |         )} | ||||||
|   | |||||||
| @@ -25,8 +25,6 @@ export function OEMStringFormWidget(p: { | |||||||
|     p.onChange?.(); |     p.onChange?.(); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   if (!p.editable && p.vm.oem_strings.length === 0) return <></>; |  | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <EditSection |     <EditSection | ||||||
|       title="SMBIOS OEM Strings" |       title="SMBIOS OEM Strings" | ||||||
|   | |||||||
| @@ -18,7 +18,6 @@ export function TextInput(p: { | |||||||
|   style?: React.CSSProperties; |   style?: React.CSSProperties; | ||||||
|   helperText?: string; |   helperText?: string; | ||||||
|   disabled?: boolean; |   disabled?: boolean; | ||||||
|   endAdornment?: React.ReactNode; |  | ||||||
| }): React.ReactElement { | }): React.ReactElement { | ||||||
|   if (!p.editable && (p.value ?? "") === "") return <></>; |   if (!p.editable && (p.value ?? "") === "") return <></>; | ||||||
|  |  | ||||||
| @@ -52,7 +51,6 @@ export function TextInput(p: { | |||||||
|         input: { |         input: { | ||||||
|           readOnly: !p.editable, |           readOnly: !p.editable, | ||||||
|           type: p.type, |           type: p.type, | ||||||
|           endAdornment: p.endAdornment, |  | ||||||
|         }, |         }, | ||||||
|       }} |       }} | ||||||
|       variant={"standard"} |       variant={"standard"} | ||||||
|   | |||||||
| @@ -2,8 +2,7 @@ import { mdiHarddiskPlus } from "@mdi/js"; | |||||||
| import Icon from "@mdi/react"; | import Icon from "@mdi/react"; | ||||||
| import CheckCircleIcon from "@mui/icons-material/CheckCircle"; | import CheckCircleIcon from "@mui/icons-material/CheckCircle"; | ||||||
| import DeleteIcon from "@mui/icons-material/Delete"; | import DeleteIcon from "@mui/icons-material/Delete"; | ||||||
| import ExpandIcon from "@mui/icons-material/Expand"; | import { Button, IconButton, Paper, Tooltip } from "@mui/material"; | ||||||
| import { Button, IconButton, Paper, Tooltip, Typography } from "@mui/material"; |  | ||||||
| import React from "react"; | import React from "react"; | ||||||
| import { DiskImage } from "../../api/DiskImageApi"; | import { DiskImage } from "../../api/DiskImageApi"; | ||||||
| import { ServerApi } from "../../api/ServerApi"; | import { ServerApi } from "../../api/ServerApi"; | ||||||
| @@ -14,7 +13,6 @@ import { VMDiskFileWidget } from "../vms/VMDiskFileWidget"; | |||||||
| import { CheckboxInput } from "./CheckboxInput"; | import { CheckboxInput } from "./CheckboxInput"; | ||||||
| import { DiskBusSelect } from "./DiskBusSelect"; | import { DiskBusSelect } from "./DiskBusSelect"; | ||||||
| import { DiskImageSelect } from "./DiskImageSelect"; | import { DiskImageSelect } from "./DiskImageSelect"; | ||||||
| import { DiskSizeInput } from "./DiskSizeInput"; |  | ||||||
| import { SelectInput } from "./SelectInput"; | import { SelectInput } from "./SelectInput"; | ||||||
| import { TextInput } from "./TextInput"; | import { TextInput } from "./TextInput"; | ||||||
|  |  | ||||||
| @@ -69,12 +67,6 @@ export function VMDisksList(p: { | |||||||
|         /> |         /> | ||||||
|       ))} |       ))} | ||||||
|  |  | ||||||
|       {p.vm.file_disks.length === 0 && ( |  | ||||||
|         <Typography style={{ textAlign: "center", paddingTop: "25px" }}> |  | ||||||
|           No disk file yet! |  | ||||||
|         </Typography> |  | ||||||
|       )} |  | ||||||
|  |  | ||||||
|       {p.editable && <Button onClick={addNewDisk}>Add new disk</Button>} |       {p.editable && <Button onClick={addNewDisk}>Add new disk</Button>} | ||||||
|  |  | ||||||
|       {/* Disk backup */} |       {/* Disk backup */} | ||||||
| @@ -101,19 +93,6 @@ function DiskInfo(p: { | |||||||
|   diskImagesList: DiskImage[]; |   diskImagesList: DiskImage[]; | ||||||
| }): React.ReactElement { | }): React.ReactElement { | ||||||
|   const confirm = useConfirm(); |   const confirm = useConfirm(); | ||||||
|  |  | ||||||
|   const expandDisk = () => { |  | ||||||
|     if (p.disk.resize === true) { |  | ||||||
|       p.disk.resize = false; |  | ||||||
|       p.disk.size = p.disk.originalSize!; |  | ||||||
|     } else { |  | ||||||
|       p.disk.resize = true; |  | ||||||
|       p.disk.originalSize = p.disk.size!; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     p.onChange?.(); |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   const deleteDisk = async () => { |   const deleteDisk = async () => { | ||||||
|     if (p.disk.deleteType) { |     if (p.disk.deleteType) { | ||||||
|       p.disk.deleteType = undefined; |       p.disk.deleteType = undefined; | ||||||
| @@ -136,75 +115,42 @@ function DiskInfo(p: { | |||||||
|  |  | ||||||
|   if (!p.editable || !p.disk.new) |   if (!p.editable || !p.disk.new) | ||||||
|     return ( |     return ( | ||||||
|       <> |       <VMDiskFileWidget | ||||||
|         <VMDiskFileWidget |         {...p} | ||||||
|           {...p} |         secondaryAction={ | ||||||
|           secondaryAction={ |           <> | ||||||
|             <> |             {p.editable && ( | ||||||
|               {p.editable && !p.disk.deleteType && ( |               <IconButton | ||||||
|  |                 edge="end" | ||||||
|  |                 aria-label="delete disk" | ||||||
|  |                 onClick={deleteDisk} | ||||||
|  |               > | ||||||
|  |                 {p.disk.deleteType ? ( | ||||||
|  |                   <Tooltip title="Cancel disk removal"> | ||||||
|  |                     <CheckCircleIcon /> | ||||||
|  |                   </Tooltip> | ||||||
|  |                 ) : ( | ||||||
|  |                   <Tooltip title="Remove disk"> | ||||||
|  |                     <DeleteIcon /> | ||||||
|  |                   </Tooltip> | ||||||
|  |                 )} | ||||||
|  |               </IconButton> | ||||||
|  |             )} | ||||||
|  |  | ||||||
|  |             {p.canBackup && ( | ||||||
|  |               <Tooltip title="Backup this disk"> | ||||||
|                 <IconButton |                 <IconButton | ||||||
|                   edge="end" |                   onClick={() => { | ||||||
|                   aria-label="expand disk" |                     p.onRequestBackup(p.disk); | ||||||
|                   onClick={expandDisk} |                   }} | ||||||
|                 > |                 > | ||||||
|                   {p.disk.resize === true ? ( |                   <Icon path={mdiHarddiskPlus} size={1} /> | ||||||
|                     <Tooltip title="Cancel disk expansion"> |  | ||||||
|                       <ExpandIcon color="error" /> |  | ||||||
|                     </Tooltip> |  | ||||||
|                   ) : ( |  | ||||||
|                     <Tooltip title="Increase disk size"> |  | ||||||
|                       <ExpandIcon /> |  | ||||||
|                     </Tooltip> |  | ||||||
|                   )} |  | ||||||
|                 </IconButton> |                 </IconButton> | ||||||
|               )} |               </Tooltip> | ||||||
|  |             )} | ||||||
|               {p.editable && ( |           </> | ||||||
|                 <IconButton |         } | ||||||
|                   edge="end" |       /> | ||||||
|                   aria-label="delete disk" |  | ||||||
|                   onClick={deleteDisk} |  | ||||||
|                 > |  | ||||||
|                   {p.disk.deleteType ? ( |  | ||||||
|                     <Tooltip title="Cancel disk removal"> |  | ||||||
|                       <CheckCircleIcon /> |  | ||||||
|                     </Tooltip> |  | ||||||
|                   ) : ( |  | ||||||
|                     <Tooltip title="Remove disk"> |  | ||||||
|                       <DeleteIcon /> |  | ||||||
|                     </Tooltip> |  | ||||||
|                   )} |  | ||||||
|                 </IconButton> |  | ||||||
|               )} |  | ||||||
|  |  | ||||||
|               {p.canBackup && ( |  | ||||||
|                 <Tooltip title="Backup this disk"> |  | ||||||
|                   <IconButton |  | ||||||
|                     onClick={() => { |  | ||||||
|                       p.onRequestBackup(p.disk); |  | ||||||
|                     }} |  | ||||||
|                   > |  | ||||||
|                     <Icon path={mdiHarddiskPlus} size={1} /> |  | ||||||
|                   </IconButton> |  | ||||||
|                 </Tooltip> |  | ||||||
|               )} |  | ||||||
|             </> |  | ||||||
|           } |  | ||||||
|         /> |  | ||||||
|  |  | ||||||
|         {/* New disk size*/} |  | ||||||
|         {p.disk.resize && !p.disk.deleteType && ( |  | ||||||
|           <DiskSizeInput |  | ||||||
|             editable |  | ||||||
|             label="New disk size (GB)" |  | ||||||
|             value={p.disk.size} |  | ||||||
|             onChange={(v) => { |  | ||||||
|               p.disk.size = v; |  | ||||||
|               p.onChange?.(); |  | ||||||
|             }} |  | ||||||
|           /> |  | ||||||
|         )} |  | ||||||
|       </> |  | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
| @@ -266,32 +212,24 @@ function DiskInfo(p: { | |||||||
|         /> |         /> | ||||||
|       )} |       )} | ||||||
|  |  | ||||||
|       {/* Resize disk image */} |       <TextInput | ||||||
|       {!!p.disk.from_image && ( |         editable={true} | ||||||
|         <CheckboxInput |         label="Disk size (GB)" | ||||||
|           editable |         size={{ | ||||||
|           checked={p.disk.resize} |           min: | ||||||
|           label="Resize disk file" |             ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000), | ||||||
|           onValueChange={(v) => { |           max: | ||||||
|             p.disk.resize = v; |             ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000), | ||||||
|             p.onChange?.(); |         }} | ||||||
|           }} |         value={(p.disk.size / (1000 * 1000 * 1000)).toString()} | ||||||
|         /> |         onValueChange={(v) => { | ||||||
|       )} |           p.disk.size = Number(v ?? "0") * 1000 * 1000 * 1000; | ||||||
|  |           p.onChange?.(); | ||||||
|  |         }} | ||||||
|  |         type="number" | ||||||
|  |         disabled={!!p.disk.from_image} | ||||||
|  |       /> | ||||||
|  |  | ||||||
|       {/* Disk size */} |  | ||||||
|       {(!p.disk.from_image || p.disk.resize === true) && ( |  | ||||||
|         <DiskSizeInput |  | ||||||
|           editable |  | ||||||
|           value={p.disk.size} |  | ||||||
|           onChange={(v) => { |  | ||||||
|             p.disk.size = v; |  | ||||||
|             p.onChange?.(); |  | ||||||
|           }} |  | ||||||
|         /> |  | ||||||
|       )} |  | ||||||
|  |  | ||||||
|       {/* Disk image selection */} |  | ||||||
|       <DiskImageSelect |       <DiskImageSelect | ||||||
|         label="Use disk image as template" |         label="Use disk image as template" | ||||||
|         list={p.diskImagesList} |         list={p.diskImagesList} | ||||||
|   | |||||||
| @@ -10,7 +10,6 @@ import { | |||||||
|   ListItemAvatar, |   ListItemAvatar, | ||||||
|   ListItemText, |   ListItemText, | ||||||
|   Tooltip, |   Tooltip, | ||||||
|   Typography, |  | ||||||
| } from "@mui/material"; | } from "@mui/material"; | ||||||
| import Grid from "@mui/material/Grid"; | import Grid from "@mui/material/Grid"; | ||||||
| import { NWFilter } from "../../api/NWFilterApi"; | import { NWFilter } from "../../api/NWFilterApi"; | ||||||
| @@ -50,12 +49,6 @@ export function VMNetworksList(p: { | |||||||
|         </div> |         </div> | ||||||
|       )} |       )} | ||||||
|  |  | ||||||
|       {p.vm.networks.length === 0 && ( |  | ||||||
|         <Typography style={{ textAlign: "center", paddingTop: "25px" }}> |  | ||||||
|           No network interface defined yet! |  | ||||||
|         </Typography> |  | ||||||
|       )} |  | ||||||
|  |  | ||||||
|       <Grid container spacing={2}> |       <Grid container spacing={2}> | ||||||
|         {/* networks list */} |         {/* networks list */} | ||||||
|         {p.vm.networks.map((n, num) => ( |         {p.vm.networks.map((n, num) => ( | ||||||
|   | |||||||
| @@ -60,7 +60,6 @@ export function TokenRightsEditor(p: { | |||||||
|               <TableCell align="center">Get XML definition</TableCell> |               <TableCell align="center">Get XML definition</TableCell> | ||||||
|               <TableCell align="center">Get autostart</TableCell> |               <TableCell align="center">Get autostart</TableCell> | ||||||
|               <TableCell align="center">Set autostart</TableCell> |               <TableCell align="center">Set autostart</TableCell> | ||||||
|               <TableCell align="center">Get CloudInit disk</TableCell> |  | ||||||
|               <TableCell align="center">Backup disk</TableCell> |               <TableCell align="center">Backup disk</TableCell> | ||||||
|             </TableRow> |             </TableRow> | ||||||
|           </TableHead> |           </TableHead> | ||||||
| @@ -85,13 +84,6 @@ export function TokenRightsEditor(p: { | |||||||
|                 {...p} |                 {...p} | ||||||
|                 right={{ verb: "PUT", path: "/api/vm/*/autostart" }} |                 right={{ verb: "PUT", path: "/api/vm/*/autostart" }} | ||||||
|               /> |               /> | ||||||
|               <CellRight |  | ||||||
|                 {...p} |  | ||||||
|                 right={{ |  | ||||||
|                   verb: "GET", |  | ||||||
|                   path: "/api/vm/*/cloud_init_disk", |  | ||||||
|                 }} |  | ||||||
|               /> |  | ||||||
|               <CellRight |               <CellRight | ||||||
|                 {...p} |                 {...p} | ||||||
|                 right={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }} |                 right={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }} | ||||||
| @@ -131,15 +123,7 @@ export function TokenRightsEditor(p: { | |||||||
|                   {...p} |                   {...p} | ||||||
|                   right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }} |                   right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }} | ||||||
|                   parent={{ verb: "PUT", path: "/api/vm/*/autostart" }} |                   parent={{ verb: "PUT", path: "/api/vm/*/autostart" }} | ||||||
|                 /> |                 />{" "} | ||||||
|                 <CellRight |  | ||||||
|                   {...p} |  | ||||||
|                   right={{ |  | ||||||
|                     verb: "GET", |  | ||||||
|                     path: `/api/vm/${v.uuid}/cloud_init_disk`, |  | ||||||
|                   }} |  | ||||||
|                   parent={{ verb: "GET", path: "/api/vm/*/cloud_init_disk" }} |  | ||||||
|                 /> |  | ||||||
|                 <CellRight |                 <CellRight | ||||||
|                   {...p} |                   {...p} | ||||||
|                   right={{ |                   right={{ | ||||||
| @@ -799,11 +783,6 @@ export function TokenRightsEditor(p: { | |||||||
|           right={{ verb: "GET", path: "/api/server/bridges" }} |           right={{ verb: "GET", path: "/api/server/bridges" }} | ||||||
|           label="Get list of network bridges" |           label="Get list of network bridges" | ||||||
|         /> |         /> | ||||||
|         <RouteRight |  | ||||||
|           {...p} |  | ||||||
|           right={{ verb: "GET", path: "/api/server/export_configs" }} |  | ||||||
|           label="Export all configurations" |  | ||||||
|         /> |  | ||||||
|       </RightsSection> |       </RightsSection> | ||||||
|     </> |     </> | ||||||
|   ); |   ); | ||||||
|   | |||||||
| @@ -5,7 +5,6 @@ import Grid from "@mui/material/Grid"; | |||||||
| import React from "react"; | import React from "react"; | ||||||
| import { useNavigate } from "react-router-dom"; | import { useNavigate } from "react-router-dom"; | ||||||
| import { validate as validateUUID } from "uuid"; | import { validate as validateUUID } from "uuid"; | ||||||
| import { DiskImage, DiskImageApi } from "../../api/DiskImageApi"; |  | ||||||
| import { GroupApi } from "../../api/GroupApi"; | import { GroupApi } from "../../api/GroupApi"; | ||||||
| import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi"; | import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi"; | ||||||
| import { NWFilter, NWFilterApi } from "../../api/NWFilterApi"; | import { NWFilter, NWFilterApi } from "../../api/NWFilterApi"; | ||||||
| @@ -19,7 +18,6 @@ import { AsyncWidget } from "../AsyncWidget"; | |||||||
| import { TabsWidget } from "../TabsWidget"; | import { TabsWidget } from "../TabsWidget"; | ||||||
| import { XMLAsyncWidget } from "../XMLWidget"; | import { XMLAsyncWidget } from "../XMLWidget"; | ||||||
| import { CheckboxInput } from "../forms/CheckboxInput"; | import { CheckboxInput } from "../forms/CheckboxInput"; | ||||||
| import { CloudInitEditor } from "../forms/CloudInitEditor"; |  | ||||||
| import { EditSection } from "../forms/EditSection"; | import { EditSection } from "../forms/EditSection"; | ||||||
| import { OEMStringFormWidget } from "../forms/OEMStringFormWidget"; | import { OEMStringFormWidget } from "../forms/OEMStringFormWidget"; | ||||||
| import { ResAutostartInput } from "../forms/ResAutostartInput"; | import { ResAutostartInput } from "../forms/ResAutostartInput"; | ||||||
| @@ -29,6 +27,7 @@ import { VMDisksList } from "../forms/VMDisksList"; | |||||||
| import { VMNetworksList } from "../forms/VMNetworksList"; | import { VMNetworksList } from "../forms/VMNetworksList"; | ||||||
| import { VMSelectIsoInput } from "../forms/VMSelectIsoInput"; | import { VMSelectIsoInput } from "../forms/VMSelectIsoInput"; | ||||||
| import { VMScreenshot } from "./VMScreenshot"; | import { VMScreenshot } from "./VMScreenshot"; | ||||||
|  | import { DiskImage, DiskImageApi } from "../../api/DiskImageApi"; | ||||||
|  |  | ||||||
| interface DetailsProps { | interface DetailsProps { | ||||||
|   vm: VMInfo; |   vm: VMInfo; | ||||||
| @@ -90,7 +89,6 @@ enum VMTab { | |||||||
|   General = 0, |   General = 0, | ||||||
|   Storage, |   Storage, | ||||||
|   Network, |   Network, | ||||||
|   CloudInit, |  | ||||||
|   Advanced, |   Advanced, | ||||||
|   XML, |   XML, | ||||||
|   Danger, |   Danger, | ||||||
| @@ -118,11 +116,6 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement { | |||||||
|           { label: "General", value: VMTab.General, visible: true }, |           { label: "General", value: VMTab.General, visible: true }, | ||||||
|           { label: "Storage", value: VMTab.Storage, visible: true }, |           { label: "Storage", value: VMTab.Storage, visible: true }, | ||||||
|           { label: "Network", value: VMTab.Network, visible: true }, |           { label: "Network", value: VMTab.Network, visible: true }, | ||||||
|           { |  | ||||||
|             label: "Cloud Init", |  | ||||||
|             value: VMTab.CloudInit, |  | ||||||
|             visible: p.editable || p.vm.cloud_init.attach_config, |  | ||||||
|           }, |  | ||||||
|           { label: "Avanced", value: VMTab.Advanced, visible: true }, |           { label: "Avanced", value: VMTab.Advanced, visible: true }, | ||||||
|  |  | ||||||
|           { |           { | ||||||
| @@ -142,7 +135,6 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement { | |||||||
|       {currTab === VMTab.General && <VMDetailsTabGeneral {...p} />} |       {currTab === VMTab.General && <VMDetailsTabGeneral {...p} />} | ||||||
|       {currTab === VMTab.Storage && <VMDetailsTabStorage {...p} />} |       {currTab === VMTab.Storage && <VMDetailsTabStorage {...p} />} | ||||||
|       {currTab === VMTab.Network && <VMDetailsTabNetwork {...p} />} |       {currTab === VMTab.Network && <VMDetailsTabNetwork {...p} />} | ||||||
|       {currTab === VMTab.CloudInit && <VMDetailsTabCloudInit {...p} />} |  | ||||||
|       {currTab === VMTab.Advanced && <VMDetailsTabAdvanced {...p} />} |       {currTab === VMTab.Advanced && <VMDetailsTabAdvanced {...p} />} | ||||||
|       {currTab === VMTab.XML && <VMDetailsTabXML {...p} />} |       {currTab === VMTab.XML && <VMDetailsTabXML {...p} />} | ||||||
|       {currTab === VMTab.Danger && <VMDetailsTabDanger {...p} />} |       {currTab === VMTab.Danger && <VMDetailsTabDanger {...p} />} | ||||||
| @@ -389,10 +381,6 @@ function VMDetailsTabNetwork(p: DetailsInnerProps): React.ReactElement { | |||||||
|   return <VMNetworksList {...p} />; |   return <VMNetworksList {...p} />; | ||||||
| } | } | ||||||
|  |  | ||||||
| function VMDetailsTabCloudInit(p: DetailsInnerProps): React.ReactElement { |  | ||||||
|   return <CloudInitEditor {...p} />; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function VMDetailsTabAdvanced(p: DetailsInnerProps): React.ReactElement { | function VMDetailsTabAdvanced(p: DetailsInnerProps): React.ReactElement { | ||||||
|   return ( |   return ( | ||||||
|     <Grid container spacing={2}> |     <Grid container spacing={2}> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user