feat: add marketing site and expand Proxmox management features
This commit is contained in:
+353
-64
@@ -2,7 +2,7 @@ use crate::error::Error;
|
||||
use crate::proxmox::{
|
||||
AddDiskConfig, AddNICConfig, Backup, BackupJob, BackupJobConfig, ClusterNode, ClusterStatus,
|
||||
CreateSnapshotConfig, Disk, EditNICConfig, NetworkInterface, Node, RestoreConfig, Snapshot,
|
||||
Storage, StorageContent, StorageDetail, Task, VM,
|
||||
Storage, StorageContent, StorageDetail, Task, UpdateVMConfig, VM,
|
||||
};
|
||||
use crate::{
|
||||
CertificateInfo, ConnectResult, ConnectionConfig, ConnectionStatusInfo, DiscoveredNode,
|
||||
@@ -11,9 +11,10 @@ use crate::{
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
||||
use reqwest::{Client, Method};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
/// Characters left unencoded when percent-encoding a volume id into a URL
|
||||
@@ -27,6 +28,29 @@ const VOLID_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
|
||||
.remove(b'_')
|
||||
.remove(b'~');
|
||||
|
||||
/// Characters left unencoded when percent-encoding a single URL path segment
|
||||
/// (node names, snapshot names). Same set as [`VOLID_ENCODE_SET`] but without
|
||||
/// the `:` that separates a storage prefix from a volid.
|
||||
const SEGMENT_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
|
||||
.remove(b'-')
|
||||
.remove(b'.')
|
||||
.remove(b'_')
|
||||
.remove(b'~');
|
||||
|
||||
/// Builds the HTTP client used for every connection. TLS verification is
|
||||
/// intentionally off at the transport layer (self-signed Proxmox servers are
|
||||
/// the norm); trust is enforced by the application-level TOFU pinning in
|
||||
/// `tls.rs`. Connect and total timeouts keep a dead server from hanging the
|
||||
/// UI forever.
|
||||
fn build_client() -> crate::Result<Client> {
|
||||
Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(Error::HttpError)
|
||||
}
|
||||
|
||||
/// The result of loading persisted connections from disk.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -99,7 +123,12 @@ where
|
||||
pub fn derive_node_url(primary_url: &str, node_ip: Option<&str>, node_name: &str) -> String {
|
||||
let host = node_ip.filter(|ip| !ip.is_empty()).unwrap_or(node_name);
|
||||
match Url::parse(primary_url) {
|
||||
Ok(url) => format!("{}://{}:{}", url.scheme(), host, url.port().unwrap_or(8006)),
|
||||
Ok(url) => {
|
||||
// pveproxy listens on 8006 for both http and https, but a scheme
|
||||
// default applies when no explicit port is given (`http` → 80).
|
||||
let default_port = if url.scheme() == "http" { 80 } else { 8006 };
|
||||
format!("{}://{}:{}", url.scheme(), host, url.port().unwrap_or(default_port))
|
||||
}
|
||||
Err(_) => format!("https://{}:8006", host),
|
||||
}
|
||||
}
|
||||
@@ -182,19 +211,62 @@ pub async fn api_request(
|
||||
});
|
||||
|
||||
if !status.is_success() {
|
||||
let message = body["errors"]
|
||||
.as_str()
|
||||
.or_else(|| body["message"].as_str())
|
||||
.or_else(|| body["data"].as_str())
|
||||
.or_else(|| body.as_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("Proxmox API error (HTTP {})", status.as_u16()));
|
||||
let message = error_message_from_body(&body, status.as_u16());
|
||||
return Err(Error::ApiError(message));
|
||||
}
|
||||
|
||||
Ok(body.get("data").cloned().unwrap_or(body))
|
||||
}
|
||||
|
||||
/// Builds the error message surfaced for a non-success API response.
|
||||
///
|
||||
/// The `errors` field is preferred over the generic `message` (Proxmox
|
||||
/// parameter-verification failures send a body like
|
||||
/// `{"errors":{"limit":"property is not defined in schema"},
|
||||
/// "message":"Parameter verification failed."}`, and the specific `errors`
|
||||
/// entry is what tells the user what went wrong). The precedence is:
|
||||
/// 1. `errors` as a string
|
||||
/// 2. `errors` as an object — the first `key: value` pair, formatted as
|
||||
/// `key: value` (string values verbatim, other values as JSON)
|
||||
/// 3. `message` as a string
|
||||
/// 4. `data` as a string
|
||||
/// 5. the raw body when it is itself a string
|
||||
/// 6. a generic `Proxmox API error (HTTP {status})` fallback
|
||||
fn error_message_from_body(body: &serde_json::Value, status: u16) -> String {
|
||||
if let Some(errors) = body.get("errors") {
|
||||
if let Some(text) = errors.as_str() {
|
||||
if !text.is_empty() {
|
||||
return text.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(obj) = errors.as_object() {
|
||||
if let Some((key, value)) = obj.iter().next() {
|
||||
let value_text = value
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| value.to_string());
|
||||
return format!("{}: {}", key, value_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(message) = body.get("message").and_then(|m| m.as_str()) {
|
||||
if !message.is_empty() {
|
||||
return message.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(data) = body.get("data").and_then(|d| d.as_str()) {
|
||||
if !data.is_empty() {
|
||||
return data.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(text) = body.as_str() {
|
||||
if !text.is_empty() {
|
||||
return text.to_string();
|
||||
}
|
||||
}
|
||||
format!("Proxmox API error (HTTP {})", status)
|
||||
}
|
||||
|
||||
struct Connection {
|
||||
config: ConnectionConfig,
|
||||
client: Client,
|
||||
@@ -393,13 +465,9 @@ impl ConnectionManager {
|
||||
|
||||
/// Builds the HTTP client and session state for a connection.
|
||||
fn build_connection(config: ConnectionConfig) -> crate::Result<Connection> {
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.map_err(Error::HttpError)?;
|
||||
Ok(Connection {
|
||||
config,
|
||||
client,
|
||||
client: build_client()?,
|
||||
ticket: Mutex::new(None),
|
||||
csrf_token: Mutex::new(None),
|
||||
current_endpoint_index: Mutex::new(0),
|
||||
@@ -906,10 +974,7 @@ impl ConnectionManager {
|
||||
return Err(Error::InvalidUrl("URL cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.map_err(|e| Error::HttpError(e))?;
|
||||
let client = build_client()?;
|
||||
|
||||
let login_url = format!("{}/access/ticket", url);
|
||||
|
||||
@@ -999,10 +1064,7 @@ impl ConnectionManager {
|
||||
));
|
||||
}
|
||||
|
||||
let client = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.map_err(|e| Error::HttpError(e))?;
|
||||
let client = build_client()?;
|
||||
|
||||
// Validate the token by making an authenticated request
|
||||
let test_url = format!("{}/cluster/status", url);
|
||||
@@ -1117,6 +1179,22 @@ impl ConnectionManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Injects a password-mode session (ticket + CSRF token) into an existing
|
||||
/// connection's in-memory state without touching the OS keyring. Used by
|
||||
/// integration tests that authenticate against a live server where no
|
||||
/// keyring secret-service is available; any later login or ticket refresh
|
||||
/// overwrites the values.
|
||||
pub async fn set_session_ticket(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
ticket: &str,
|
||||
csrf_token: &str,
|
||||
) -> crate::Result<()> {
|
||||
let conn = self.connection(connection_id)?;
|
||||
conn.set_session(ticket.to_string(), csrf_token.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn refresh_ticket(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
@@ -1274,19 +1352,52 @@ impl ConnectionManager {
|
||||
let data = conn
|
||||
.request(Method::GET, "/cluster/resources", &query, None)
|
||||
.await?;
|
||||
parse_api("/cluster/resources?type=storage", data)
|
||||
// `/cluster/resources?type=storage` entries carry usage as `disk` /
|
||||
// `maxdisk` and an `available`/`unavailable` status string — not the
|
||||
// `used`/`total`/`avail`/`enabled`/`active` fields the struct models.
|
||||
// Each entry is mapped manually so the overview shows real numbers.
|
||||
let entries: Vec<serde_json::Value> =
|
||||
parse_api("/cluster/resources?type=storage", data)?;
|
||||
let mut storages = Vec::with_capacity(entries.len());
|
||||
for entry in entries {
|
||||
let disk = entry["disk"].as_u64().unwrap_or(0);
|
||||
let maxdisk = entry["maxdisk"].as_u64().unwrap_or(0);
|
||||
let status = entry["status"].as_str().unwrap_or("");
|
||||
let available = status == "available";
|
||||
storages.push(Storage {
|
||||
storage: entry["storage"].as_str().unwrap_or("").to_string(),
|
||||
r#type: entry["type"].as_str().unwrap_or("").to_string(),
|
||||
content: entry["content"].as_str().unwrap_or("").to_string(),
|
||||
active: u32::from(available),
|
||||
enabled: u32::from(available),
|
||||
shared: entry["shared"]
|
||||
.as_u64()
|
||||
.map(|v| v as u32)
|
||||
.or_else(|| entry["shared"].as_bool().map(u32::from))
|
||||
.unwrap_or(0),
|
||||
used: disk,
|
||||
total: maxdisk,
|
||||
avail: maxdisk.saturating_sub(disk),
|
||||
node: entry["node"].as_str().unwrap_or("").to_string(),
|
||||
});
|
||||
}
|
||||
Ok(storages)
|
||||
}
|
||||
|
||||
pub async fn get_storage_content(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
storage: &str,
|
||||
node: Option<&str>,
|
||||
) -> crate::Result<Vec<StorageContent>> {
|
||||
let conn = self.connection(connection_id)?;
|
||||
// The content endpoint is node-scoped. Prefer the node configured on
|
||||
// the connection's primary endpoint; otherwise pick the first online
|
||||
// node from the cluster.
|
||||
let node = self.storage_node(connection_id).await?;
|
||||
// The content endpoint is node-scoped. Prefer an explicitly requested
|
||||
// node; otherwise use the node configured on the connection's primary
|
||||
// endpoint, falling back to the first online node from the cluster.
|
||||
let node = match node.filter(|n| !n.is_empty()) {
|
||||
Some(node) => node.to_string(),
|
||||
None => self.storage_node(connection_id).await?,
|
||||
};
|
||||
let path = format!("/nodes/{}/storage/{}/content", node, storage);
|
||||
let data = conn.request(Method::GET, &path, &[], None).await?;
|
||||
parse_api(&path, data)
|
||||
@@ -1306,9 +1417,11 @@ impl ConnectionManager {
|
||||
|
||||
pub async fn get_tasks(&self, connection_id: &str) -> crate::Result<Vec<Task>> {
|
||||
let conn = self.connection(connection_id)?;
|
||||
let query = [("limit", "50".to_string())];
|
||||
// The server rejects `/cluster/tasks?limit=...` with HTTP 400 (`limit`
|
||||
// is not in the endpoint schema); the default server-side limit of 50
|
||||
// applies when no query is sent.
|
||||
let data = conn
|
||||
.request(Method::GET, "/cluster/tasks", &query, None)
|
||||
.request(Method::GET, "/cluster/tasks", &[], None)
|
||||
.await?;
|
||||
parse_api("/cluster/tasks", data)
|
||||
}
|
||||
@@ -1713,8 +1826,11 @@ impl ConnectionManager {
|
||||
config: AddNICConfig,
|
||||
) -> crate::Result<()> {
|
||||
Self::validate_vm_type(vm_type)?;
|
||||
// NIC model validation applies to QEMU guests only; LXC containers
|
||||
// always use the veth device type and the model is ignored by the
|
||||
// server (the frontend sends `veth` for containers).
|
||||
const VALID_MODELS: [&str; 4] = ["virtio", "e1000", "rtl8139", "vmxnet3"];
|
||||
if !VALID_MODELS.contains(&config.model.as_str()) {
|
||||
if vm_type != "lxc" && !VALID_MODELS.contains(&config.model.as_str()) {
|
||||
return Err(Error::InvalidUrl(format!(
|
||||
"Invalid NIC model '{}': expected one of virtio, e1000, rtl8139, vmxnet3",
|
||||
config.model
|
||||
@@ -1726,14 +1842,29 @@ impl ConnectionManager {
|
||||
let data = conn.request(Method::GET, &path, &[], None).await?;
|
||||
let index = first_free_bus_index(&data, "net");
|
||||
let mac = config.macaddr.unwrap_or_else(|| "random".to_string());
|
||||
let mut value = format!("{}={}", config.model, mac);
|
||||
value.push_str(&format!(",bridge={}", config.bridge));
|
||||
if let Some(tag) = config.tag {
|
||||
value.push_str(&format!(",tag={}", tag));
|
||||
}
|
||||
if let Some(firewall) = config.firewall {
|
||||
value.push_str(&format!(",firewall={}", u32::from(firewall)));
|
||||
}
|
||||
let value = if vm_type == "lxc" {
|
||||
// LXC net values use the `name=ethN,type=veth,bridge=...` form;
|
||||
// a user-supplied MAC (anything but `random`) is sent as
|
||||
// `hwaddr`, and `firewall=1` is added when firewall is enabled.
|
||||
let mut value = format!("name=eth{},type=veth,bridge={}", index, config.bridge);
|
||||
if !mac.is_empty() && mac != "random" {
|
||||
value.push_str(&format!(",hwaddr={}", mac));
|
||||
}
|
||||
if config.firewall.unwrap_or(false) {
|
||||
value.push_str(",firewall=1");
|
||||
}
|
||||
value
|
||||
} else {
|
||||
let mut value = format!("{}={}", config.model, mac);
|
||||
value.push_str(&format!(",bridge={}", config.bridge));
|
||||
if let Some(tag) = config.tag {
|
||||
value.push_str(&format!(",tag={}", tag));
|
||||
}
|
||||
if let Some(firewall) = config.firewall {
|
||||
value.push_str(&format!(",firewall={}", u32::from(firewall)));
|
||||
}
|
||||
value
|
||||
};
|
||||
let key = format!("net{}", index);
|
||||
let form = vec![(key.as_str(), value)];
|
||||
conn.request(Method::POST, &path, &[], Some(&form)).await?;
|
||||
@@ -1781,6 +1912,41 @@ impl ConnectionManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Updates a VM/container's basic configuration (`name`, `cores`, `memory`,
|
||||
/// `description`) by POSTing a form containing only the present fields.
|
||||
/// A config with every field `None` errors out instead of sending an empty
|
||||
/// POST.
|
||||
pub async fn update_vm_config(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
node: &str,
|
||||
vmid: u32,
|
||||
vm_type: &str,
|
||||
config: UpdateVMConfig,
|
||||
) -> crate::Result<()> {
|
||||
Self::validate_vm_type(vm_type)?;
|
||||
let conn = self.connection(connection_id)?;
|
||||
let path = format!("/nodes/{}/{}/{}/config", node, vm_type, vmid);
|
||||
let mut form: Vec<(&str, String)> = Vec::new();
|
||||
if let Some(name) = config.name {
|
||||
form.push(("name", name));
|
||||
}
|
||||
if let Some(cores) = config.cores {
|
||||
form.push(("cores", cores.to_string()));
|
||||
}
|
||||
if let Some(memory) = config.memory {
|
||||
form.push(("memory", memory.to_string()));
|
||||
}
|
||||
if let Some(description) = config.description {
|
||||
form.push(("description", description));
|
||||
}
|
||||
if form.is_empty() {
|
||||
return Err(Error::ApiError("Nothing to update".to_string()));
|
||||
}
|
||||
conn.request(Method::POST, &path, &[], Some(&form)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_snapshots(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
@@ -1827,9 +1993,10 @@ impl ConnectionManager {
|
||||
) -> crate::Result<()> {
|
||||
Self::validate_vm_type(vm_type)?;
|
||||
let conn = self.connection(connection_id)?;
|
||||
// Snapshot names can contain URL-hostile characters, but Proxmox
|
||||
// expects the name inline in the path, so it is inserted verbatim.
|
||||
let path = format!("/nodes/{}/{}/{}/snapshot/{}", node, vm_type, vmid, name);
|
||||
// Snapshot names can contain URL-hostile characters, so the name is
|
||||
// percent-encoded into the path (everything but `A-Za-z0-9-._~`).
|
||||
let encoded_name = utf8_percent_encode(name, SEGMENT_ENCODE_SET).to_string();
|
||||
let path = format!("/nodes/{}/{}/{}/snapshot/{}", node, vm_type, vmid, encoded_name);
|
||||
conn.request(Method::DELETE, &path, &[], None).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1844,9 +2011,10 @@ impl ConnectionManager {
|
||||
) -> crate::Result<()> {
|
||||
Self::validate_vm_type(vm_type)?;
|
||||
let conn = self.connection(connection_id)?;
|
||||
let encoded_name = utf8_percent_encode(name, SEGMENT_ENCODE_SET).to_string();
|
||||
let path = format!(
|
||||
"/nodes/{}/{}/{}/snapshot/{}/rollback",
|
||||
node, vm_type, vmid, name
|
||||
node, vm_type, vmid, encoded_name
|
||||
);
|
||||
conn.request(Method::POST, &path, &[], None).await?;
|
||||
Ok(())
|
||||
@@ -1962,15 +2130,56 @@ impl ConnectionManager {
|
||||
connection_id: &str,
|
||||
storage: Option<&str>,
|
||||
) -> crate::Result<Vec<Backup>> {
|
||||
let conn = self.connection(connection_id)?;
|
||||
let node = self.storage_node(connection_id).await?;
|
||||
let storage = storage.unwrap_or("local");
|
||||
// A specific storage is queried verbatim and its errors propagate.
|
||||
// Without one, every enabled storage whose content list includes
|
||||
// `backup` is queried and the results are aggregated; a storage whose
|
||||
// content query fails during aggregation is skipped rather than
|
||||
// failing the whole call.
|
||||
match storage.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(storage) => self.backup_entries(connection_id, &node, storage).await,
|
||||
None => {
|
||||
let storages = self.get_storage(connection_id).await?;
|
||||
let mut seen = HashSet::new();
|
||||
let mut names = Vec::new();
|
||||
for storage in storages {
|
||||
if storage.enabled == 0 {
|
||||
continue;
|
||||
}
|
||||
let content_tokens: Vec<&str> =
|
||||
storage.content.split(',').map(str::trim).collect();
|
||||
if !content_tokens.contains(&"backup") {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(storage.storage.clone()) {
|
||||
names.push(storage.storage);
|
||||
}
|
||||
}
|
||||
let mut backups = Vec::new();
|
||||
for name in names {
|
||||
if let Ok(entries) = self.backup_entries(connection_id, &node, &name).await {
|
||||
backups.extend(entries);
|
||||
}
|
||||
}
|
||||
Ok(backups)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the backup entries of a single storage on `node` from the
|
||||
/// node-scoped content endpoint. The content endpoint can mix backup
|
||||
/// entries with iso/vztmpl entries, so the requested `content=backup`
|
||||
/// filter is mirrored here and only entries tagged `backup` are mapped.
|
||||
async fn backup_entries(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
node: &str,
|
||||
storage: &str,
|
||||
) -> crate::Result<Vec<Backup>> {
|
||||
let conn = self.connection(connection_id)?;
|
||||
let path = format!("/nodes/{}/storage/{}/content", node, storage);
|
||||
let query = [("content", "backup".to_string())];
|
||||
let data = conn.request(Method::GET, &path, &query, None).await?;
|
||||
// The content endpoint can mix backup entries with iso/vztmpl entries,
|
||||
// so the requested `content=backup` filter is mirrored here and only
|
||||
// entries tagged `backup` are mapped.
|
||||
let entries: Vec<serde_json::Value> = parse_api(&path, data)?;
|
||||
let mut backups = Vec::new();
|
||||
for entry in entries {
|
||||
@@ -2183,15 +2392,19 @@ fn parse_disk_attrs(value: &str) -> (String, u64, String) {
|
||||
(storage, size, format)
|
||||
}
|
||||
|
||||
/// True when `key` is a VM disk config key (`scsi0`, `virtio1`, `ide2`,
|
||||
/// `sata0`, `nvme0`, ...).
|
||||
/// True when `key` is a VM/container disk config key. QEMU guests use the
|
||||
/// `scsi0`/`virtio1`/`ide2`/`sata0`/`nvme0` bus keys; LXC containers use
|
||||
/// `rootfs` and mount-point keys `mp0`, `mp1`, ...
|
||||
fn is_disk_key(key: &str) -> bool {
|
||||
const BUSES: [&str; 5] = ["scsi", "virtio", "ide", "sata", "nvme"];
|
||||
BUSES.iter().any(|bus| {
|
||||
let bus_key = |bus: &str| {
|
||||
key.strip_prefix(bus).map_or(false, |suffix| {
|
||||
!suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())
|
||||
})
|
||||
})
|
||||
};
|
||||
BUSES.iter().any(|bus| bus_key(bus))
|
||||
|| key == "rootfs"
|
||||
|| bus_key("mp")
|
||||
}
|
||||
|
||||
/// True when `key` is a NIC config key (`net0`, `net1`, ...).
|
||||
@@ -2215,8 +2428,13 @@ fn first_free_bus_index(config: &serde_json::Value, bus: &str) -> u32 {
|
||||
}
|
||||
|
||||
/// The parsed pieces of a NIC config value such as
|
||||
/// `virtio=BC:24:11:AA:BB:CC,bridge=vmbr0,tag=10,firewall=1`.
|
||||
/// `virtio=BC:24:11:AA:BB:CC,bridge=vmbr0,tag=10,firewall=1` (QEMU) or
|
||||
/// `name=eth0,type=veth,hwaddr=BC:24:11:8D:DF:95,bridge=vmbr0,ip=dhcp` (LXC).
|
||||
struct ParsedNetValue {
|
||||
/// True when the value uses the LXC `name=eth0,...` format.
|
||||
lxc: bool,
|
||||
/// The interface name segment of an LXC value (`eth0`); empty for QEMU.
|
||||
name: String,
|
||||
model: String,
|
||||
macaddr: String,
|
||||
bridge: Option<String>,
|
||||
@@ -2225,10 +2443,16 @@ struct ParsedNetValue {
|
||||
link_down: Option<u32>,
|
||||
}
|
||||
|
||||
/// Parses a NIC config value into its components. The first comma-separated
|
||||
/// segment is `model=macaddr`; the remaining segments are key=value
|
||||
/// attributes.
|
||||
/// Parses a NIC config value into its components.
|
||||
///
|
||||
/// QEMU values start with `model=macaddr` and continue with `key=value`
|
||||
/// attributes (`bridge`, `tag`, `firewall`, ...). LXC values start with
|
||||
/// `name=ethN` and express the device type and MAC as `type=veth` /
|
||||
/// `hwaddr=...` attributes, which are mapped onto `model` and `macaddr` so
|
||||
/// callers see a uniform shape.
|
||||
fn parse_net_value(value: &str) -> ParsedNetValue {
|
||||
let mut lxc = false;
|
||||
let mut name = String::new();
|
||||
let mut model = String::new();
|
||||
let mut macaddr = String::new();
|
||||
let mut bridge = None;
|
||||
@@ -2238,9 +2462,14 @@ fn parse_net_value(value: &str) -> ParsedNetValue {
|
||||
|
||||
let mut segments = value.split(',');
|
||||
if let Some(first) = segments.next() {
|
||||
if let Some((m, mac)) = first.split_once('=') {
|
||||
model = m.trim().to_string();
|
||||
macaddr = mac.trim().to_string();
|
||||
if let Some((key, val)) = first.split_once('=') {
|
||||
if key.trim() == "name" {
|
||||
lxc = true;
|
||||
name = val.trim().to_string();
|
||||
} else {
|
||||
model = key.trim().to_string();
|
||||
macaddr = val.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
for segment in segments {
|
||||
@@ -2251,12 +2480,19 @@ fn parse_net_value(value: &str) -> ParsedNetValue {
|
||||
"tag" => tag = val.parse().ok(),
|
||||
"firewall" => firewall = val.parse().ok(),
|
||||
"link_down" => link_down = val.parse().ok(),
|
||||
_ if lxc => match attr.trim() {
|
||||
"type" => model = val.to_string(),
|
||||
"hwaddr" => macaddr = val.to_string(),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ParsedNetValue {
|
||||
lxc,
|
||||
name,
|
||||
model,
|
||||
macaddr,
|
||||
bridge,
|
||||
@@ -2269,19 +2505,58 @@ fn parse_net_value(value: &str) -> ParsedNetValue {
|
||||
/// Re-encodes a NIC config value after applying the edited fields. The model
|
||||
/// and MAC address are always preserved; `config.bridge`, `config.tag` (a tag
|
||||
/// of `0` removes the tag) and `config.firewall` replace the existing values
|
||||
/// when present.
|
||||
/// when present. Any other comma-separated `key=value` attribute in the
|
||||
/// current value (e.g. `link_down`, `rate`, `queues`, `disconnect`) is
|
||||
/// carried over verbatim, so editing a NIC does not drop attributes the
|
||||
/// client does not model.
|
||||
///
|
||||
/// LXC values are re-encoded in their native form — `name=ethN,type=...,
|
||||
/// hwaddr=...,bridge=...[,firewall=...]` — with the same unknown-attribute
|
||||
/// pass-through. LXC has no VLAN tag, so a tag is never emitted.
|
||||
fn reencode_nic_value(current: &str, config: &EditNICConfig) -> String {
|
||||
let parsed = parse_net_value(current);
|
||||
let bridge = config.bridge.clone().or(parsed.bridge);
|
||||
let firewall = match config.firewall {
|
||||
Some(on) => Some(u32::from(on)),
|
||||
None => parsed.firewall,
|
||||
};
|
||||
|
||||
if parsed.lxc {
|
||||
let mut value = format!(
|
||||
"name={},type={},hwaddr={}",
|
||||
parsed.name, parsed.model, parsed.macaddr
|
||||
);
|
||||
if let Some(bridge) = bridge {
|
||||
value.push_str(&format!(",bridge={}", bridge));
|
||||
}
|
||||
if let Some(firewall) = firewall {
|
||||
value.push_str(&format!(",firewall={}", firewall));
|
||||
}
|
||||
// Carry over every other `key=value` attribute from the current value
|
||||
// verbatim, skipping the leading `name=` segment and the attributes
|
||||
// already re-emitted above (`tag` is never valid for LXC).
|
||||
let mut segments = current.split(',');
|
||||
segments.next();
|
||||
for segment in segments {
|
||||
if let Some((attr, _)) = segment.split_once('=') {
|
||||
if matches!(
|
||||
attr.trim(),
|
||||
"bridge" | "firewall" | "name" | "type" | "hwaddr" | "tag"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
value.push(',');
|
||||
value.push_str(segment.trim());
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
let tag = match config.tag {
|
||||
Some(0) => None,
|
||||
Some(t) => Some(t),
|
||||
None => parsed.tag.filter(|&t| t != 0),
|
||||
};
|
||||
let firewall = match config.firewall {
|
||||
Some(on) => Some(u32::from(on)),
|
||||
None => parsed.firewall,
|
||||
};
|
||||
|
||||
let mut value = format!("{}={}", parsed.model, parsed.macaddr);
|
||||
if let Some(bridge) = bridge {
|
||||
@@ -2293,5 +2568,19 @@ fn reencode_nic_value(current: &str, config: &EditNICConfig) -> String {
|
||||
if let Some(firewall) = firewall {
|
||||
value.push_str(&format!(",firewall={}", firewall));
|
||||
}
|
||||
// Carry over every other `key=value` attribute from the current value
|
||||
// verbatim, skipping the leading model=macaddr segment and the attributes
|
||||
// already re-emitted above.
|
||||
let mut segments = current.split(',');
|
||||
segments.next();
|
||||
for segment in segments {
|
||||
if let Some((attr, _)) = segment.split_once('=') {
|
||||
if matches!(attr.trim(), "bridge" | "tag" | "firewall") {
|
||||
continue;
|
||||
}
|
||||
value.push(',');
|
||||
value.push_str(segment.trim());
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Local WebSocket proxy for VM and container consoles.
|
||||
//!
|
||||
//! The webview cannot open a `wss://` connection to a self-signed Proxmox
|
||||
//! server (the browser validates the certificate against the system trust
|
||||
//! store, which rejects it). The consoles therefore go through a loopback
|
||||
//! proxy: the backend opens the `wss://` connection to Proxmox (using the same
|
||||
//! accept-self-signed transport as the HTTP API) and forwards frames to a
|
||||
//! plain `ws://127.0.0.1:<port>` endpoint that the webview can reach. A random
|
||||
//! per-session token in the query string keeps other local processes from
|
||||
//! hijacking an open console.
|
||||
|
||||
use crate::connection::ConnectionManager;
|
||||
use crate::websocket::connect_ws;
|
||||
use crate::{Error, Result};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::watch;
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
|
||||
use tokio_tungstenite::tungstenite::http::StatusCode;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Characters left unencoded when embedding a proxy ticket into a query
|
||||
/// string (everything but RFC 3986 unreserved characters is encoded).
|
||||
const QUERY_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
|
||||
.remove(b'-')
|
||||
.remove(b'.')
|
||||
.remove(b'_')
|
||||
.remove(b'~');
|
||||
|
||||
/// The local endpoint handed to the frontend for one console session.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConsoleProxyInfo {
|
||||
pub session_id: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
struct ConsoleSession {
|
||||
shutdown: watch::Sender<()>,
|
||||
}
|
||||
|
||||
pub struct ConsoleProxyManager {
|
||||
sessions: HashMap<String, ConsoleSession>,
|
||||
}
|
||||
|
||||
impl ConsoleProxyManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a console proxy for a guest and returns a local `ws://` URL.
|
||||
///
|
||||
/// `kind` is `"vnc"` (QEMU) or `"term"` (LXC). A session handles a single
|
||||
/// local client; when the client disconnects the Proxmox stream is torn
|
||||
/// down. Call [`ConsoleProxyManager::stop`] to cancel a session that is
|
||||
/// still waiting for its client.
|
||||
pub async fn start(
|
||||
&mut self,
|
||||
connection_id: &str,
|
||||
kind: &str,
|
||||
node: &str,
|
||||
vmid: u32,
|
||||
manager: &ConnectionManager,
|
||||
) -> Result<ConsoleProxyInfo> {
|
||||
let (proxy_port, ticket) = match kind {
|
||||
"vnc" => {
|
||||
let proxy = manager.create_vnc_proxy(connection_id, node, vmid).await?;
|
||||
(proxy.port, proxy.ticket)
|
||||
}
|
||||
"term" => {
|
||||
let proxy = manager.create_term_proxy(connection_id, node, vmid).await?;
|
||||
(proxy.port, proxy.ticket)
|
||||
}
|
||||
other => {
|
||||
return Err(Error::InvalidUrl(format!(
|
||||
"Invalid console type '{}': expected 'vnc' or 'term'",
|
||||
other
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let origin = manager.get_websocket_url(connection_id, node).await?;
|
||||
let encoded_ticket = utf8_percent_encode(&ticket, QUERY_ENCODE_SET).to_string();
|
||||
let path = if kind == "vnc" {
|
||||
format!(
|
||||
"/api2/json/nodes/{}/qemu/{}/vncwebsocket?port={}&vncticket={}",
|
||||
node, vmid, proxy_port, encoded_ticket
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"/api2/json/nodes/{}/lxc/{}/proxy?port={}&ticket={}",
|
||||
node, vmid, proxy_port, encoded_ticket
|
||||
)
|
||||
};
|
||||
let ws_url = format!("{}{}", origin, path);
|
||||
|
||||
// Connect to Proxmox first so an unreachable server fails the command
|
||||
// instead of leaving a dangling local listener.
|
||||
let server = timeout(Duration::from_secs(20), connect_ws(&ws_url))
|
||||
.await
|
||||
.map_err(|_| Error::WebSocketError("Timed out opening console proxy".to_string()))?
|
||||
.map_err(|e| Error::WebSocketError(format!("Cannot open console proxy: {}", e)))?;
|
||||
|
||||
let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
|
||||
.await
|
||||
.map_err(|e| Error::WebSocketError(format!("Cannot bind console proxy: {}", e)))?;
|
||||
let local_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| Error::WebSocketError(format!("Cannot read console proxy port: {}", e)))?
|
||||
.port();
|
||||
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
let token = Uuid::new_v4().to_string();
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(());
|
||||
|
||||
let task_token = token.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut shutdown = shutdown_rx;
|
||||
// Wait for the single local client (or a stop request).
|
||||
let client = tokio::select! {
|
||||
_ = shutdown.changed() => None,
|
||||
accepted = listener.accept() => match accepted {
|
||||
Ok((tcp, _)) => {
|
||||
let token = task_token.clone();
|
||||
match tokio_tungstenite::accept_hdr_async(tcp, move |request: &Request, response: Response| -> std::result::Result<Response, ErrorResponse> {
|
||||
let query = request.uri().query().unwrap_or("");
|
||||
if query == format!("token={}", token) {
|
||||
Ok(response)
|
||||
} else {
|
||||
Err(Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.body(None)
|
||||
.expect("valid HTTP response"))
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(client) => Some(client),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(client) = client {
|
||||
relay(client, server, &mut shutdown).await;
|
||||
}
|
||||
});
|
||||
|
||||
self.sessions.insert(
|
||||
session_id.clone(),
|
||||
ConsoleSession {
|
||||
shutdown: shutdown_tx,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(ConsoleProxyInfo {
|
||||
session_id,
|
||||
url: format!("ws://127.0.0.1:{}/?token={}", local_port, token),
|
||||
})
|
||||
}
|
||||
|
||||
/// Cancels a console session. Sessions whose client already connected and
|
||||
/// disconnected are gone from the map, so a stop for a stale id is a
|
||||
/// no-op.
|
||||
pub async fn stop(&mut self, session_id: &str) -> Result<()> {
|
||||
if let Some(session) = self.sessions.remove(session_id) {
|
||||
let _ = session.shutdown.send(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Bidirectionally copies WebSocket messages between the local client and the
|
||||
/// Proxmox console stream until either side closes or the session is stopped.
|
||||
async fn relay(
|
||||
client: tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
|
||||
server: tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
shutdown: &mut watch::Receiver<()>,
|
||||
) {
|
||||
let (mut client_tx, mut client_rx) = client.split();
|
||||
let (mut server_tx, mut server_rx) = server.split();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown.changed() => break,
|
||||
next = client_rx.next() => match next {
|
||||
Some(Ok(msg)) => {
|
||||
let closing = matches!(msg, Message::Close(_));
|
||||
if server_tx.send(msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if closing {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
},
|
||||
next = server_rx.next() => match next {
|
||||
Some(Ok(msg)) => {
|
||||
let closing = matches!(msg, Message::Close(_));
|
||||
if client_tx.send(msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if closing {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
-4
@@ -3,10 +3,11 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::Manager;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
mod connection;
|
||||
mod console_proxy;
|
||||
mod error;
|
||||
mod proxmox;
|
||||
pub mod tls;
|
||||
@@ -15,10 +16,11 @@ mod websocket;
|
||||
pub use connection::{
|
||||
api_request, derive_node_url, AuthContext, AuthMode, ConnectionManager, LoadResult,
|
||||
};
|
||||
pub use console_proxy::ConsoleProxyInfo;
|
||||
pub use error::Error;
|
||||
pub use proxmox::{
|
||||
AddDiskConfig, AddNICConfig, BackupJobConfig, CreateSnapshotConfig, EditNICConfig,
|
||||
RestoreConfig,
|
||||
RestoreConfig, UpdateVMConfig,
|
||||
};
|
||||
use websocket::WebSocketManager;
|
||||
|
||||
@@ -125,6 +127,7 @@ pub struct CertificateInfo {
|
||||
struct AppState {
|
||||
connection_manager: Arc<RwLock<ConnectionManager>>,
|
||||
ws_manager: Arc<RwLock<WebSocketManager>>,
|
||||
console_proxy: Arc<RwLock<console_proxy::ConsoleProxyManager>>,
|
||||
}
|
||||
|
||||
/// Returns the path of the persisted connections file.
|
||||
@@ -269,9 +272,12 @@ async fn get_storage_content(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
storage: String,
|
||||
node: Option<String>,
|
||||
) -> Result<Vec<proxmox::StorageContent>> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager.get_storage_content(&connection_id, &storage).await
|
||||
manager
|
||||
.get_storage_content(&connection_id, &storage, node.as_deref())
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -598,6 +604,21 @@ async fn migrate_vm(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_vm_config(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
vm_type: String,
|
||||
config: proxmox::UpdateVMConfig,
|
||||
) -> Result<()> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
manager
|
||||
.update_vm_config(&connection_id, &node, vmid, &vm_type, config)
|
||||
.await
|
||||
}
|
||||
|
||||
// Authentication commands
|
||||
#[tauri::command]
|
||||
async fn login_with_password(
|
||||
@@ -714,6 +735,31 @@ async fn is_websocket_connected(
|
||||
Ok(ws_manager.is_connected(&connection_id))
|
||||
}
|
||||
|
||||
// Console proxy commands
|
||||
#[tauri::command]
|
||||
async fn start_console_proxy(
|
||||
state: tauri::State<'_, AppState>,
|
||||
connection_id: String,
|
||||
kind: String,
|
||||
node: String,
|
||||
vmid: u32,
|
||||
) -> Result<ConsoleProxyInfo> {
|
||||
let manager = state.connection_manager.read().await;
|
||||
let mut proxy = state.console_proxy.write().await;
|
||||
proxy
|
||||
.start(&connection_id, &kind, &node, vmid, &manager)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stop_console_proxy(
|
||||
state: tauri::State<'_, AppState>,
|
||||
session_id: String,
|
||||
) -> Result<()> {
|
||||
let mut proxy = state.console_proxy.write().await;
|
||||
proxy.stop(&session_id).await
|
||||
}
|
||||
|
||||
// Backup management commands
|
||||
#[tauri::command]
|
||||
async fn get_backup_jobs(
|
||||
@@ -862,10 +908,10 @@ async fn update_tray_menu(
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(AppState {
|
||||
connection_manager: Arc::new(RwLock::new(ConnectionManager::new())),
|
||||
ws_manager: Arc::new(RwLock::new(WebSocketManager::new())),
|
||||
console_proxy: Arc::new(RwLock::new(console_proxy::ConsoleProxyManager::new())),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
load_connections,
|
||||
@@ -909,12 +955,15 @@ pub fn run() {
|
||||
delete_snapshot,
|
||||
rollback_snapshot,
|
||||
migrate_vm,
|
||||
update_vm_config,
|
||||
create_vnc_proxy,
|
||||
create_term_proxy,
|
||||
get_websocket_url,
|
||||
connect_websocket,
|
||||
disconnect_websocket,
|
||||
is_websocket_connected,
|
||||
start_console_proxy,
|
||||
stop_console_proxy,
|
||||
get_backup_jobs,
|
||||
get_backups,
|
||||
create_backup_job,
|
||||
@@ -956,6 +1005,15 @@ pub fn run() {
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
// Connection entries are dynamic menu items added by the
|
||||
// frontend via `update_tray_menu`. Clicking one switches
|
||||
// the active connection there, so forward the id as an
|
||||
// event instead of reaching into the backend state here.
|
||||
id if id.starts_with("connection_") => {
|
||||
if let Some(connection_id) = id.strip_prefix("connection_") {
|
||||
let _ = app.emit("tray-connection-click", connection_id.to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
@@ -26,15 +26,25 @@ pub struct Node {
|
||||
pub node: String,
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub cpu: f64,
|
||||
#[serde(default)]
|
||||
pub maxcpu: u32,
|
||||
#[serde(default)]
|
||||
pub mem: u64,
|
||||
#[serde(default)]
|
||||
pub maxmem: u64,
|
||||
#[serde(default)]
|
||||
pub disk: u64,
|
||||
#[serde(default)]
|
||||
pub maxdisk: u64,
|
||||
#[serde(default)]
|
||||
pub uptime: u64,
|
||||
#[serde(default)]
|
||||
pub level: String,
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub r#type: String,
|
||||
}
|
||||
|
||||
@@ -47,6 +57,7 @@ pub struct VM {
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub r#type: String,
|
||||
#[serde(default)]
|
||||
pub node: String,
|
||||
@@ -85,8 +96,11 @@ pub struct VM {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Storage {
|
||||
#[serde(default)]
|
||||
pub storage: String,
|
||||
#[serde(default)]
|
||||
pub r#type: String,
|
||||
#[serde(default)]
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub active: u32,
|
||||
@@ -107,16 +121,23 @@ pub struct Storage {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Task {
|
||||
#[serde(default)]
|
||||
pub upid: String,
|
||||
#[serde(default)]
|
||||
pub node: String,
|
||||
#[serde(default)]
|
||||
pub pid: u32,
|
||||
#[serde(default)]
|
||||
pub pstart: u64,
|
||||
#[serde(default)]
|
||||
pub starttime: u64,
|
||||
#[serde(default)]
|
||||
pub endtime: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub r#type: String,
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub user: String,
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
@@ -136,20 +157,28 @@ pub struct ClusterStatus {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClusterNode {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub nodeid: u32,
|
||||
#[serde(default)]
|
||||
pub online: u32,
|
||||
#[serde(default)]
|
||||
pub local: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub ip: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Snapshot {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub snaptime: u64,
|
||||
#[serde(default)]
|
||||
pub vmstate: u32,
|
||||
#[serde(default)]
|
||||
pub parent: Option<String>,
|
||||
@@ -163,17 +192,14 @@ pub struct CreateSnapshotConfig {
|
||||
pub vmstate: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApiResponse<T> {
|
||||
pub data: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkInterface {
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub macaddr: String,
|
||||
#[serde(default)]
|
||||
pub bridge: Option<String>,
|
||||
@@ -204,10 +230,22 @@ pub struct EditNICConfig {
|
||||
pub firewall: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateVMConfig {
|
||||
pub name: Option<String>,
|
||||
pub cores: Option<u32>,
|
||||
/// Memory size in MiB.
|
||||
pub memory: Option<u64>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Backup {
|
||||
#[serde(default)]
|
||||
pub volid: String,
|
||||
#[serde(default)]
|
||||
pub backupid: String,
|
||||
#[serde(rename = "backup-type")]
|
||||
pub backup_type: String,
|
||||
@@ -215,8 +253,11 @@ pub struct Backup {
|
||||
pub backup_id: String,
|
||||
#[serde(rename = "backup-time")]
|
||||
pub backup_time: u64,
|
||||
#[serde(default)]
|
||||
pub storage: String,
|
||||
#[serde(default)]
|
||||
pub size: u64,
|
||||
#[serde(default)]
|
||||
pub ctime: u64,
|
||||
}
|
||||
|
||||
@@ -224,9 +265,15 @@ pub struct Backup {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackupJob {
|
||||
pub id: String,
|
||||
/// The server sends `storage` (not `store`).
|
||||
#[serde(rename = "storage")]
|
||||
pub store: String,
|
||||
pub schedule: String,
|
||||
/// `all` is only present on jobs that back up every guest; jobs with an
|
||||
/// explicit `vmid` selection omit it.
|
||||
#[serde(default)]
|
||||
pub all: u32,
|
||||
#[serde(default)]
|
||||
pub enabled: u32,
|
||||
#[serde(default)]
|
||||
pub node: Option<String>,
|
||||
@@ -266,7 +313,9 @@ pub struct RestoreConfig {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorageContent {
|
||||
#[serde(default)]
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub ctime: u64,
|
||||
#[serde(default)]
|
||||
pub format: Option<String>,
|
||||
@@ -274,12 +323,14 @@ pub struct StorageContent {
|
||||
pub size: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub subtype: Option<String>,
|
||||
#[serde(default)]
|
||||
pub volid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorageDetail {
|
||||
#[serde(default)]
|
||||
pub storage: String,
|
||||
pub r#type: String,
|
||||
pub content: String,
|
||||
|
||||
@@ -17,6 +17,7 @@ use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{timeout, Duration};
|
||||
use tokio_rustls::TlsConnector;
|
||||
use url::Url;
|
||||
use x509_cert::der::Decode;
|
||||
@@ -27,7 +28,7 @@ use x509_cert::der::Decode;
|
||||
/// certificate's SHA-256 fingerprint is captured on each connection and
|
||||
/// compared against the pinned value recorded on first use (TOFU).
|
||||
#[derive(Debug)]
|
||||
struct AcceptAllVerifier;
|
||||
pub(crate) struct AcceptAllVerifier;
|
||||
|
||||
impl ServerCertVerifier for AcceptAllVerifier {
|
||||
fn verify_server_cert(
|
||||
@@ -70,7 +71,7 @@ impl ServerCertVerifier for AcceptAllVerifier {
|
||||
///
|
||||
/// `install_default` is idempotent and thread-safe; subsequent calls return
|
||||
/// `Err` with the already-installed provider, which is ignored here.
|
||||
fn ensure_crypto_provider() {
|
||||
pub(crate) fn ensure_crypto_provider() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
}
|
||||
|
||||
@@ -109,8 +110,9 @@ async fn capture_leaf_certificate_der(url: &str) -> crate::Result<Vec<u8>> {
|
||||
.first()
|
||||
.ok_or_else(|| Error::InvalidUrl(format!("Cannot resolve host '{}'", addr)))?;
|
||||
|
||||
let tcp = TcpStream::connect(address)
|
||||
let tcp = timeout(Duration::from_secs(10), TcpStream::connect(address))
|
||||
.await
|
||||
.map_err(|_| Error::CertificateError(format!("Timed out connecting to '{}'", addr)))?
|
||||
.map_err(|e| Error::CertificateError(format!("Cannot connect to '{}': {}", addr, e)))?;
|
||||
|
||||
let config = ClientConfig::builder()
|
||||
@@ -119,9 +121,10 @@ async fn capture_leaf_certificate_der(url: &str) -> crate::Result<Vec<u8>> {
|
||||
.with_no_client_auth();
|
||||
|
||||
let connector = TlsConnector::from(Arc::new(config));
|
||||
let tls = connector.connect(server_name, tcp).await.map_err(|e| {
|
||||
Error::CertificateError(format!("TLS handshake with '{}' failed: {}", addr, e))
|
||||
})?;
|
||||
let tls = timeout(Duration::from_secs(15), connector.connect(server_name, tcp))
|
||||
.await
|
||||
.map_err(|_| Error::CertificateError(format!("Timed out during TLS handshake with '{}'", addr)))?
|
||||
.map_err(|e| Error::CertificateError(format!("TLS handshake with '{}' failed: {}", addr, e)))?;
|
||||
|
||||
let (_, session) = tls.get_ref();
|
||||
let leaf = session
|
||||
|
||||
@@ -1,14 +1,52 @@
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio::time::{sleep, timeout, Duration};
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::Connector;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// Connects to a Proxmox WebSocket URL, accepting self-signed certificates.
|
||||
///
|
||||
/// The regular `wss://` flow validates against the system trust store, which
|
||||
/// rejects the self-signed certificates typical of home-lab Proxmox servers.
|
||||
/// As with the HTTP transport, the application enforces trust at its own layer
|
||||
/// (TOFU pinning in `tls.rs`), so the transport here accepts any certificate.
|
||||
pub async fn connect_ws(
|
||||
url: &str,
|
||||
) -> crate::Result<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>>
|
||||
{
|
||||
let request = url
|
||||
.into_client_request()
|
||||
.map_err(|e| Error::WebSocketError(e.to_string()))?;
|
||||
let is_wss = request.uri().scheme_str() == Some("wss");
|
||||
|
||||
let connector = if is_wss {
|
||||
crate::tls::ensure_crypto_provider();
|
||||
let config = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(crate::tls::AcceptAllVerifier))
|
||||
.with_no_client_auth();
|
||||
Some(Connector::Rustls(Arc::new(config)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (ws, _) = timeout(
|
||||
Duration::from_secs(15),
|
||||
tokio_tungstenite::connect_async_tls_with_config(request, None, false, connector),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| Error::WebSocketError(format!("Timed out connecting to '{}'", url)))?
|
||||
.map_err(|e| Error::WebSocketError(e.to_string()))?;
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskUpdate {
|
||||
pub connection_id: String,
|
||||
@@ -60,7 +98,7 @@ impl WebSocketManager {
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> crate::Result<()> {
|
||||
// Disconnect any existing connection for this ID
|
||||
self.disconnect(&connection_id).await;
|
||||
let _ = self.disconnect(&connection_id).await;
|
||||
|
||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||
|
||||
@@ -134,9 +172,7 @@ async fn connect_and_run(
|
||||
url: &str,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> crate::Result<()> {
|
||||
let (ws_stream, _) = connect_async(url)
|
||||
.await
|
||||
.map_err(|e| Error::WebSocketError(e.to_string()))?;
|
||||
let ws_stream = connect_ws(url).await?;
|
||||
|
||||
let cid = connection_id.to_string();
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
Reference in New Issue
Block a user