1 Commits

Author SHA1 Message Date
4d45651422 Update Rust crate reqwest to 0.12.20 2025-06-11 00:26:10 +00:00
27 changed files with 882 additions and 1659 deletions

@@ -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

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.47", 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,27 @@ 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.7" actix-files = "0.6.6"
actix-ws = "0.3.0" actix-ws = "0.3.0"
actix-http = "3.11.1" 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" serde_yml = "0.0.12"
quick-xml = { version = "0.38.3", features = ["serialize", "overlapped-lists"] } quick-xml = { version = "0.37.5", features = ["serialize", "overlapped-lists"] }
futures-util = "0.3.31" futures-util = "0.3.31"
anyhow = "1.0.99" 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.23", features = ["stream"] } reqwest = { version = "0.12.20", 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.16" 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.0", 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 +45,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()?)?;
@@ -443,7 +446,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 +602,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 +617,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,11 +104,11 @@ 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,11 +188,11 @@ 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
} }

@@ -280,7 +280,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

@@ -31,13 +31,14 @@ 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: {mime_type}" "Unsupported file type for disk upload: {}",
mime_type
))); )));
} }
}
// Extract and check file name // Extract and check file name
let Some(file_name) = file.file_name else { let Some(file_name) = file.file_name else {

@@ -31,12 +31,12 @@ 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 {
None => { None => {
@@ -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,17 +87,17 @@ 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))
}

@@ -128,22 +128,22 @@ 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 })
}) })

@@ -96,29 +96,29 @@ 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
&& !regex!("^[a-zA-Z0-9]+$").is_match(bridge)
{
return Err(StructureExtraction("Network bridge name is invalid!").into());
} }
if let Some(domain) = &self.domain if let Some(bridge) = &self.bridge_name {
&& !regex!("^[a-zA-Z0-9.]+$").is_match(domain) if !regex!("^[a-zA-Z0-9]+$").is_match(bridge) {
{ return Err(StructureExtraction("Network bridge name is invalid!").into());
}
}
if let Some(domain) = &self.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,17 +303,17 @@ 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 {

@@ -118,23 +118,23 @@ 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
&& n.contains('\n')
{
return Err(StructureExtraction("VM title contain newline char!").into());
} }
if let Some(group) = &self.group if let Some(n) = &self.title {
&& !regex!("^[a-zA-Z0-9]+$").is_match(&group.0) if n.contains('\n') {
{ return Err(StructureExtraction("VM title contain newline char!").into());
}
}
if let Some(group) = &self.group {
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 {
return Err(StructureExtraction("VM memory is invalid!").into()); return Err(StructureExtraction("VM memory is invalid!").into());

@@ -157,10 +157,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",

@@ -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,11 +60,11 @@ 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 {
if *start == 0 { if *start == 0 {
@@ -84,11 +84,11 @@ 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(())
} }

@@ -81,10 +81,10 @@ impl CloudInitConfig {
// Process metadata // Process metadata
let mut metadatas = vec![]; let mut metadatas = vec![];
if let Some(inst_id) = &self.instance_id { if let Some(inst_id) = &self.instance_id {
metadatas.push(format!("instance-id: {inst_id}")); metadatas.push(format!("instance-id: {}", inst_id));
} }
if let Some(local_hostname) = &self.local_hostname { if let Some(local_hostname) = &self.local_hostname {
metadatas.push(format!("local-hostname: {local_hostname}")); metadatas.push(format!("local-hostname: {}", local_hostname));
} }
if let Some(dsmode) = &self.dsmode { if let Some(dsmode) = &self.dsmode {
metadatas.push(format!( metadatas.push(format!(

@@ -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()
))
}

File diff suppressed because it is too large Load Diff

@@ -11,46 +11,45 @@
}, },
"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", "@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^7.3.1", "@mui/icons-material": "^7.1.1",
"@mui/material": "^7.3.1", "@mui/material": "^7.1.1",
"@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.0", "humanize-duration": "^3.32.2",
"monaco-editor": "^0.52.2", "monaco-editor": "^0.52.2",
"monaco-yaml": "^5.4.0", "monaco-yaml": "^5.4.0",
"react": "^19.1.1", "react": "^19.1.0",
"react-dom": "^19.1.1", "react-dom": "^19.1.0",
"react-router-dom": "^7.8.0", "react-router-dom": "^7.6.2",
"react-syntax-highlighter": "^15.6.6", "react-syntax-highlighter": "^15.6.1",
"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.1.13", "@types/react": "^19.1.6",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.6",
"@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.20", "eslint-plugin-react-refresh": "^0.4.20",
"eslint-plugin-react-x": "^1.52.9", "eslint-plugin-react-x": "^1.49.0",
"globals": "^16.3.0", "globals": "^16.1.0",
"typescript": "^5.9.2", "typescript": "^5.8.3",
"typescript-eslint": "^8.43.0", "typescript-eslint": "^8.32.1",
"vite": "^6.3.6" "vite": "^6.3.5"
} }
} }

@@ -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;
}
} }

@@ -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>

@@ -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,14 +1,8 @@
/* eslint-disable @typescript-eslint/no-base-to-string */
import Editor from "@monaco-editor/react"; import Editor from "@monaco-editor/react";
import BookIcon from "@mui/icons-material/Book";
import RefreshIcon from "@mui/icons-material/Refresh"; import RefreshIcon from "@mui/icons-material/Refresh";
import { Grid, IconButton, InputAdornment, Tooltip } from "@mui/material"; import { Grid, IconButton, InputAdornment, Tooltip } from "@mui/material";
import React from "react";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import YAML from "yaml";
import { VMInfo } from "../../api/VMApi"; import { VMInfo } from "../../api/VMApi";
import { RouterLink } from "../RouterLink";
import { CheckboxInput } from "./CheckboxInput"; import { CheckboxInput } from "./CheckboxInput";
import { EditSection } from "./EditSection"; import { EditSection } from "./EditSection";
import { SelectInput } from "./SelectInput"; import { SelectInput } from "./SelectInput";
@@ -44,14 +38,6 @@ export function CloudInitEditor(p: CloudInitProps): React.ReactElement {
{...p} {...p}
editable={p.editable && p.vm.cloud_init.attach_config} 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> </Grid>
</> </>
); );
@@ -122,27 +108,12 @@ function CloudInitMetadata(p: CloudInitProps): React.ReactElement {
function CloudInitRawUserData(p: CloudInitProps): React.ReactElement { function CloudInitRawUserData(p: CloudInitProps): React.ReactElement {
return ( return (
<EditSection <EditSection title="User data">
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 <Editor
theme="vs-dark" theme="vs-dark"
options={{ options={{
readOnly: !p.editable, readOnly: !p.editable,
quickSuggestions: { other: true, comments: true, strings: true }, quickSuggestions: { other: true, comments: true, strings: true },
wordWrap: "on",
}} }}
language="yaml" language="yaml"
height={"30vh"} height={"30vh"}
@@ -155,187 +126,3 @@ function CloudInitRawUserData(p: CloudInitProps): React.ReactElement {
</EditSection> </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?.();
}}
/>
);
}

@@ -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>
)} )}

@@ -799,11 +799,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>
</> </>
); );