feat: add marketing site and expand Proxmox management features

This commit is contained in:
Matt
2026-08-10 14:33:06 +00:00
parent 27b9aeb45e
commit 20dd2dc449
70 changed files with 12092 additions and 1282 deletions
+353 -64
View File
@@ -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
}
+224
View File
@@ -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
View File
@@ -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)?;
+57 -6
View File
@@ -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,
+9 -6
View File
@@ -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
+42 -6
View File
@@ -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();