feat: add cluster discovery, failover, VM management, and console support

This commit is contained in:
Matt
2026-08-09 23:01:51 +00:00
parent 7bb3ba48f0
commit 190fdb2573
100 changed files with 11351 additions and 1604 deletions
View File
+288
View File
@@ -47,6 +47,48 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-object-pool"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1ac0219111eb7bb7cb76d4cf2cb50c598e7ae549091d3616f9e95442c18486f"
dependencies = [
"async-lock",
"event-listener",
]
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "atk"
version = "0.18.2"
@@ -849,6 +891,26 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "event-listener"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -1005,6 +1067,12 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]]
name = "futures-util"
version = "0.3.33"
@@ -1317,6 +1385,25 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "h2"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1329,6 +1416,30 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "headers"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
dependencies = [
"base64 0.22.1",
"bytes",
"headers-core",
"http",
"httpdate",
"mime",
"sha1",
]
[[package]]
name = "headers-core"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
dependencies = [
"http",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -1396,6 +1507,46 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "httpmock"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf4888a4d02d8e1f92ffb6b4965cf5ff56dda36ef41975f41c6fa0f6bde78c4e"
dependencies = [
"assert-json-diff",
"async-object-pool",
"async-trait",
"base64 0.22.1",
"bytes",
"crossbeam-utils",
"form_urlencoded",
"futures-timer",
"futures-util",
"headers",
"http",
"http-body-util",
"hyper",
"hyper-util",
"path-tree",
"regex",
"serde",
"serde_json",
"serde_regex",
"similar",
"stringmetrics",
"tabwriter",
"thiserror 2.0.19",
"tokio",
"tracing",
"url",
]
[[package]]
name = "hyper"
version = "1.11.0"
@@ -1406,9 +1557,11 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@@ -1865,6 +2018,12 @@ dependencies = [
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
@@ -2286,6 +2445,12 @@ dependencies = [
"system-deps",
]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2309,6 +2474,25 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "path-tree"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a97453bc21a968f722df730bfe11bd08745cb50d1300b0df2bda131dece136"
dependencies = [
"smallvec",
]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@@ -2526,7 +2710,10 @@ version = "0.1.0"
dependencies = [
"futures-util",
"hex",
"httpmock",
"keyring",
"percent-encoding",
"rcgen",
"reqwest 0.12.28",
"rustls",
"rustls-pemfile",
@@ -2536,8 +2723,10 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-shell",
"tempfile",
"thiserror 2.0.19",
"tokio",
"tokio-rustls",
"tokio-tungstenite",
"url",
"uuid",
@@ -2692,6 +2881,19 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -2862,6 +3064,19 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.42"
@@ -3118,6 +3333,16 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_regex"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bafc8d0c5330cecff10f16b459b479fd9acaa5b4acd7167301414e21b0057012"
dependencies = [
"regex",
"serde",
]
[[package]]
name = "serde_repr"
version = "0.1.21"
@@ -3298,6 +3523,12 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -3414,6 +3645,12 @@ dependencies = [
"quote",
]
[[package]]
name = "stringmetrics"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b3c8667cd96245cbb600b8dec5680a7319edd719c5aa2b5d23c6bff94f39765"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -3502,6 +3739,15 @@ dependencies = [
"version-compare",
]
[[package]]
name = "tabwriter"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432"
dependencies = [
"unicode-width",
]
[[package]]
name = "tao"
version = "0.35.3"
@@ -3809,6 +4055,19 @@ dependencies = [
"toml 1.1.4+spec-1.1.0",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.5.1"
@@ -3997,6 +4256,7 @@ dependencies = [
"bytes",
"futures-core",
"futures-sink",
"libc",
"pin-project-lite",
"tokio",
]
@@ -4172,10 +4432,23 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
@@ -4298,6 +4571,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -5174,6 +5453,15 @@ dependencies = [
"tls_codec",
]
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]]
name = "yoke"
version = "0.8.3"
+7
View File
@@ -20,12 +20,19 @@ keyring = { version = "3", features = ["apple-native", "windows-native", "linux-
uuid = { version = "1", features = ["v4"] }
rustls = "0.23"
rustls-pemfile = "2"
tokio-rustls = "0.26"
x509-cert = "0.2"
sha2 = "0.10"
hex = "0.4"
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3"
url = "2"
percent-encoding = "2"
[dev-dependencies]
httpmock = "0.8"
rcgen = "0.13"
tempfile = "3"
[features]
default = ["custom-protocol"]
+1 -1
View File
@@ -1 +1 @@
{}
{"default":{"identifier":"default","description":"Default capabilities for ProxmoxDesktop","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-listen","core:event:allow-emit","core:event:allow-emit-to","core:window:default","core:window:allow-show","core:window:allow-hide","core:window:allow-close","core:window:allow-set-focus","core:window:allow-is-visible","shell:allow-execute","shell:default"]}}
File diff suppressed because it is too large Load Diff
+1846 -190
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -12,6 +12,9 @@ pub enum Error {
#[error("HTTP request failed: {0}")]
HttpError(#[from] reqwest::Error),
#[error("Cannot connect to server: {0}")]
ConnectionFailed(String),
#[error("Certificate error: {0}")]
CertificateError(String),
+235 -54
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::TrayIconBuilder;
@@ -6,12 +7,19 @@ use tauri::Manager;
use tokio::sync::RwLock;
mod connection;
mod proxmox;
mod error;
mod proxmox;
pub mod tls;
mod websocket;
use connection::ConnectionManager;
use error::Error;
pub use connection::{
api_request, derive_node_url, AuthContext, AuthMode, ConnectionManager, LoadResult,
};
pub use error::Error;
pub use proxmox::{
AddDiskConfig, AddNICConfig, BackupJobConfig, CreateSnapshotConfig, EditNICConfig,
RestoreConfig,
};
use websocket::WebSocketManager;
pub type Result<T> = std::result::Result<T, Error>;
@@ -23,13 +31,36 @@ pub struct ConnectionConfig {
pub name: String,
pub primary: EndpointConfig,
pub fallbacks: Vec<EndpointConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cert_fingerprint: Option<String>,
pub trusted: bool,
#[serde(default)]
pub accept_untrusted: bool,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cluster_name: Option<String>,
pub is_cluster: bool,
pub auth_mode: String,
pub username: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub nodes: Vec<DiscoveredNode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cluster_id: Option<String>,
}
/// A node discovered in the cluster connected through a
/// [`ConnectionConfig`]. `url` is the endpoint URL derived from the
/// connection's primary endpoint (same scheme and port, host replaced with
/// the node's cluster IP or name); `is_primary` marks the node that the
/// connection is anchored on.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DiscoveredNode {
pub name: String,
pub url: String,
pub status: String,
pub is_primary: bool,
pub local: bool,
}
#[derive(Clone, Serialize, Deserialize)]
@@ -37,6 +68,7 @@ pub struct ConnectionConfig {
pub struct EndpointConfig {
pub url: String,
pub node: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
}
@@ -48,7 +80,38 @@ pub struct LoginResult {
pub csrf_token: String,
}
#[derive(Clone, Serialize, Deserialize)]
/// The outcome of a `connect` attempt. A standalone success reports
/// `merged_into: None`; connecting to a cluster already represented by another
/// connection folds the new endpoint into it and reports
/// `merged_into: Some(target)`. When no endpoint is reachable the connection
/// is still tracked and reported with `status: "failed"` instead of an error.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectResult {
/// The effective connection id to use after the connect (the merge target
/// when this connect merged into an existing same-cluster connection).
pub connection_id: String,
/// `Some(target)` when this connect merged into an existing connection.
pub merged_into: Option<String>,
/// `"connected"` | `"failover"` | `"failed"`.
pub status: String,
}
/// A snapshot of a connection's runtime state for the status bar: the
/// effective status, the primary and currently-serving endpoints, and the last
/// discovered node list.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionStatusInfo {
pub connection_id: String,
/// `"connected"` | `"failover"` | `"failed"` | `"disconnected"`.
pub status: String,
pub primary_url: String,
pub current_endpoint_url: String,
pub nodes: Vec<DiscoveredNode>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CertificateInfo {
pub fingerprint: String,
@@ -64,38 +127,91 @@ struct AppState {
ws_manager: Arc<RwLock<WebSocketManager>>,
}
/// Returns the path of the persisted connections file.
///
/// Lives at `{app_config_dir}/proxmoxdesktop/connections.json`; the parent
/// directory is created lazily when the file is first written.
fn connections_file(app: &tauri::AppHandle) -> Result<PathBuf> {
let dir = app.path().app_config_dir()?;
Ok(dir.join("proxmoxdesktop").join("connections.json"))
}
#[tauri::command]
async fn load_connections(
state: tauri::State<'_, AppState>,
app: tauri::AppHandle,
) -> Result<LoadResult> {
let mut manager = state.connection_manager.write().await;
let path = connections_file(&app)?;
manager.load_connections(&path).await
}
#[tauri::command]
async fn add_connection(
state: tauri::State<'_, AppState>,
config: ConnectionConfig,
app: tauri::AppHandle,
) -> Result<()> {
let mut manager = state.connection_manager.write().await;
manager.add_connection(config).await
let path = connections_file(&app)?;
manager.add_connection(config, &path).await
}
#[tauri::command]
async fn remove_connection(
state: tauri::State<'_, AppState>,
id: String,
app: tauri::AppHandle,
) -> Result<()> {
let mut manager = state.connection_manager.write().await;
manager.remove_connection(&id).await
let path = connections_file(&app)?;
manager.remove_connection(&id, &path).await
}
#[tauri::command]
async fn update_connection(
state: tauri::State<'_, AppState>,
config: ConnectionConfig,
app: tauri::AppHandle,
) -> Result<()> {
let mut manager = state.connection_manager.write().await;
let path = connections_file(&app)?;
manager.update_connection(config, &path).await
}
#[tauri::command]
async fn connect_to_server(
state: tauri::State<'_, AppState>,
id: String,
) -> Result<()> {
app: tauri::AppHandle,
) -> Result<ConnectResult> {
let mut manager = state.connection_manager.write().await;
manager.connect(&id).await
let path = connections_file(&app)?;
manager.connect(&id, &path).await
}
#[tauri::command]
async fn disconnect_from_server(
async fn get_connection_status(
state: tauri::State<'_, AppState>,
connection_id: String,
) -> Result<ConnectionStatusInfo> {
let mut manager = state.connection_manager.write().await;
manager.status_info(&connection_id).await
}
#[tauri::command]
async fn set_active_connection(
state: tauri::State<'_, AppState>,
id: String,
app: tauri::AppHandle,
) -> Result<()> {
let mut manager = state.connection_manager.write().await;
let path = connections_file(&app)?;
manager.set_active_connection(id, &path).await
}
#[tauri::command]
async fn disconnect_from_server(state: tauri::State<'_, AppState>, id: String) -> Result<()> {
let mut manager = state.connection_manager.write().await;
manager.disconnect(&id).await
}
@@ -114,9 +230,11 @@ async fn trust_certificate(
state: tauri::State<'_, AppState>,
id: String,
fingerprint: String,
app: tauri::AppHandle,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.trust_certificate(&id, &fingerprint).await
let mut manager = state.connection_manager.write().await;
let path = connections_file(&app)?;
manager.trust_certificate(&id, &fingerprint, &path).await
}
#[tauri::command]
@@ -164,7 +282,9 @@ async fn get_storage_detail(
storage: String,
) -> Result<proxmox::StorageDetail> {
let manager = state.connection_manager.read().await;
manager.get_storage_detail(&connection_id, &node, &storage).await
manager
.get_storage_detail(&connection_id, &node, &storage)
.await
}
#[tauri::command]
@@ -191,9 +311,12 @@ async fn start_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.start_vm(&connection_id, &node, vmid).await
manager
.start_vm(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -202,9 +325,10 @@ async fn stop_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.stop_vm(&connection_id, &node, vmid).await
manager.stop_vm(&connection_id, &node, vmid, &vm_type).await
}
#[tauri::command]
@@ -213,9 +337,12 @@ async fn shutdown_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.shutdown_vm(&connection_id, &node, vmid).await
manager
.shutdown_vm(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -224,9 +351,12 @@ async fn reboot_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.reboot_vm(&connection_id, &node, vmid).await
manager
.reboot_vm(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -235,9 +365,12 @@ async fn suspend_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.suspend_vm(&connection_id, &node, vmid).await
manager
.suspend_vm(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -246,9 +379,12 @@ async fn resume_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.resume_vm(&connection_id, &node, vmid).await
manager
.resume_vm(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -257,9 +393,12 @@ async fn get_disks(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<Vec<proxmox::Disk>> {
let manager = state.connection_manager.read().await;
manager.get_disks(&connection_id, &node, vmid).await
manager
.get_disks(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -268,10 +407,13 @@ async fn add_disk(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
config: proxmox::AddDiskConfig,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.add_disk(&connection_id, &node, vmid, config).await
manager
.add_disk(&connection_id, &node, vmid, &vm_type, config)
.await
}
#[tauri::command]
@@ -280,11 +422,14 @@ async fn resize_disk(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
disk: String,
size: u64,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.resize_disk(&connection_id, &node, vmid, &disk, size).await
manager
.resize_disk(&connection_id, &node, vmid, &vm_type, &disk, size)
.await
}
#[tauri::command]
@@ -293,10 +438,13 @@ async fn remove_disk(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
disk: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.remove_disk(&connection_id, &node, vmid, &disk).await
manager
.remove_disk(&connection_id, &node, vmid, &vm_type, &disk)
.await
}
#[tauri::command]
@@ -305,11 +453,14 @@ async fn move_disk(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
disk: String,
storage: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.move_disk(&connection_id, &node, vmid, &disk, &storage).await
manager
.move_disk(&connection_id, &node, vmid, &vm_type, &disk, &storage)
.await
}
#[tauri::command]
@@ -318,9 +469,12 @@ async fn get_network_interfaces(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<Vec<proxmox::NetworkInterface>> {
let manager = state.connection_manager.read().await;
manager.get_network_interfaces(&connection_id, &node, vmid).await
manager
.get_network_interfaces(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -329,10 +483,13 @@ async fn add_nic(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
config: proxmox::AddNICConfig,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.add_nic(&connection_id, &node, vmid, config).await
manager
.add_nic(&connection_id, &node, vmid, &vm_type, config)
.await
}
#[tauri::command]
@@ -341,11 +498,14 @@ async fn edit_nic(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
nic: String,
config: proxmox::EditNICConfig,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.edit_nic(&connection_id, &node, vmid, &nic, config).await
manager
.edit_nic(&connection_id, &node, vmid, &vm_type, &nic, config)
.await
}
#[tauri::command]
@@ -354,10 +514,13 @@ async fn remove_nic(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
nic: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.remove_nic(&connection_id, &node, vmid, &nic).await
manager
.remove_nic(&connection_id, &node, vmid, &vm_type, &nic)
.await
}
#[tauri::command]
@@ -366,9 +529,12 @@ async fn get_snapshots(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<Vec<proxmox::Snapshot>> {
let manager = state.connection_manager.read().await;
manager.get_snapshots(&connection_id, &node, vmid).await
manager
.get_snapshots(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -377,10 +543,13 @@ async fn create_snapshot(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
config: proxmox::CreateSnapshotConfig,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.create_snapshot(&connection_id, &node, vmid, config).await
manager
.create_snapshot(&connection_id, &node, vmid, &vm_type, config)
.await
}
#[tauri::command]
@@ -389,10 +558,13 @@ async fn delete_snapshot(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
name: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.delete_snapshot(&connection_id, &node, vmid, &name).await
manager
.delete_snapshot(&connection_id, &node, vmid, &vm_type, &name)
.await
}
#[tauri::command]
@@ -401,10 +573,13 @@ async fn rollback_snapshot(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
name: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.rollback_snapshot(&connection_id, &node, vmid, &name).await
manager
.rollback_snapshot(&connection_id, &node, vmid, &vm_type, &name)
.await
}
#[tauri::command]
@@ -413,11 +588,14 @@ async fn migrate_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
target_node: String,
online: bool,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.migrate_vm(&connection_id, &node, vmid, &target_node, online).await
manager
.migrate_vm(&connection_id, &node, vmid, &vm_type, &target_node, online)
.await
}
// Authentication commands
@@ -429,7 +607,9 @@ async fn login_with_password(
password: String,
) -> Result<LoginResult> {
let manager = state.connection_manager.read().await;
manager.login_with_password(&url, &username, &password).await
manager
.login_with_password(&url, &username, &password)
.await
}
#[tauri::command]
@@ -443,10 +623,7 @@ async fn login_with_token(
}
#[tauri::command]
async fn logout(
state: tauri::State<'_, AppState>,
connection_id: String,
) -> Result<()> {
async fn logout(state: tauri::State<'_, AppState>, connection_id: String) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.logout(&connection_id).await
}
@@ -461,7 +638,7 @@ async fn get_stored_credentials(
}
// Console proxy types
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VNCProxyResponse {
pub ticket: String,
@@ -469,7 +646,7 @@ pub struct VNCProxyResponse {
pub cert: String,
}
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TermProxyResponse {
pub ticket: String,
@@ -554,7 +731,9 @@ async fn get_backups(
storage: Option<String>,
) -> Result<Vec<proxmox::Backup>> {
let manager = state.connection_manager.read().await;
manager.get_backups(&connection_id, storage.as_deref()).await
manager
.get_backups(&connection_id, storage.as_deref())
.await
}
#[tauri::command]
@@ -689,10 +868,14 @@ pub fn run() {
ws_manager: Arc::new(RwLock::new(WebSocketManager::new())),
})
.invoke_handler(tauri::generate_handler![
load_connections,
add_connection,
remove_connection,
update_connection,
set_active_connection,
connect_to_server,
disconnect_from_server,
get_connection_status,
login_with_password,
login_with_token,
logout,
@@ -759,23 +942,21 @@ pub fn run() {
.tooltip("ProxmoxDesktop")
.icon(app.default_window_icon().cloned().expect("no default icon"))
.menu(&menu)
.on_menu_event(move |app, event| {
match event.id.as_ref() {
"show_hide" => {
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
.on_menu_event(move |app, event| match event.id.as_ref() {
"show_hide" => {
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
}
"quit" => {
app.exit(0);
}
_ => {}
}
"quit" => {
app.exit(0);
}
_ => {}
})
.build(app)?;
+54
View File
@@ -7,6 +7,7 @@ pub struct Disk {
pub size: u64,
pub storage: String,
pub format: String,
#[serde(default)]
pub usage: Option<String>,
}
@@ -21,7 +22,9 @@ pub struct AddDiskConfig {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Node {
#[serde(default)]
pub node: String,
#[serde(default)]
pub status: String,
pub cpu: f64,
pub maxcpu: u32,
@@ -38,25 +41,44 @@ pub struct Node {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VM {
#[serde(default)]
pub vmid: u32,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub status: String,
pub r#type: String,
#[serde(default)]
pub node: String,
#[serde(default)]
pub cpu: f64,
#[serde(default)]
pub cpus: 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 netin: u64,
#[serde(default)]
pub netout: u64,
#[serde(default)]
pub diskread: u64,
#[serde(default)]
pub diskwrite: u64,
#[serde(default)]
pub pid: Option<u32>,
#[serde(default)]
pub template: Option<u32>,
#[serde(default)]
pub lock: Option<String>,
#[serde(default)]
pub tags: Option<String>,
}
@@ -66,12 +88,19 @@ pub struct Storage {
pub storage: String,
pub r#type: String,
pub content: String,
#[serde(default)]
pub active: u32,
#[serde(default)]
pub enabled: u32,
#[serde(default)]
pub shared: u32,
#[serde(default)]
pub used: u64,
#[serde(default)]
pub total: u64,
#[serde(default)]
pub avail: u64,
#[serde(default)]
pub node: String,
}
@@ -79,15 +108,19 @@ pub struct Storage {
#[serde(rename_all = "camelCase")]
pub struct Task {
pub upid: String,
#[serde(default)]
pub node: String,
pub pid: u32,
pub pstart: u64,
pub starttime: u64,
#[serde(default)]
pub endtime: Option<u64>,
pub r#type: String,
pub id: String,
pub user: String,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub exitstatus: Option<String>,
}
@@ -114,9 +147,11 @@ pub struct ClusterNode {
#[serde(rename_all = "camelCase")]
pub struct Snapshot {
pub name: String,
#[serde(default)]
pub description: String,
pub snaptime: u64,
pub vmstate: u32,
#[serde(default)]
pub parent: Option<String>,
}
@@ -140,9 +175,13 @@ pub struct NetworkInterface {
pub name: String,
pub model: String,
pub macaddr: String,
#[serde(default)]
pub bridge: Option<String>,
#[serde(default)]
pub tag: Option<u32>,
#[serde(default)]
pub firewall: Option<u32>,
#[serde(default)]
pub link_down: Option<u32>,
}
@@ -189,10 +228,15 @@ pub struct BackupJob {
pub schedule: String,
pub all: u32,
pub enabled: u32,
#[serde(default)]
pub node: Option<String>,
#[serde(default)]
pub vmid: Option<String>,
#[serde(default)]
pub compress: Option<String>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub quiet: Option<u32>,
}
@@ -224,8 +268,11 @@ pub struct RestoreConfig {
pub struct StorageContent {
pub content: String,
pub ctime: u64,
#[serde(default)]
pub format: Option<String>,
#[serde(default)]
pub size: Option<u64>,
#[serde(default)]
pub subtype: Option<String>,
pub volid: String,
}
@@ -236,11 +283,18 @@ pub struct StorageDetail {
pub storage: String,
pub r#type: String,
pub content: String,
#[serde(default)]
pub active: u32,
#[serde(default)]
pub enabled: u32,
#[serde(default)]
pub shared: u32,
#[serde(default)]
pub used: u64,
#[serde(default)]
pub total: u64,
#[serde(default)]
pub avail: u64,
#[serde(default)]
pub node: String,
}
+218
View File
@@ -0,0 +1,218 @@
//! Application-layer TLS certificate capture and TOFU (Trust On First Use)
//! fingerprint pinning.
//!
//! The reqwest transport used by the rest of the app is configured to accept
//! self-signed and otherwise invalid certificates, which is typical for
//! home-lab Proxmox servers. To keep connections safe without a full CA trust
//! chain, this module performs its own certificate capture at the connect
//! layer: it opens a raw TLS connection, extracts the presented leaf
//! certificate, and computes its SHA-256 fingerprint. [`verify_server_certificate`]
//! then compares that fingerprint against the value pinned when the server was
//! first trusted.
use crate::{CertificateInfo, Error};
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName};
use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
use sha2::{Digest, Sha256};
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio_rustls::TlsConnector;
use url::Url;
use x509_cert::der::Decode;
/// Verifier that accepts every presented certificate chain.
///
/// The actual security decision is deferred to the application layer: the leaf
/// 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;
impl ServerCertVerifier for AcceptAllVerifier {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
/// Installs the ring crypto provider once per process.
///
/// `install_default` is idempotent and thread-safe; subsequent calls return
/// `Err` with the already-installed provider, which is ignored here.
fn ensure_crypto_provider() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
/// Opens a raw TLS connection to `url` and returns the DER-encoded leaf
/// certificate presented by the server.
async fn capture_leaf_certificate_der(url: &str) -> crate::Result<Vec<u8>> {
ensure_crypto_provider();
let parsed =
Url::parse(url).map_err(|e| Error::InvalidUrl(format!("Invalid URL '{}': {}", url, e)))?;
if parsed.scheme() != "https" {
return Err(Error::InvalidUrl(format!(
"Only https URLs are supported, got '{}://'",
parsed.scheme()
)));
}
let host = parsed
.host_str()
.ok_or_else(|| Error::InvalidUrl(format!("URL '{}' has no host", url)))?
.to_string();
let port = parsed.port().unwrap_or(443);
let server_name = ServerName::try_from(host.clone()).map_err(|e| {
Error::InvalidUrl(format!(
"Invalid hostname '{}' in URL '{}': {}",
host, url, e
))
})?;
let addr = format!("{}:{}", host, port);
let addresses = tokio::net::lookup_host(&addr)
.await
.map_err(|e| Error::InvalidUrl(format!("Cannot resolve '{}': {}", addr, e)))?
.collect::<Vec<_>>();
let address = addresses
.first()
.ok_or_else(|| Error::InvalidUrl(format!("Cannot resolve host '{}'", addr)))?;
let tcp = TcpStream::connect(address)
.await
.map_err(|e| Error::CertificateError(format!("Cannot connect to '{}': {}", addr, e)))?;
let config = ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAllVerifier))
.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 (_, session) = tls.get_ref();
let leaf = session
.peer_certificates()
.and_then(|certs| certs.first())
.ok_or_else(|| {
Error::CertificateError(format!("Server '{}' presented no TLS certificate", addr))
})?;
Ok(leaf.as_ref().to_vec())
}
/// Computes the SHA-256 fingerprint of a DER-encoded certificate in the
/// `AA:BB:CC:...` uppercase colon-separated format used across the app.
fn fingerprint_of(der: &[u8]) -> String {
let digest = Sha256::digest(der);
let hex = hex::encode_upper(digest);
hex.as_bytes()
.chunks(2)
.map(|pair| std::str::from_utf8(pair).expect("hex digits are ASCII"))
.collect::<Vec<_>>()
.join(":")
}
/// Captures just the SHA-256 fingerprint of the certificate presented by `url`.
pub async fn capture_fingerprint(url: &str) -> crate::Result<String> {
let der = capture_leaf_certificate_der(url).await?;
Ok(fingerprint_of(&der))
}
/// Compares a captured fingerprint against a pinned value.
///
/// The comparison is case-insensitive and ignores the `:` (and other
/// non-hex) separators, so `ab:cd` and `AB:CD` (or even `abcd`) all match.
pub fn verify_pin(fingerprint: &str, pinned: &str) -> bool {
fn normalize(s: &str) -> String {
s.chars()
.filter(|c| c.is_ascii_hexdigit())
.flat_map(char::to_uppercase)
.collect()
}
normalize(fingerprint) == normalize(pinned)
}
/// Connects to `url`, captures the presented leaf certificate, and returns a
/// [`CertificateInfo`] with its fingerprint and parsed metadata.
pub async fn fetch_certificate_info(url: &str) -> crate::Result<CertificateInfo> {
let der = capture_leaf_certificate_der(url).await?;
let cert = x509_cert::Certificate::from_der(&der)
.map_err(|e| Error::CertificateError(format!("Failed to parse certificate: {}", e)))?;
let tbs = &cert.tbs_certificate;
let issuer = tbs.issuer.to_string();
let subject = tbs.subject.to_string();
let self_signed = issuer == subject;
Ok(CertificateInfo {
fingerprint: fingerprint_of(&der),
issuer,
subject,
valid_from: tbs.validity.not_before.to_string(),
valid_to: tbs.validity.not_after.to_string(),
self_signed,
})
}
/// Captures the certificate fingerprint of `url` and verifies it against the
/// pinned fingerprint recorded on first use.
///
/// * `pinned == None`: first use — returns the fingerprint so the caller can
/// record it (the TOFU trust step).
/// * `pinned == Some(...)` and it matches the captured fingerprint: `Ok`.
/// * `pinned == Some(...)` and it does not match: returns
/// [`Error::CertificateError`] unless `accept_untrusted` is set, in which
/// case the mismatch is ignored (escape hatch).
///
/// The returned value is the captured fingerprint.
pub async fn verify_server_certificate(
url: &str,
pinned: Option<&str>,
accept_untrusted: bool,
) -> crate::Result<String> {
let fingerprint = capture_fingerprint(url).await?;
if let Some(pinned) = pinned {
if !verify_pin(&fingerprint, pinned) && !accept_untrusted {
return Err(Error::CertificateError(format!(
"Certificate fingerprint changed. Expected '{}' but got '{}'. \
This could be a man-in-the-middle attack.",
pinned, fingerprint
)));
}
}
Ok(fingerprint)
}
+2 -5
View File
@@ -4,8 +4,8 @@ use std::collections::HashMap;
use tauri::Emitter;
use tokio::sync::mpsc;
use tokio::time::{sleep, Duration};
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
use crate::error::Error;
@@ -236,10 +236,7 @@ fn handle_ws_message(connection_id: &str, text: &str, app_handle: &tauri::AppHan
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
vmid: data
.get("vmid")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
vmid: data.get("vmid").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
status: data
.get("status")
.and_then(|v| v.as_str())
+1 -1
View File
@@ -1 +1 @@
{"rustc_fingerprint":6524788087311550793,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.95.0 (59807616e 2026-04-14)\nbinary: rustc\ncommit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860\ncommit-date: 2026-04-14\nhost: aarch64-apple-darwin\nrelease: 1.95.0\nLLVM version: 22.1.2\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.dylib\nlib___.dylib\nlib___.a\nlib___.dylib\n/Users/matt/.rustup/toolchains/stable-aarch64-apple-darwin\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"unix\"\ntarget_feature=\"aes\"\ntarget_feature=\"crc\"\ntarget_feature=\"dit\"\ntarget_feature=\"dotprod\"\ntarget_feature=\"dpb\"\ntarget_feature=\"dpb2\"\ntarget_feature=\"fcma\"\ntarget_feature=\"fhm\"\ntarget_feature=\"flagm\"\ntarget_feature=\"fp16\"\ntarget_feature=\"frintts\"\ntarget_feature=\"jsconv\"\ntarget_feature=\"lor\"\ntarget_feature=\"lse\"\ntarget_feature=\"neon\"\ntarget_feature=\"paca\"\ntarget_feature=\"pacg\"\ntarget_feature=\"pan\"\ntarget_feature=\"pmuv3\"\ntarget_feature=\"ras\"\ntarget_feature=\"rcpc\"\ntarget_feature=\"rcpc2\"\ntarget_feature=\"rdm\"\ntarget_feature=\"sb\"\ntarget_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"ssbs\"\ntarget_feature=\"vh\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"macos\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"apple\"\nunix\n","stderr":""}},"successes":{}}
{"rustc_fingerprint":9200552825474144316,"outputs":{"7752308220390268658":{"success":true,"status":"","code":0,"stdout":"rustc 1.97.1 (8bab26f4f 2026-07-14)\nbinary: rustc\ncommit-hash: 8bab26f4f68e0e26f0bb7960be334d5b520ea452\ncommit-date: 2026-07-14\nhost: x86_64-unknown-linux-gnu\nrelease: 1.97.1\nLLVM version: 22.1.6\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/user/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_has_atomic_primitive_alignment=\"16\"\ntarget_has_atomic_primitive_alignment=\"32\"\ntarget_has_atomic_primitive_alignment=\"64\"\ntarget_has_atomic_primitive_alignment=\"8\"\ntarget_has_atomic_primitive_alignment=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
@@ -1,2 +0,0 @@
{"$message_type":"diagnostic","message":"linker `cc` not found","code":null,"level":"error","spans":[],"children":[{"message":"No such file or directory (os error 2)","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: linker `cc` not found\u001b[0m\n \u001b[1m\u001b[94m|\u001b[0m\n \u001b[1m\u001b[94m= \u001b[0m\u001b[1mnote\u001b[0m: No such file or directory (os error 2)\n\n"}
{"$message_type":"diagnostic","message":"aborting due to 1 previous error","code":null,"level":"error","spans":[],"children":[],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: aborting due to 1 previous error\u001b[0m\n\n"}
@@ -1,2 +0,0 @@
{"$message_type":"diagnostic","message":"linker `cc` not found","code":null,"level":"error","spans":[],"children":[{"message":"No such file or directory (os error 2)","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: linker `cc` not found\u001b[0m\n \u001b[1m\u001b[94m|\u001b[0m\n \u001b[1m\u001b[94m= \u001b[0m\u001b[1mnote\u001b[0m: No such file or directory (os error 2)\n\n"}
{"$message_type":"diagnostic","message":"aborting due to 1 previous error","code":null,"level":"error","spans":[],"children":[],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: aborting due to 1 previous error\u001b[0m\n\n"}
@@ -1,2 +0,0 @@
{"$message_type":"diagnostic","message":"linker `cc` not found","code":null,"level":"error","spans":[],"children":[{"message":"No such file or directory (os error 2)","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: linker `cc` not found\u001b[0m\n \u001b[1m\u001b[94m|\u001b[0m\n \u001b[1m\u001b[94m= \u001b[0m\u001b[1mnote\u001b[0m: No such file or directory (os error 2)\n\n"}
{"$message_type":"diagnostic","message":"aborting due to 1 previous error","code":null,"level":"error","spans":[],"children":[],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: aborting due to 1 previous error\u001b[0m\n\n"}
@@ -1,2 +0,0 @@
{"$message_type":"diagnostic","message":"linker `cc` not found","code":null,"level":"error","spans":[],"children":[{"message":"No such file or directory (os error 2)","code":null,"level":"note","spans":[],"children":[],"rendered":null}],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: linker `cc` not found\u001b[0m\n \u001b[1m\u001b[94m|\u001b[0m\n \u001b[1m\u001b[94m= \u001b[0m\u001b[1mnote\u001b[0m: No such file or directory (os error 2)\n\n"}
{"$message_type":"diagnostic","message":"aborting due to 1 previous error","code":null,"level":"error","spans":[],"children":[],"rendered":"\u001b[1m\u001b[91merror\u001b[0m\u001b[1m: aborting due to 1 previous error\u001b[0m\n\n"}
@@ -1,5 +1,5 @@
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/libc-19124a20af635abc/build_script_build-19124a20af635abc: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/libc-0.2.189/build.rs:
@@ -1,5 +1,5 @@
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/proc-macro2-28cd7e73fe64eada/build_script_build-28cd7e73fe64eada: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/proc-macro2-1.0.107/build.rs:
@@ -1,5 +1,5 @@
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/quote-6dff9724e4e81362/build_script_build-6dff9724e4e81362: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/quote-1.0.47/build.rs:
@@ -1,5 +1,5 @@
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/build/serde_core-e613c711ddfe8493/build_script_build-e613c711ddfe8493: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/serde_core-1.0.229/build.rs:
@@ -1,8 +1,8 @@
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/deps/unicode_ident-8443eb632a3fbe4c.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/deps/unicode_ident-8443eb632a3fbe4c.d: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/deps/libunicode_ident-8443eb632a3fbe4c.rlib: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/deps/libunicode_ident-8443eb632a3fbe4c.rlib: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
/home/user/dev/ProxmoxDesktop/src-tauri/target/debug/deps/libunicode_ident-8443eb632a3fbe4c.rmeta: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
/home/user/projects/dev/ProxmoxDesktop/src-tauri/target/debug/deps/libunicode_ident-8443eb632a3fbe4c.rmeta: /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs /home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/lib.rs:
/home/user/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/unicode-ident-1.0.24/src/tables.rs:
+213
View File
@@ -0,0 +1,213 @@
//! Integration tests for the Proxmox API client core.
//!
//! These tests exercise the generic `api_request` helper against a local HTTP
//! mock server, covering auth header injection, response envelope unwrapping,
//! error mapping, query parameters, and URL construction.
use httpmock::prelude::*;
use proxmox_desktop::{api_request, AuthContext, AuthMode, Error};
use reqwest::Client;
use reqwest::Method as RMethod;
const TICKET: &str = "PVE:root@pam:abc123";
const CSRF_TOKEN: &str = "csrf-token-123";
fn token_auth() -> AuthContext {
AuthContext {
mode: AuthMode::Token,
token: Some("root@pam!test-token".to_string()),
ticket: None,
csrf_token: None,
}
}
fn password_auth() -> AuthContext {
AuthContext {
mode: AuthMode::Password,
token: None,
ticket: Some(TICKET.to_string()),
csrf_token: Some(CSRF_TOKEN.to_string()),
}
}
#[tokio::test]
async fn token_auth_sends_authorization_header_and_unwraps_envelope() {
let server = MockServer::start();
let expected = serde_json::json!({
"version": "8.1.0",
"release": "8.1",
"repoid": "abc123",
});
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/version")
.header("Authorization", "PVEAPIToken=root@pam!test-token");
then.status(200)
.header("content-type", "application/json")
.body(serde_json::json!({ "data": expected }).to_string());
});
let result = api_request(
&Client::new(),
&server.base_url(),
RMethod::GET,
"/version",
&token_auth(),
&[],
None,
)
.await
.expect("request should succeed");
assert_eq!(result, expected);
mock.assert();
}
#[tokio::test]
async fn password_auth_sends_cookie_header_on_get() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/version")
.header("Cookie", format!("PVEAuthCookie={}", TICKET));
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":{"version":"8.1.0"}}"#);
});
let result = api_request(
&Client::new(),
&server.base_url(),
RMethod::GET,
"/version",
&password_auth(),
&[],
None,
)
.await
.expect("request should succeed");
assert_eq!(result["version"], "8.1.0");
mock.assert();
}
#[tokio::test]
async fn password_auth_sends_cookie_and_csrf_headers_on_post() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve/qemu/100/status/start")
.header("Cookie", format!("PVEAuthCookie={}", TICKET))
.header("CSRFPreventionToken", CSRF_TOKEN);
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let result = api_request(
&Client::new(),
&server.base_url(),
RMethod::POST,
"/nodes/pve/qemu/100/status/start",
&password_auth(),
&[],
None,
)
.await
.expect("request should succeed");
assert_eq!(result, serde_json::Value::Null);
mock.assert();
}
#[tokio::test]
async fn server_error_message_is_mapped_to_api_error() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api2/json/version");
then.status(500)
.header("content-type", "application/json")
.body(r#"{"message":"boom"}"#);
});
let error = api_request(
&Client::new(),
&server.base_url(),
RMethod::GET,
"/version",
&token_auth(),
&[],
None,
)
.await
.expect_err("request should fail");
assert!(
matches!(error, Error::ApiError(ref message) if message == "boom"),
"expected ApiError with message 'boom', got: {}",
error
);
mock.assert();
}
#[tokio::test]
async fn query_params_are_included_in_request() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve/qemu")
.query_param("node", "pve")
.query_param("vmid", "100")
.query_param("include-config", "true");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":[]}"#);
});
let query = [
("node", "pve".to_string()),
("vmid", "100".to_string()),
("include-config", "true".to_string()),
];
let result = api_request(
&Client::new(),
&server.base_url(),
RMethod::GET,
"/nodes/pve/qemu",
&token_auth(),
&query,
None,
)
.await
.expect("request should succeed");
assert_eq!(result, serde_json::json!([]));
mock.assert();
}
#[tokio::test]
async fn base_url_trailing_slash_is_stripped() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api2/json/version");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":{"version":"8.1.0"}}"#);
});
let base_url = format!("{}/", server.base_url());
let result = api_request(
&Client::new(),
&base_url,
RMethod::GET,
"/version",
&token_auth(),
&[],
None,
)
.await
.expect("request should succeed");
assert_eq!(result["version"], "8.1.0");
mock.assert();
}
+570
View File
@@ -0,0 +1,570 @@
//! Integration tests for the real Proxmox data-read API calls and the
//! VM/container lifecycle operations.
//!
//! Every request goes through `Connection::request`, so these tests cover URL
//! construction, query parameters, auth header injection, response envelope
//! unwrapping, and mapping into the public serde structs. Token-mode
//! connections resolve the token from the config, so the read methods can be
//! called directly on an added connection without `connect()`.
use httpmock::prelude::*;
use proxmox_desktop::{ConnectionConfig, ConnectionManager, EndpointConfig, Error};
/// Builds a `ConnectionManager` with a single token-mode connection whose
/// primary endpoint points at the mock server. `node` pins the storage node.
async fn setup_manager(
url: &str,
token: &str,
node: Option<&str>,
) -> (ConnectionManager, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
let config = ConnectionConfig {
id: "conn".to_string(),
name: "conn".to_string(),
primary: EndpointConfig {
url: url.to_string(),
node: node.map(str::to_string),
token: Some(token.to_string()),
},
fallbacks: vec![],
cert_fingerprint: None,
trusted: false,
accept_untrusted: true,
status: "disconnected".to_string(),
cluster_name: None,
is_cluster: false,
auth_mode: "token".to_string(),
username: None,
nodes: vec![],
cluster_id: None,
};
manager
.add_connection(config, &path)
.await
.expect("connection should be added");
(manager, dir)
}
#[tokio::test]
async fn get_nodes_maps_cluster_resources() {
let server = MockServer::start();
let token = "root@pam!nodes-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"node": "pve1", "status": "online", "cpu": 0.12, "maxcpu": 16,
"mem": 8589934592u64, "maxmem": 68719476736u64, "disk": 214748364800u64,
"maxdisk": 858993459200u64, "uptime": 123456, "level": "",
"id": "node/pve1", "type": "node"},
{"node": "pve2", "status": "offline", "cpu": 0.0, "maxcpu": 8,
"mem": 0, "maxmem": 34359738368u64, "disk": 0,
"maxdisk": 536870912000u64, "uptime": 0, "level": "",
"id": "node/pve2", "type": "node"}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let nodes = manager
.get_nodes("conn")
.await
.expect("nodes should be fetched");
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].node, "pve1");
assert_eq!(nodes[0].status, "online");
assert!((nodes[0].cpu - 0.12).abs() < 1e-9);
assert_eq!(nodes[0].maxcpu, 16);
assert_eq!(nodes[0].mem, 8589934592u64);
assert_eq!(nodes[0].maxmem, 68719476736u64);
assert_eq!(nodes[0].disk, 214748364800u64);
assert_eq!(nodes[0].maxdisk, 858993459200u64);
assert_eq!(nodes[0].uptime, 123456);
assert_eq!(nodes[0].id, "node/pve1");
assert_eq!(nodes[0].r#type, "node");
assert_eq!(nodes[1].node, "pve2");
assert_eq!(nodes[1].status, "offline");
mock.assert();
}
#[tokio::test]
async fn get_vms_uses_cluster_resources() {
let server = MockServer::start();
let token = "root@pam!vms-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/cluster/resources")
.query_param("type", "vm")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"vmid": 100, "name": "web01", "type": "qemu", "status": "running",
"node": "pve1", "cpu": 0.03, "cpus": 2, "maxcpu": 2,
"mem": 2147483648u64, "maxmem": 4294967296u64, "disk": 107374182400u64,
"maxdisk": 34359738368u64, "uptime": 99999, "netin": 123, "netout": 456,
"diskread": 789, "diskwrite": 101112, "template": 0, "tags": "prod",
"pid": 1234},
// A stopped container: the API omits runtime stats.
{"vmid": 201, "name": "ct01", "type": "lxc", "status": "stopped",
"node": "pve2", "template": 1, "tags": ""}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let vms = manager
.get_vms("conn")
.await
.expect("VMs should be fetched");
assert_eq!(vms.len(), 2);
assert_eq!(vms[0].vmid, 100);
assert_eq!(vms[0].name.as_deref(), Some("web01"));
assert_eq!(vms[0].r#type, "qemu");
assert_eq!(vms[0].status, "running");
assert_eq!(vms[0].node, "pve1");
assert_eq!(vms[0].uptime, 99999);
assert_eq!(vms[0].netin, 123);
assert_eq!(vms[0].netout, 456);
assert_eq!(vms[0].diskread, 789);
assert_eq!(vms[0].diskwrite, 101112);
assert_eq!(vms[0].tags.as_deref(), Some("prod"));
assert_eq!(vms[0].pid, Some(1234));
// Tolerance: fields omitted for the stopped LXC default instead of failing.
assert_eq!(vms[1].r#type, "lxc");
assert_eq!(vms[1].status, "stopped");
assert_eq!(vms[1].uptime, 0);
assert_eq!(vms[1].cpu, 0.0);
assert_eq!(vms[1].netin, 0);
assert_eq!(vms[1].pid, None);
mock.assert();
}
#[tokio::test]
async fn get_storage_maps_cluster_resources() {
let server = MockServer::start();
let token = "root@pam!storage-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/cluster/resources")
.query_param("type", "storage");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"storage": "local", "node": "pve1", "type": "dir",
"content": "iso,vztmpl", "enabled": 1, "shared": 0, "active": 1,
"total": 858993459200u64, "used": 429496729600u64, "avail": 429496729600u64,
"status": "available"},
{"storage": "backup", "node": "pve1", "type": "nfs",
"content": "backup", "enabled": 1, "shared": 1, "active": 1,
"total": 1717986918400u64, "used": 644245094400u64,
"avail": 1073741824000u64, "status": "available"}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let storages = manager
.get_storage("conn")
.await
.expect("storage should be fetched");
assert_eq!(storages.len(), 2);
assert_eq!(storages[0].storage, "local");
assert_eq!(storages[0].r#type, "dir");
assert_eq!(storages[0].content, "iso,vztmpl");
assert_eq!(storages[0].active, 1);
assert_eq!(storages[0].enabled, 1);
assert_eq!(storages[0].shared, 0);
assert_eq!(storages[0].used, 429496729600u64);
assert_eq!(storages[0].total, 858993459200u64);
assert_eq!(storages[0].avail, 429496729600u64);
assert_eq!(storages[0].node, "pve1");
assert_eq!(storages[1].shared, 1);
mock.assert();
}
#[tokio::test]
async fn get_storage_content_uses_configured_node() {
let server = MockServer::start();
let token = "root@pam!content-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/storage/local/content");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"volid": "local:iso/debian-12.iso", "content": "iso",
"ctime": 1700000000, "format": "iso", "size": 68719476736u64},
{"volid": "local:vztmpl/ubuntu-22.tar.xz", "content": "vztmpl",
"ctime": 1700000001}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
let contents = manager
.get_storage_content("conn", "local")
.await
.expect("content should be fetched");
assert_eq!(contents.len(), 2);
assert_eq!(contents[0].volid, "local:iso/debian-12.iso");
assert_eq!(contents[0].content, "iso");
assert_eq!(contents[0].format.as_deref(), Some("iso"));
assert_eq!(contents[0].size, Some(68719476736u64));
// Tolerance: optional metadata is omitted for the second entry.
assert_eq!(contents[1].format, None);
assert_eq!(contents[1].subtype, None);
mock.assert();
}
#[tokio::test]
async fn get_storage_content_falls_back_to_online_node() {
let server = MockServer::start();
let token = "root@pam!fallback-token";
let nodes_mock = server.mock(|when, then| {
when.method(GET).path("/api2/json/nodes");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"node": "pve1", "status": "online", "cpu": 0.0, "maxcpu": 8,
"mem": 0, "maxmem": 34359738368u64, "disk": 0,
"maxdisk": 536870912000u64, "uptime": 0, "level": "",
"id": "node/pve1", "type": "node"},
{"node": "pve2", "status": "offline", "cpu": 0.0, "maxcpu": 8,
"mem": 0, "maxmem": 34359738368u64, "disk": 0,
"maxdisk": 536870912000u64, "uptime": 0, "level": "",
"id": "node/pve2", "type": "node"}
]
})
.to_string(),
);
});
let content_mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/storage/local/content");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [{"volid": "local:iso/debian.iso", "content": "iso",
"ctime": 1700000000}]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let contents = manager
.get_storage_content("conn", "local")
.await
.expect("content should be fetched");
assert_eq!(contents.len(), 1);
assert_eq!(contents[0].volid, "local:iso/debian.iso");
nodes_mock.assert();
content_mock.assert();
}
#[tokio::test]
async fn get_storage_detail_maps_status() {
let server = MockServer::start();
let token = "root@pam!detail-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/storage/local/status");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {"storage": "local", "type": "dir", "content": "iso,vztmpl",
"active": 1, "enabled": 1, "shared": 0, "used": 429496729600u64,
"total": 858993459200u64, "avail": 429496729600u64, "node": "pve1"}
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let detail = manager
.get_storage_detail("conn", "pve1", "local")
.await
.expect("detail should be fetched");
assert_eq!(detail.storage, "local");
assert_eq!(detail.r#type, "dir");
assert_eq!(detail.content, "iso,vztmpl");
assert_eq!(detail.active, 1);
assert_eq!(detail.enabled, 1);
assert_eq!(detail.used, 429496729600u64);
assert_eq!(detail.total, 858993459200u64);
assert_eq!(detail.avail, 429496729600u64);
assert_eq!(detail.node, "pve1");
mock.assert();
}
#[tokio::test]
async fn get_tasks_maps_cluster_tasks() {
let server = MockServer::start();
let token = "root@pam!tasks-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/cluster/tasks")
.query_param("limit", "50");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"upid": "UPID:pve1:00000001:00000001:5F000000:qmpstart:100:root@pam:",
"node": "pve1", "pid": 1000, "pstart": 100, "starttime": 1700000000,
"type": "qmpstart", "id": "100", "user": "root@pam",
"status": "stopped", "endtime": 1700000100, "exitstatus": "OK"},
{"upid": "UPID:pve2:00000002:00000002:5F000001:vzstart:201:root@pam:",
"node": "pve2", "pid": 2000, "pstart": 200, "starttime": 1700000001,
"type": "vzstart", "id": "201", "user": "root@pam"}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let tasks = manager
.get_tasks("conn")
.await
.expect("tasks should be fetched");
assert_eq!(tasks.len(), 2);
assert_eq!(
tasks[0].upid,
"UPID:pve1:00000001:00000001:5F000000:qmpstart:100:root@pam:"
);
assert_eq!(tasks[0].node, "pve1");
assert_eq!(tasks[0].pid, 1000);
assert_eq!(tasks[0].pstart, 100);
assert_eq!(tasks[0].starttime, 1700000000);
assert_eq!(tasks[0].r#type, "qmpstart");
assert_eq!(tasks[0].id, "100");
assert_eq!(tasks[0].user, "root@pam");
assert_eq!(tasks[0].endtime, Some(1700000100));
assert_eq!(tasks[0].status.as_deref(), Some("stopped"));
assert_eq!(tasks[0].exitstatus.as_deref(), Some("OK"));
// Tolerance: the second task has not finished yet.
assert_eq!(tasks[1].endtime, None);
assert_eq!(tasks[1].status, None);
assert_eq!(tasks[1].exitstatus, None);
mock.assert();
}
#[tokio::test]
async fn get_cluster_status_builds_cluster() {
let server = MockServer::start();
let token = "root@pam!cluster-token";
let mock = server.mock(|when, then| {
when.method(GET).path("/api2/json/cluster/status");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"type": "cluster", "id": "cluster", "name": "proxmox-cluster",
"nodes": 2},
{"type": "node", "id": "node/pve1", "nodeid": 1, "online": 1,
"local": 1, "ip": "10.0.0.1"},
{"type": "node", "id": "node/pve2", "nodeid": 2, "online": 0,
"ip": "10.0.0.2"}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let status = manager
.get_cluster_status("conn")
.await
.expect("cluster status should be fetched");
assert_eq!(status.r#type, "cluster");
assert_eq!(status.name, "proxmox-cluster");
assert_eq!(status.id, "cluster");
let nodes = status.nodes.expect("nodes should be present");
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].name, "pve1");
assert_eq!(nodes[0].nodeid, 1);
assert_eq!(nodes[0].online, 1);
assert_eq!(nodes[0].local, Some(1));
assert_eq!(nodes[0].ip.as_deref(), Some("10.0.0.1"));
assert_eq!(nodes[1].name, "pve2");
assert_eq!(nodes[1].online, 0);
assert_eq!(nodes[1].local, None);
mock.assert();
}
#[tokio::test]
async fn get_cluster_status_falls_back_to_default_name() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api2/json/cluster/status");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [{"type": "node", "id": "node/pve1", "nodeid": 1, "online": 1}]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!default-token", None).await;
let status = manager
.get_cluster_status("conn")
.await
.expect("status should be fetched");
assert_eq!(status.name, "default");
assert_eq!(status.id, "");
let nodes = status.nodes.expect("nodes should be present");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].name, "pve1");
mock.assert();
}
#[tokio::test]
async fn lifecycle_uses_qemu_or_lxc_path() {
let server = MockServer::start();
let token = "root@pam!lifecycle-token";
let start_qemu = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/status/start")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let start_lxc = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/lxc/201/status/start");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let stop_qemu = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/status/stop");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.start_vm("conn", "pve1", 100, "qemu")
.await
.expect("qemu start should succeed");
manager
.start_vm("conn", "pve1", 201, "lxc")
.await
.expect("lxc start should succeed");
manager
.stop_vm("conn", "pve1", 100, "qemu")
.await
.expect("qemu stop should succeed");
start_qemu.assert();
start_lxc.assert();
stop_qemu.assert();
}
#[tokio::test]
async fn lifecycle_with_invalid_type_errors() {
let server = MockServer::start();
let probe = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/kvm/100/status/start");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!bad-type-token", None).await;
let error = manager
.start_vm("conn", "pve1", 100, "kvm")
.await
.expect_err("invalid vm type must be rejected");
assert!(
matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")),
"expected InvalidUrl mentioning 'kvm', got: {}",
error
);
assert_eq!(
probe.hits(),
0,
"no HTTP request should be made for an invalid vm type"
);
}
#[tokio::test]
async fn read_methods_error_with_connection_not_found() {
let server = MockServer::start();
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!missing-token", None).await;
let error = manager
.get_nodes("does-not-exist")
.await
.expect_err("unknown connection must error");
assert!(
matches!(error, Error::ConnectionNotFound(ref id) if id == "does-not-exist"),
"expected ConnectionNotFound, got: {}",
error
);
let error = manager
.get_vms("does-not-exist")
.await
.expect_err("unknown connection must error");
assert!(
matches!(error, Error::ConnectionNotFound(ref id) if id == "does-not-exist"),
"expected ConnectionNotFound, got: {}",
error
);
let error = manager
.start_vm("does-not-exist", "pve1", 100, "qemu")
.await
.expect_err("unknown connection must error");
assert!(
matches!(error, Error::ConnectionNotFound(ref id) if id == "does-not-exist"),
"expected ConnectionNotFound, got: {}",
error
);
}
+168
View File
@@ -0,0 +1,168 @@
//! Integration tests for the console proxy API calls (VNC + terminal) and
//! the WebSocket base URL derivation.
//!
//! The proxy calls go through `Connection::request`, so these tests cover URL
//! construction, auth header injection, response envelope unwrapping, and
//! mapping into the public serde structs. `get_websocket_url` only rewrites
//! the stored endpoint URL, so it never hits the network. Token-mode
//! connections resolve the token from the config, so the methods can be called
//! directly on an added connection without `connect()`.
use httpmock::prelude::*;
use proxmox_desktop::{ConnectionConfig, ConnectionManager, EndpointConfig, Error};
/// Builds a `ConnectionManager` with a single token-mode connection whose
/// primary endpoint points at `url`.
async fn setup_manager(url: &str, token: &str) -> (ConnectionManager, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
let config = ConnectionConfig {
id: "conn".to_string(),
name: "conn".to_string(),
primary: EndpointConfig {
url: url.to_string(),
node: None,
token: Some(token.to_string()),
},
fallbacks: vec![],
cert_fingerprint: None,
trusted: false,
accept_untrusted: true,
status: "disconnected".to_string(),
cluster_name: None,
is_cluster: false,
auth_mode: "token".to_string(),
username: None,
nodes: vec![],
cluster_id: None,
};
manager
.add_connection(config, &path)
.await
.expect("connection should be added");
(manager, dir)
}
#[tokio::test]
async fn create_vnc_proxy_maps_response() {
let server = MockServer::start();
let token = "root@pam!vnc-token";
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/vncproxy")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {
"ticket": "PVE:vnc:abc",
"port": 6000,
"cert": "MIIB..."
}
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token).await;
let proxy = manager
.create_vnc_proxy("conn", "pve1", 100)
.await
.expect("vnc proxy should be created");
assert_eq!(proxy.ticket, "PVE:vnc:abc");
assert_eq!(proxy.port, 6000);
assert_eq!(proxy.cert, "MIIB...");
mock.assert();
}
#[tokio::test]
async fn create_term_proxy_maps_response() {
let server = MockServer::start();
let token = "root@pam!term-token";
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/lxc/201/termproxy")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {
"ticket": "PVE:term:xyz",
"port": 6100
}
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token).await;
let proxy = manager
.create_term_proxy("conn", "pve1", 201)
.await
.expect("term proxy should be created");
assert_eq!(proxy.ticket, "PVE:term:xyz");
assert_eq!(proxy.port, 6100);
mock.assert();
}
#[tokio::test]
async fn get_websocket_url_converts_https_to_wss() {
// Explicit port on https.
let (manager, _dir) = setup_manager("https://192.168.1.10:8006", "root@pam!ws-token").await;
let url = manager
.get_websocket_url("conn", "pve1")
.await
.expect("websocket url should be derived");
assert_eq!(url, "wss://192.168.1.10:8006");
// No explicit port: the default wss port applies, so none is emitted.
let (manager, _dir) = setup_manager("https://pve.lan", "root@pam!ws-token").await;
let url = manager
.get_websocket_url("conn", "pve1")
.await
.expect("websocket url should be derived");
assert_eq!(url, "wss://pve.lan");
// Plain http maps to ws.
let (manager, _dir) = setup_manager("http://10.0.0.5:8006", "root@pam!ws-token").await;
let url = manager
.get_websocket_url("conn", "pve1")
.await
.expect("websocket url should be derived");
assert_eq!(url, "ws://10.0.0.5:8006");
}
#[tokio::test]
async fn get_websocket_url_errors_on_invalid_scheme() {
let (manager, _dir) = setup_manager("ftp://host", "root@pam!ws-token").await;
let error = manager
.get_websocket_url("conn", "pve1")
.await
.expect_err("non-http(s) scheme must be rejected");
assert!(
matches!(error, Error::InvalidUrl(ref message) if message.contains("ftp")),
"expected InvalidUrl mentioning 'ftp', got: {}",
error
);
}
#[tokio::test]
async fn missing_connection_errors() {
let server = MockServer::start();
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!missing-token").await;
let error = manager
.create_vnc_proxy("does-not-exist", "pve1", 100)
.await
.expect_err("unknown connection must error");
assert!(
matches!(error, Error::ConnectionNotFound(ref id) if id == "does-not-exist"),
"expected ConnectionNotFound, got: {}",
error
);
}
+465
View File
@@ -0,0 +1,465 @@
//! Integration tests for VM/container disk and network interface management.
//!
//! Every request goes through `Connection::request`, so these tests cover URL
//! construction (including the `qemu`/`lxc` path segment), form encoding,
//! config parsing, and slot allocation. Token-mode connections resolve the
//! token from the config, so the methods can be called directly on an added
//! connection without `connect()`.
//!
//! The form bodies are `application/x-www-form-urlencoded`, so values with
//! special characters (`:`, `=`, `,`) appear percent-encoded in the raw body;
//! `body_includes` assertions use the encoded form (e.g. `:` becomes `%3A`).
use httpmock::prelude::*;
use proxmox_desktop::{
AddDiskConfig, AddNICConfig, ConnectionConfig, ConnectionManager, EditNICConfig,
EndpointConfig, Error,
};
/// Builds a `ConnectionManager` with a single token-mode connection whose
/// primary endpoint points at the mock server.
async fn setup_manager(
url: &str,
token: &str,
node: Option<&str>,
) -> (ConnectionManager, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
let config = ConnectionConfig {
id: "conn".to_string(),
name: "conn".to_string(),
primary: EndpointConfig {
url: url.to_string(),
node: node.map(str::to_string),
token: Some(token.to_string()),
},
fallbacks: vec![],
cert_fingerprint: None,
trusted: false,
accept_untrusted: true,
status: "disconnected".to_string(),
cluster_name: None,
is_cluster: false,
auth_mode: "token".to_string(),
username: None,
nodes: vec![],
cluster_id: None,
};
manager
.add_connection(config, &path)
.await
.expect("connection should be added");
(manager, dir)
}
#[tokio::test]
async fn get_disks_parses_config_strings() {
let server = MockServer::start();
let token = "root@pam!disks-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/qemu/100/config")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {
"scsi0": "local-lvm:vm-100-disk-0,size=32G,format=qcow2",
"scsi1": "local-lvm:vm-100-disk-1,size=50G",
"net0": "virtio=BC:24:11:AA:BB:CC,bridge=vmbr0",
"memory": 2048
}
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let disks = manager
.get_disks("conn", "pve1", 100, "qemu")
.await
.expect("disks should be fetched");
assert_eq!(disks.len(), 2);
assert_eq!(disks[0].device, "scsi0");
assert_eq!(disks[0].storage, "local-lvm");
assert_eq!(disks[0].size, 34359738368);
assert_eq!(disks[0].format, "qcow2");
assert_eq!(disks[0].usage, None);
// The second disk has no size/format attributes: size is 0, format empty.
assert_eq!(disks[1].device, "scsi1");
assert_eq!(disks[1].storage, "local-lvm");
assert_eq!(disks[1].size, 53687091200);
assert_eq!(disks[1].format, "");
mock.assert();
}
#[tokio::test]
async fn add_disk_picks_free_slot_and_posts() {
let server = MockServer::start();
let token = "root@pam!add-disk-token";
let get_mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/qemu/100/config");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {"scsi0": "local-lvm:vm-100-disk-0,size=32G"}
})
.to_string(),
);
});
let post_mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/config")
// urlencoded: the `:` in `local-lvm:64G` becomes `%3A`.
.body_includes("scsi1=local-lvm%3A64G");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let config = AddDiskConfig {
storage: "local-lvm".to_string(),
size: 68719476736,
bus_type: "scsi".to_string(),
};
manager
.add_disk("conn", "pve1", 100, "qemu", config)
.await
.expect("disk should be added");
get_mock.assert();
post_mock.assert();
}
#[tokio::test]
async fn resize_disk_posts_resize() {
let server = MockServer::start();
let token = "root@pam!resize-token";
let mock = server.mock(|when, then| {
when.method(PUT)
.path("/api2/json/nodes/pve1/qemu/100/resize")
.body_includes("disk=scsi0")
.body_includes("size=50G");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.resize_disk("conn", "pve1", 100, "qemu", "scsi0", 53687091200)
.await
.expect("resize should succeed");
mock.assert();
}
#[tokio::test]
async fn remove_disk_posts_delete() {
let server = MockServer::start();
let token = "root@pam!remove-disk-token";
let mock = server.mock(|when, then| {
when.method(DELETE)
.path("/api2/json/nodes/pve1/qemu/100/config")
.body_includes("delete=scsi0");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.remove_disk("conn", "pve1", 100, "qemu", "scsi0")
.await
.expect("disk should be removed");
mock.assert();
}
#[tokio::test]
async fn move_disk_posts_move_disk() {
let server = MockServer::start();
let token = "root@pam!move-disk-token";
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/move_disk")
.body_includes("disk=scsi0")
.body_includes("storage=local-zfs");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.move_disk("conn", "pve1", 100, "qemu", "scsi0", "local-zfs")
.await
.expect("disk should be moved");
mock.assert();
}
#[tokio::test]
async fn get_network_interfaces_parses_nets() {
let server = MockServer::start();
let token = "root@pam!nets-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/qemu/100/config");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {
"net0": "virtio=BC:24:11:AA:BB:CC,bridge=vmbr0,tag=10,firewall=1,link_down=1",
"net1": "e1000=11:22:33:44:55:66,bridge=vmbr1"
}
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let nics = manager
.get_network_interfaces("conn", "pve1", 100, "qemu")
.await
.expect("nics should be fetched");
assert_eq!(nics.len(), 2);
assert_eq!(nics[0].name, "net0");
assert_eq!(nics[0].model, "virtio");
assert_eq!(nics[0].macaddr, "BC:24:11:AA:BB:CC");
assert_eq!(nics[0].bridge.as_deref(), Some("vmbr0"));
assert_eq!(nics[0].tag, Some(10));
assert_eq!(nics[0].firewall, Some(1));
assert_eq!(nics[0].link_down, Some(1));
// The second NIC has no optional attributes.
assert_eq!(nics[1].name, "net1");
assert_eq!(nics[1].model, "e1000");
assert_eq!(nics[1].macaddr, "11:22:33:44:55:66");
assert_eq!(nics[1].bridge.as_deref(), Some("vmbr1"));
assert_eq!(nics[1].tag, None);
assert_eq!(nics[1].firewall, None);
assert_eq!(nics[1].link_down, None);
mock.assert();
}
#[tokio::test]
async fn add_nic_posts_form() {
let server = MockServer::start();
let token = "root@pam!add-nic-token";
let get_mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/qemu/100/config");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {"memory": 2048}
})
.to_string(),
);
});
let post_mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/config")
// urlencoded: `=` and `,` become `%3D` and `%2C`.
.body_includes("net0=virtio%3Drandom%2Cbridge%3Dvmbr0");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let config = AddNICConfig {
bridge: "vmbr0".to_string(),
model: "virtio".to_string(),
macaddr: None,
tag: None,
firewall: None,
};
manager
.add_nic("conn", "pve1", 100, "qemu", config)
.await
.expect("nic should be added");
get_mock.assert();
post_mock.assert();
}
#[tokio::test]
async fn edit_nic_preserves_model_and_mac() {
let server = MockServer::start();
let token = "root@pam!edit-nic-token";
let get_mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/qemu/100/config");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {"net0": "virtio=MAC,bridge=vmbr0"}
})
.to_string(),
);
});
let post_mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/config")
.body_includes("net0=virtio%3DMAC%2Cbridge%3Dvmbr1");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let config = EditNICConfig {
bridge: Some("vmbr1".to_string()),
model: None,
tag: None,
firewall: None,
};
manager
.edit_nic("conn", "pve1", 100, "qemu", "net0", config)
.await
.expect("nic should be edited");
get_mock.assert();
post_mock.assert();
}
#[tokio::test]
async fn remove_nic_posts_delete() {
let server = MockServer::start();
let token = "root@pam!remove-nic-token";
let mock = server.mock(|when, then| {
when.method(DELETE)
.path("/api2/json/nodes/pve1/qemu/100/config")
.body_includes("delete=net0");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.remove_nic("conn", "pve1", 100, "qemu", "net0")
.await
.expect("nic should be removed");
mock.assert();
}
#[tokio::test]
async fn lxc_vm_types_use_lxc_path() {
let server = MockServer::start();
let token = "root@pam!lxc-token";
let config_mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/lxc/201/config");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": {
"rootfs": "local:vm-201-disk-0,size=8G",
"mp0": "local:vm-201-mp-0,size=4G",
"net0": "name=eth0,bridge=vmbr0,ip=dhcp"
}
})
.to_string(),
);
});
let resize_mock = server.mock(|when, then| {
when.method(PUT)
.path("/api2/json/nodes/pve1/lxc/201/resize")
.body_includes("disk=rootfs")
.body_includes("size=16G");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
// Containers use `rootfs`/`mpN` keys rather than `scsiN`/`virtioN`; they
// are not returned by get_disks, and network keys without an explicit
// model are still parsed.
let disks = manager
.get_disks("conn", "pve1", 201, "lxc")
.await
.expect("disks should be fetched");
assert!(
disks.is_empty(),
"container disks are not in the scsi/virtio/... key set"
);
let nics = manager
.get_network_interfaces("conn", "pve1", 201, "lxc")
.await
.expect("nics should be fetched");
assert_eq!(nics.len(), 1);
assert_eq!(nics[0].name, "net0");
assert_eq!(nics[0].bridge.as_deref(), Some("vmbr0"));
manager
.resize_disk("conn", "pve1", 201, "lxc", "rootfs", 17179869184)
.await
.expect("resize should succeed");
config_mock.assert_calls(2);
resize_mock.assert();
}
#[tokio::test]
async fn invalid_vm_type_errors() {
let server = MockServer::start();
let probe = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/kvm/100/config");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":{}}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!bad-type-token", None).await;
let error = manager
.get_disks("conn", "pve1", 100, "kvm")
.await
.expect_err("invalid vm type must be rejected");
assert!(
matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")),
"expected InvalidUrl mentioning 'kvm', got: {}",
error
);
let config = AddNICConfig {
bridge: "vmbr0".to_string(),
model: "virtio".to_string(),
macaddr: None,
tag: None,
firewall: None,
};
let error = manager
.add_nic("conn", "pve1", 100, "kvm", config)
.await
.expect_err("invalid vm type must be rejected");
assert!(
matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")),
"expected InvalidUrl mentioning 'kvm', got: {}",
error
);
assert_eq!(
probe.calls(),
0,
"no HTTP request should be made for an invalid vm type"
);
}
+448
View File
@@ -0,0 +1,448 @@
//! Integration tests for the real snapshot, migration, and backup API calls.
//!
//! Every request goes through `Connection::request`, so these tests cover URL
//! construction, form encoding, auth header injection, response envelope
//! unwrapping, and mapping into the public serde structs. Token-mode
//! connections resolve the token from the config, so the methods can be called
//! directly on an added connection without `connect()`.
use httpmock::prelude::*;
use proxmox_desktop::{
BackupJobConfig, ConnectionConfig, ConnectionManager, CreateSnapshotConfig, EndpointConfig,
Error,
};
/// Builds a `ConnectionManager` with a single token-mode connection whose
/// primary endpoint points at the mock server. `node` pins the storage node.
async fn setup_manager(
url: &str,
token: &str,
node: Option<&str>,
) -> (ConnectionManager, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
let config = ConnectionConfig {
id: "conn".to_string(),
name: "conn".to_string(),
primary: EndpointConfig {
url: url.to_string(),
node: node.map(str::to_string),
token: Some(token.to_string()),
},
fallbacks: vec![],
cert_fingerprint: None,
trusted: false,
accept_untrusted: true,
status: "disconnected".to_string(),
cluster_name: None,
is_cluster: false,
auth_mode: "token".to_string(),
username: None,
nodes: vec![],
cluster_id: None,
};
manager
.add_connection(config, &path)
.await
.expect("connection should be added");
(manager, dir)
}
#[tokio::test]
async fn get_snapshots_maps_list() {
let server = MockServer::start();
let token = "root@pam!snap-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/qemu/100/snapshot")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"name": "snap1", "description": "before upgrade",
"snaptime": 1700000000, "vmstate": 1, "parent": "current"},
{"name": "snap2", "snaptime": 1700000100, "vmstate": 0}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let snapshots = manager
.get_snapshots("conn", "pve1", 100, "qemu")
.await
.expect("snapshots should be fetched");
assert_eq!(snapshots.len(), 2);
assert_eq!(snapshots[0].name, "snap1");
assert_eq!(snapshots[0].description, "before upgrade");
assert_eq!(snapshots[0].snaptime, 1700000000);
assert_eq!(snapshots[0].vmstate, 1);
assert_eq!(snapshots[0].parent.as_deref(), Some("current"));
// Tolerance: the second snapshot omits optional fields.
assert_eq!(snapshots[1].name, "snap2");
assert_eq!(snapshots[1].description, "");
assert_eq!(snapshots[1].parent, None);
mock.assert();
}
#[tokio::test]
async fn create_snapshot_posts_form() {
let server = MockServer::start();
let token = "root@pam!snap-create-token";
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/snapshot")
.body_includes("snapname=snap1")
.body_includes("vmstate=1");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.create_snapshot(
"conn",
"pve1",
100,
"qemu",
CreateSnapshotConfig {
name: "snap1".to_string(),
description: Some("before upgrade".to_string()),
vmstate: Some(true),
},
)
.await
.expect("snapshot should be created");
mock.assert();
}
#[tokio::test]
async fn delete_snapshot_deletes_path() {
let server = MockServer::start();
let token = "root@pam!snap-delete-token";
let mock = server.mock(|when, then| {
when.method(DELETE)
.path("/api2/json/nodes/pve1/qemu/100/snapshot/snap1");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.delete_snapshot("conn", "pve1", 100, "qemu", "snap1")
.await
.expect("snapshot should be deleted");
mock.assert();
}
#[tokio::test]
async fn rollback_snapshot_posts_rollback() {
let server = MockServer::start();
let token = "root@pam!snap-rollback-token";
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/snapshot/snap1/rollback");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.rollback_snapshot("conn", "pve1", 100, "qemu", "snap1")
.await
.expect("rollback should succeed");
mock.assert();
}
#[tokio::test]
async fn migrate_vm_posts_migrate() {
let server = MockServer::start();
let token = "root@pam!migrate-token";
let online_mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/migrate")
.body_includes("target=pve2")
.body_includes("online=1");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let offline_mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/nodes/pve1/qemu/100/migrate")
.body_includes("target=pve2")
.body_includes("online=0");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.migrate_vm("conn", "pve1", 100, "qemu", "pve2", true)
.await
.expect("online migration should succeed");
manager
.migrate_vm("conn", "pve1", 100, "qemu", "pve2", false)
.await
.expect("offline migration should succeed");
online_mock.assert();
offline_mock.assert();
}
#[tokio::test]
async fn get_backup_jobs_maps_list() {
let server = MockServer::start();
let token = "root@pam!jobs-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/cluster/backup")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"id": "backup-1", "store": "backup", "schedule": "0 2 * * *",
"all": 1, "enabled": 1, "node": "pve1", "compress": "zstd",
"mode": "snapshot", "quiet": 0},
{"id": "backup-2", "store": "local", "schedule": "30 3 * * 1",
"all": 0, "enabled": 0, "vmid": "100,101"}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
let jobs = manager
.get_backup_jobs("conn")
.await
.expect("backup jobs should be fetched");
assert_eq!(jobs.len(), 2);
assert_eq!(jobs[0].id, "backup-1");
assert_eq!(jobs[0].store, "backup");
assert_eq!(jobs[0].schedule, "0 2 * * *");
assert_eq!(jobs[0].all, 1);
assert_eq!(jobs[0].enabled, 1);
assert_eq!(jobs[0].node.as_deref(), Some("pve1"));
assert_eq!(jobs[0].compress.as_deref(), Some("zstd"));
assert_eq!(jobs[0].mode.as_deref(), Some("snapshot"));
assert_eq!(jobs[0].quiet, Some(0));
// Tolerance: the second job omits the optional node/mode fields.
assert_eq!(jobs[1].node, None);
assert_eq!(jobs[1].compress, None);
assert_eq!(jobs[1].mode, None);
assert_eq!(jobs[1].quiet, None);
assert_eq!(jobs[1].vmid.as_deref(), Some("100,101"));
assert_eq!(jobs[1].all, 0);
mock.assert();
}
#[tokio::test]
async fn create_backup_job_posts_form() {
let server = MockServer::start();
let token = "root@pam!job-create-token";
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/cluster/backup")
.body_includes("schedule=0+2+*+*+*")
.body_includes("storage=backup")
.body_includes("mode=snapshot")
.body_includes("compress=zstd")
.body_includes("all=1")
.body_includes("enabled=1")
.body_includes("vmid=100%2C101")
.body_includes("node=pve1");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.create_backup_job(
"conn",
BackupJobConfig {
id: None,
storage: "backup".to_string(),
schedule: "0 2 * * *".to_string(),
mode: "snapshot".to_string(),
compression: "zstd".to_string(),
all: true,
vmid: Some("100,101".to_string()),
enabled: true,
node: Some("pve1".to_string()),
},
)
.await
.expect("backup job should be created");
mock.assert();
}
#[tokio::test]
async fn delete_backup_job_deletes_path() {
let server = MockServer::start();
let token = "root@pam!job-delete-token";
let mock = server.mock(|when, then| {
when.method(DELETE).path("/api2/json/cluster/backup/job-id");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
manager
.delete_backup_job("conn", "job-id")
.await
.expect("backup job should be deleted");
mock.assert();
}
#[tokio::test]
async fn get_backups_filters_and_maps() {
let server = MockServer::start();
let token = "root@pam!backups-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/storage/local/content")
.query_param("content", "backup");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{"volid": "local:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
"backupid": "vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
"backup-type": "qemu", "backup-id": "100",
"backup-time": 1700000000, "storage": "local",
"size": 1073741824u64, "ctime": 1700000001, "content": "backup"},
{"volid": "local:iso/debian-12.iso", "content": "iso",
"ctime": 1700000002},
{"volid": "local:vztmpl/ubuntu-22.tar.xz", "content": "vztmpl",
"ctime": 1700000003}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
let backups = manager
.get_backups("conn", Some("local"))
.await
.expect("backups should be fetched");
assert_eq!(backups.len(), 1);
assert_eq!(
backups[0].volid,
"local:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
);
assert_eq!(
backups[0].backupid,
"vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
);
assert_eq!(backups[0].backup_type, "qemu");
assert_eq!(backups[0].backup_id, "100");
assert_eq!(backups[0].backup_time, 1700000000);
assert_eq!(backups[0].storage, "local");
assert_eq!(backups[0].size, 1073741824u64);
assert_eq!(backups[0].ctime, 1700000001);
mock.assert();
}
#[tokio::test]
async fn get_backups_defaults_to_local_storage() {
let server = MockServer::start();
let token = "root@pam!backups-default-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/storage/local/content")
.query_param("content", "backup");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":[]}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
let backups = manager
.get_backups("conn", None)
.await
.expect("backups should be fetched");
assert!(backups.is_empty());
mock.assert();
}
#[tokio::test]
async fn delete_backup_url_encodes_volid() {
let server = MockServer::start();
let token = "root@pam!backup-delete-token";
let mock = server.mock(|when, then| {
when.method(DELETE).path(
"/api2/json/nodes/pve1/storage/local/content/local%3Abackup%2Fvzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
);
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
manager
.delete_backup(
"conn",
"local:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
)
.await
.expect("backup should be deleted");
mock.assert();
}
#[tokio::test]
async fn invalid_vm_type_errors() {
let server = MockServer::start();
let probe = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/nodes/pve1/kvm/100/snapshot");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":[]}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!bad-type-token", None).await;
let error = manager
.get_snapshots("conn", "pve1", 100, "kvm")
.await
.expect_err("invalid vm type must be rejected");
assert!(
matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")),
"expected InvalidUrl mentioning 'kvm', got: {}",
error
);
let error = manager
.migrate_vm("conn", "pve1", 100, "kvm", "pve2", true)
.await
.expect_err("invalid vm type must be rejected");
assert!(
matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")),
"expected InvalidUrl mentioning 'kvm', got: {}",
error
);
assert_eq!(
probe.calls(),
0,
"no HTTP request should be made for an invalid vm type"
);
}
+467
View File
@@ -0,0 +1,467 @@
//! Integration tests for the connect-time node discovery, same-cluster
//! merging, and the connection status reporting flow.
//!
//! `connect` now authenticates, resets failover state, and discovers the
//! cluster's nodes before either folding the connection into an existing
//! same-cluster connection or marking it connected. These tests cover the
//! discovered-node persistence, the merge of a second node's connection into
//! the first, the status reporting (including failover and disconnected), and
//! the all-endpoints-down case where the connect reports `"failed"` instead of
//! erroring.
use httpmock::prelude::*;
use httpmock::Mock;
use proxmox_desktop::{ConnectionConfig, ConnectionManager, EndpointConfig, Error};
const TOKEN: &str = "root@pam!cluster-token";
/// A minimal `/nodes` entry. The `Node` struct requires every field, so all
/// are included.
fn node_json(name: &str, status: &str) -> serde_json::Value {
serde_json::json!({
"node": name,
"status": status,
"cpu": 0.0,
"maxcpu": 8,
"mem": 0,
"maxmem": 34359738368u64,
"disk": 0,
"maxdisk": 536870912000u64,
"uptime": 0,
"level": "",
"id": format!("node/{}", name),
"type": "node"
})
}
/// Builds the `/cluster/status` response body: one `cluster` entry followed by
/// the given node entries.
fn cluster_status_body(
cluster_id: &str,
cluster_name: &str,
nodes: &[serde_json::Value],
) -> String {
let mut entries = Vec::with_capacity(nodes.len() + 1);
entries.push(serde_json::json!({
"type": "cluster",
"id": cluster_id,
"name": cluster_name,
"nodes": nodes.len()
}));
entries.extend_from_slice(nodes);
serde_json::json!({ "data": entries }).to_string()
}
fn stub_version<'a>(server: &'a MockServer) -> Mock<'a> {
server.mock(|when, then| {
when.method(GET).path("/api2/json/version");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":{"version":"8.2"}}"#);
})
}
fn stub_nodes<'a>(server: &'a MockServer, nodes: &[serde_json::Value]) -> Mock<'a> {
server.mock(|when, then| {
when.method(GET).path("/api2/json/nodes");
then.status(200)
.header("content-type", "application/json")
.body(serde_json::json!({ "data": nodes }).to_string());
})
}
fn stub_cluster_status<'a>(
server: &'a MockServer,
cluster_id: &str,
cluster_name: &str,
nodes: &[serde_json::Value],
) -> Mock<'a> {
server.mock(|when, then| {
when.method(GET).path("/api2/json/cluster/status");
then.status(200)
.header("content-type", "application/json")
.body(cluster_status_body(cluster_id, cluster_name, nodes));
})
}
/// Returns a URL pointing at a closed TCP port: a listener is bound to an
/// ephemeral port, its address is captured, and the listener is dropped so any
/// subsequent connection attempt is refused.
fn closed_port_url() -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("address should be available");
drop(listener);
format!("http://{}", addr)
}
fn fallback_config(url: &str) -> EndpointConfig {
EndpointConfig {
url: url.to_string(),
node: None,
token: Some(TOKEN.to_string()),
}
}
/// Builds a token-mode connection config that skips certificate verification
/// (the mock servers speak plain HTTP).
fn token_config(id: &str, url: &str, fallbacks: Vec<EndpointConfig>) -> ConnectionConfig {
ConnectionConfig {
id: id.to_string(),
name: id.to_string(),
primary: EndpointConfig {
url: url.to_string(),
node: None,
token: Some(TOKEN.to_string()),
},
fallbacks,
cert_fingerprint: None,
trusted: false,
accept_untrusted: true,
status: "disconnected".to_string(),
cluster_name: None,
is_cluster: false,
auth_mode: "token".to_string(),
username: None,
nodes: vec![],
cluster_id: None,
}
}
async fn add_connection(
manager: &mut ConnectionManager,
path: &std::path::Path,
config: ConnectionConfig,
) {
manager
.add_connection(config, path)
.await
.expect("connection should be added");
}
#[tokio::test]
async fn connect_discovers_and_persists_nodes() {
let server = MockServer::start();
let port = server.port();
stub_version(&server);
stub_nodes(
&server,
&[node_json("pve1", "online"), node_json("pve2", "online")],
);
stub_cluster_status(
&server,
"cluster/lab",
"lab",
&[
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
"online": 1, "local": 1, "ip": "10.0.0.5"}),
serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2,
"online": 1, "local": 0, "ip": "10.0.0.6"}),
],
);
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
add_connection(
&mut manager,
&path,
token_config("conn", &server.base_url(), vec![]),
)
.await;
let result = manager
.connect("conn", &path)
.await
.expect("connect should succeed");
assert_eq!(result.status, "connected");
assert_eq!(result.merged_into, None);
assert_eq!(result.connection_id, "conn");
let config = manager
.connection_config("conn")
.expect("config should be readable");
assert_eq!(config.nodes.len(), 2);
assert_eq!(config.cluster_id.as_deref(), Some("cluster/lab"));
assert_eq!(config.primary.node.as_deref(), Some("pve1"));
// The discovered nodes and cluster identity are persisted.
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
let conn_json = &json["connections"][0];
assert_eq!(conn_json["clusterId"], "cluster/lab");
assert_eq!(
conn_json["nodes"]
.as_array()
.expect("nodes should be present")
.len(),
2
);
assert_eq!(
conn_json["nodes"][0]["url"],
format!("http://10.0.0.5:{}", port),
"the primary node's URL must be persisted"
);
assert_eq!(conn_json["status"], "connected");
}
#[tokio::test]
async fn connect_second_node_merges_into_existing() {
let server_a = MockServer::start();
let server_b = MockServer::start();
// Both servers report the same cluster identity, as two nodes of one
// cluster would.
for (server, ip1, ip2) in [
(&server_a, "10.0.0.5", "10.0.0.6"),
(&server_b, "10.0.0.7", "10.0.0.8"),
] {
stub_version(server);
stub_nodes(
server,
&[node_json("pve1", "online"), node_json("pve2", "online")],
);
stub_cluster_status(
server,
"cluster/lab",
"lab",
&[
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
"online": 1, "local": 1, "ip": ip1}),
serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2,
"online": 1, "local": 0, "ip": ip2}),
],
);
}
let vms_mock = server_a.mock(|when, then| {
when.method(GET)
.path("/api2/json/cluster/resources")
.query_param("type", "vm");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [{"vmid": 100, "name": "web01", "type": "qemu",
"status": "running", "node": "pve1"}]
})
.to_string(),
);
});
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
add_connection(
&mut manager,
&path,
token_config("conn-a", &server_a.base_url(), vec![]),
)
.await;
add_connection(
&mut manager,
&path,
token_config("conn-b", &server_b.base_url(), vec![]),
)
.await;
let first = manager
.connect("conn-a", &path)
.await
.expect("first connect should succeed");
assert_eq!(first.status, "connected");
assert_eq!(first.merged_into, None);
let second = manager
.connect("conn-b", &path)
.await
.expect("second connect should succeed");
assert_eq!(second.status, "connected");
assert_eq!(second.connection_id, "conn-a");
assert_eq!(second.merged_into.as_deref(), Some("conn-a"));
// The merged connection is gone and conn-a carries B's endpoint.
assert!(
matches!(
manager.connection_config("conn-b"),
Err(Error::ConnectionNotFound(ref id)) if id == "conn-b"
),
"conn-b must be removed after merging"
);
let config_a = manager
.connection_config("conn-a")
.expect("config should be readable");
assert!(
config_a
.fallbacks
.iter()
.any(|endpoint| endpoint.url == server_b.base_url()),
"conn-a must have B's URL as a fallback"
);
assert_eq!(
config_a.nodes.len(),
4,
"the node lists of both servers must be merged"
);
// Only the surviving connection is persisted.
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
let ids: Vec<&str> = json["connections"]
.as_array()
.expect("connections should be an array")
.iter()
.filter_map(|c| c["id"].as_str())
.collect();
assert_eq!(ids, vec!["conn-a"]);
assert_eq!(
manager
.runtime_status("conn-a")
.expect("status should be readable"),
"connected"
);
// A subsequent data request on the merged connection is served by A.
let vms = manager
.get_vms("conn-a")
.await
.expect("get_vms should succeed via A");
assert_eq!(vms.len(), 1);
vms_mock.assert();
}
#[tokio::test]
async fn status_info_reports_failover() {
let fallback = MockServer::start();
stub_version(&fallback);
stub_nodes(&fallback, &[node_json("pve1", "online")]);
stub_cluster_status(
&fallback,
"cluster/lab",
"lab",
&[
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
"online": 1, "local": 1, "ip": "10.0.0.5"}),
],
);
let vms_mock = fallback.mock(|when, then| {
when.method(GET)
.path("/api2/json/cluster/resources")
.query_param("type", "vm");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [{"vmid": 100, "name": "web01", "type": "qemu",
"status": "running", "node": "pve1"}]
})
.to_string(),
);
});
let primary_url = closed_port_url();
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
add_connection(
&mut manager,
&path,
token_config(
"conn",
&primary_url,
vec![fallback_config(&fallback.base_url())],
),
)
.await;
let result = manager
.connect("conn", &path)
.await
.expect("connect should succeed via the fallback");
assert_eq!(result.status, "connected");
let vms = manager
.get_vms("conn")
.await
.expect("get_vms should succeed via the fallback");
assert_eq!(vms.len(), 1);
assert_eq!(
manager
.runtime_status("conn")
.expect("status should be readable"),
"failover"
);
let info = manager
.status_info("conn")
.await
.expect("status info should be readable");
assert_eq!(info.status, "failover");
assert_eq!(info.current_endpoint_url, fallback.base_url());
assert_eq!(info.primary_url, primary_url);
vms_mock.assert();
}
#[tokio::test]
async fn status_info_when_disconnected() {
let server = MockServer::start();
let version_mock = stub_version(&server);
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
add_connection(
&mut manager,
&path,
token_config("conn", &server.base_url(), vec![]),
)
.await;
let info = manager
.status_info("conn")
.await
.expect("status info should be readable");
assert_eq!(info.status, "disconnected");
assert_eq!(info.primary_url, server.base_url());
assert_eq!(info.current_endpoint_url, server.base_url());
assert!(info.nodes.is_empty());
assert_eq!(
version_mock.hits(),
0,
"a disconnected connection must not hit the network"
);
}
#[tokio::test]
async fn connect_all_endpoints_down_returns_failed_result() {
let primary_url = closed_port_url();
let fallback_url = closed_port_url();
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
add_connection(
&mut manager,
&path,
token_config("conn", &primary_url, vec![fallback_config(&fallback_url)]),
)
.await;
let result = manager
.connect("conn", &path)
.await
.expect("an unreachable cluster must be reported, not errored");
assert_eq!(result.status, "failed");
assert_eq!(result.merged_into, None);
assert_eq!(result.connection_id, "conn");
let config = manager
.connection_config("conn")
.expect("config should be readable");
assert_eq!(config.status, "failed");
// The failed status is persisted so the connection stays tracked.
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
assert_eq!(json["connections"][0]["status"], "failed");
}
+289
View File
@@ -0,0 +1,289 @@
//! Integration tests for cluster node auto-discovery.
//!
//! `discover_nodes` combines `/nodes` (the node list and statuses) with
//! `/cluster/status` (cluster IPs, local flags, and cluster identity) and
//! derives an endpoint URL per node from the connection's primary URL. These
//! tests cover the URL derivation rules, primary-node marking, the config
//! mutation, and the persist -> load round-trip.
use httpmock::prelude::*;
use httpmock::Mock;
use proxmox_desktop::{derive_node_url, ConnectionConfig, ConnectionManager, EndpointConfig};
const TOKEN: &str = "root@pam!discovery-token";
/// A minimal `/nodes` entry. The `Node` struct requires every field, so all
/// are included.
fn node_json(name: &str, status: &str) -> serde_json::Value {
serde_json::json!({
"node": name,
"status": status,
"cpu": 0.0,
"maxcpu": 8,
"mem": 0,
"maxmem": 34359738368u64,
"disk": 0,
"maxdisk": 536870912000u64,
"uptime": 0,
"level": "",
"id": format!("node/{}", name),
"type": "node"
})
}
/// Builds a `ConnectionManager` with a single token-mode connection whose
/// primary endpoint is the mock server. `node` pins `primary.node`.
async fn setup_manager(
server: &MockServer,
node: Option<&str>,
) -> (ConnectionManager, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
let config = ConnectionConfig {
id: "conn".to_string(),
name: "conn".to_string(),
primary: EndpointConfig {
url: server.base_url(),
node: node.map(str::to_string),
token: Some(TOKEN.to_string()),
},
fallbacks: vec![],
cert_fingerprint: None,
trusted: false,
accept_untrusted: true,
status: "disconnected".to_string(),
cluster_name: None,
is_cluster: false,
auth_mode: "token".to_string(),
username: None,
nodes: vec![],
cluster_id: None,
};
manager
.add_connection(config, &path)
.await
.expect("connection should be added");
(manager, dir)
}
/// Stubs `GET /api2/json/nodes` returning `nodes` (entries from `node_json`).
fn stub_nodes<'a>(server: &'a MockServer, nodes: &[serde_json::Value]) -> Mock<'a> {
server.mock(|when, then| {
when.method(GET).path("/api2/json/nodes");
then.status(200)
.header("content-type", "application/json")
.body(serde_json::json!({ "data": nodes }).to_string());
})
}
/// Builds the `/cluster/status` response body: one `cluster` entry followed by
/// the given node entries.
fn cluster_status_body(
cluster_id: &str,
cluster_name: &str,
nodes: &[serde_json::Value],
) -> String {
let mut entries = Vec::with_capacity(nodes.len() + 1);
entries.push(serde_json::json!({
"type": "cluster",
"id": cluster_id,
"name": cluster_name,
"nodes": nodes.len()
}));
entries.extend_from_slice(nodes);
serde_json::json!({ "data": entries }).to_string()
}
/// Stubs `GET /api2/json/cluster/status` returning the cluster entry plus the
/// given node entries.
fn stub_cluster_status<'a>(
server: &'a MockServer,
cluster_id: &str,
cluster_name: &str,
nodes: &[serde_json::Value],
) -> Mock<'a> {
server.mock(|when, then| {
when.method(GET).path("/api2/json/cluster/status");
then.status(200)
.header("content-type", "application/json")
.body(cluster_status_body(cluster_id, cluster_name, nodes));
})
}
#[test]
fn derive_node_url_keeps_scheme_and_port() {
assert_eq!(
derive_node_url("https://10.0.0.5:8006", Some("10.0.0.6"), "pve1"),
"https://10.0.0.6:8006"
);
// An empty IP falls back to the node name.
assert_eq!(
derive_node_url("http://pve.lan:8006", Some(""), "pve.lan"),
"http://pve.lan:8006"
);
// A missing port defaults to pveproxy's 8006 for both schemes.
assert_eq!(
derive_node_url("https://pve.lan", Some("10.0.0.7"), "pve1"),
"https://10.0.0.7:8006"
);
// An unparseable primary URL falls back to a best-effort https URL.
assert_eq!(
derive_node_url("not a url", Some("10.0.0.8"), "pve1"),
"https://10.0.0.8:8006"
);
}
#[tokio::test]
async fn discover_nodes_builds_and_marks_primary() {
let server = MockServer::start();
let port = server.port();
let nodes_mock = stub_nodes(
&server,
&[node_json("pve1", "online"), node_json("pve2", "online")],
);
let cluster_mock = stub_cluster_status(
&server,
"cluster/lab",
"lab",
&[
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
"online": 1, "local": 1, "ip": "10.0.0.5"}),
serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2,
"online": 1, "local": 0, "ip": "10.0.0.6"}),
],
);
let (mut manager, _dir) = setup_manager(&server, Some("pve1")).await;
let discovered = manager
.discover_nodes("conn")
.await
.expect("discovery should succeed");
assert_eq!(discovered.len(), 2);
assert_eq!(
discovered[0].name, "pve1",
"the primary node must sort first"
);
assert!(discovered[0].is_primary);
assert!(discovered[0].local);
assert_eq!(discovered[0].status, "online");
assert_eq!(
discovered[0].url,
format!("http://10.0.0.5:{}", port),
"pve1's URL keeps the scheme and port but uses the cluster IP"
);
assert_eq!(discovered[1].name, "pve2");
assert!(!discovered[1].is_primary);
assert!(!discovered[1].local);
assert_eq!(discovered[1].url, format!("http://10.0.0.6:{}", port));
let config = manager
.connection_config("conn")
.expect("config should be readable");
assert_eq!(config.cluster_name.as_deref(), Some("lab"));
assert_eq!(config.cluster_id.as_deref(), Some("cluster/lab"));
assert_eq!(config.primary.node.as_deref(), Some("pve1"));
assert_eq!(
config.nodes, discovered,
"the discovered list must be stored"
);
nodes_mock.assert();
cluster_mock.assert();
}
#[tokio::test]
async fn discover_nodes_persists_and_round_trips() {
let server = MockServer::start();
let nodes_mock = stub_nodes(
&server,
&[node_json("pve1", "online"), node_json("pve2", "offline")],
);
let cluster_mock = stub_cluster_status(
&server,
"cluster/lab",
"lab",
&[
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
"online": 1, "local": 1, "ip": "10.0.0.5"}),
serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2,
"online": 0, "local": 0, "ip": "10.0.0.6"}),
],
);
let (mut manager, dir) = setup_manager(&server, Some("pve1")).await;
let path = dir.path().join("connections.json");
let discovered = manager
.discover_nodes("conn")
.await
.expect("discovery should succeed");
// discover_nodes stores on the config but does not persist by itself; any
// later persist (here via set_active_connection) writes the new fields.
manager
.set_active_connection("conn".to_string(), &path)
.await
.expect("active connection should be set");
let mut reloaded = ConnectionManager::new();
let result = reloaded
.load_connections(&path)
.await
.expect("connections should load");
assert_eq!(result.connections.len(), 1);
let loaded = &result.connections[0];
assert_eq!(loaded.nodes, discovered, "discovered nodes must round-trip");
assert_eq!(loaded.cluster_id.as_deref(), Some("cluster/lab"));
assert_eq!(loaded.cluster_name.as_deref(), Some("lab"));
nodes_mock.assert();
cluster_mock.assert();
}
#[tokio::test]
async fn discover_nodes_sets_primary_node_from_url_match() {
let server = MockServer::start();
// The mock server's host is used as the node's cluster IP so the derived
// URL equals the primary URL.
let host = server.host();
let nodes_mock = stub_nodes(&server, &[node_json("pve1", "online")]);
let cluster_mock = stub_cluster_status(
&server,
"cluster/lab",
"lab",
&[
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
"online": 1, "local": 1, "ip": host}),
],
);
let (mut manager, _dir) = setup_manager(&server, None).await;
let discovered = manager
.discover_nodes("conn")
.await
.expect("discovery should succeed");
assert_eq!(discovered.len(), 1);
assert_eq!(
discovered[0].url,
server.base_url(),
"the derived URL must equal the primary URL"
);
assert!(
discovered[0].is_primary,
"a node whose URL matches the primary URL must be marked primary"
);
// With primary.node unset, discovery pins it to the primary node.
let config = manager
.connection_config("conn")
.expect("config should be readable");
assert_eq!(config.primary.node.as_deref(), Some("pve1"));
nodes_mock.assert();
cluster_mock.assert();
}
+202
View File
@@ -0,0 +1,202 @@
//! Integration tests for automatic failover across cluster endpoints.
//!
//! Transport-level failures (connection refused, timeouts, DNS resolution)
//! must be distinguishable from real authentication failures so the request
//! core can rotate to the next configured endpoint. These tests cover the
//! error mapping in `api_request` and the rotation logic in
//! `Connection::request`.
use httpmock::prelude::*;
use httpmock::Mock;
use proxmox_desktop::{
api_request, AuthContext, AuthMode, ConnectionConfig, ConnectionManager, EndpointConfig, Error,
};
use reqwest::Client;
use reqwest::Method as RMethod;
const TOKEN: &str = "root@pam!failover-token";
/// Returns a URL pointing at a closed TCP port: a listener is bound to an
/// ephemeral port, its address is captured, and the listener is dropped so
/// any subsequent connection attempt is refused.
fn closed_port_url() -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let addr = listener.local_addr().expect("address should be available");
drop(listener);
format!("http://{}", addr)
}
fn token_auth() -> AuthContext {
AuthContext {
mode: AuthMode::Token,
token: Some(TOKEN.to_string()),
ticket: None,
csrf_token: None,
}
}
/// Builds a `ConnectionManager` with a single token-mode connection whose
/// primary endpoint is `primary_url` and whose fallbacks are `fallback_urls`.
async fn setup_manager_with_fallbacks(
primary_url: &str,
fallback_urls: &[String],
) -> (ConnectionManager, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
let config = ConnectionConfig {
id: "conn".to_string(),
name: "conn".to_string(),
primary: EndpointConfig {
url: primary_url.to_string(),
node: None,
token: Some(TOKEN.to_string()),
},
fallbacks: fallback_urls
.iter()
.map(|url| EndpointConfig {
url: url.clone(),
node: None,
token: Some(TOKEN.to_string()),
})
.collect(),
cert_fingerprint: None,
trusted: false,
accept_untrusted: true,
status: "disconnected".to_string(),
cluster_name: None,
is_cluster: false,
auth_mode: "token".to_string(),
username: None,
nodes: vec![],
cluster_id: None,
};
manager
.add_connection(config, &path)
.await
.expect("connection should be added");
(manager, dir)
}
/// Stubs `GET /api2/json/cluster/resources?type=vm` on `server`.
fn stub_vm_resources<'a>(server: &'a MockServer, status: u16, body: &str) -> Mock<'a> {
server.mock(|when, then| {
when.method(GET)
.path("/api2/json/cluster/resources")
.query_param("type", "vm");
then.status(status)
.header("content-type", "application/json")
.body(body);
})
}
#[tokio::test]
async fn transport_error_maps_to_connection_failed() {
let url = closed_port_url();
let error = api_request(
&Client::new(),
&url,
RMethod::GET,
"/version",
&token_auth(),
&[],
None,
)
.await
.expect_err("connection refused must fail");
assert!(
matches!(error, Error::ConnectionFailed(_)),
"expected ConnectionFailed, got: {}",
error
);
}
#[tokio::test]
async fn request_rotates_to_fallback_when_primary_down() {
let fallback = MockServer::start();
let fallback_mock = stub_vm_resources(
&fallback,
200,
&serde_json::json!({
"data": [
{"vmid": 100, "name": "web01", "type": "qemu", "status": "running",
"node": "pve1"}
]
})
.to_string(),
);
let primary_url = closed_port_url();
let (manager, _dir) = setup_manager_with_fallbacks(&primary_url, &[fallback.base_url()]).await;
let vms = manager
.get_vms("conn")
.await
.expect("request should succeed via the fallback endpoint");
assert_eq!(vms.len(), 1);
assert_eq!(vms[0].vmid, 100);
assert_eq!(vms[0].name.as_deref(), Some("web01"));
assert_eq!(
manager
.runtime_status("conn")
.expect("status should be readable"),
"failover"
);
fallback_mock.assert();
}
#[tokio::test]
async fn non_transport_error_does_not_rotate() {
let primary = MockServer::start();
let fallback = MockServer::start();
let primary_mock = stub_vm_resources(&primary, 500, r#"{"message":"boom"}"#);
let fallback_mock = stub_vm_resources(&fallback, 200, r#"{"data":[]}"#);
let (manager, _dir) =
setup_manager_with_fallbacks(&primary.base_url(), &[fallback.base_url()]).await;
let error = manager
.get_vms("conn")
.await
.expect_err("a 500 response must surface as an ApiError");
assert!(
matches!(error, Error::ApiError(ref message) if message == "boom"),
"expected ApiError with message 'boom', got: {}",
error
);
primary_mock.assert();
assert_eq!(
fallback_mock.hits(),
0,
"a non-transport error must not trigger failover"
);
}
#[tokio::test]
async fn request_succeeds_on_primary_again_when_it_returns() {
let primary = MockServer::start();
let fallback = MockServer::start();
let primary_mock = stub_vm_resources(&primary, 200, r#"{"data":[]}"#);
let fallback_mock = stub_vm_resources(&fallback, 200, r#"{"data":[]}"#);
let (manager, _dir) =
setup_manager_with_fallbacks(&primary.base_url(), &[fallback.base_url()]).await;
let vms = manager
.get_vms("conn")
.await
.expect("request should succeed on the primary endpoint");
assert!(vms.is_empty());
primary_mock.assert();
assert_eq!(fallback_mock.hits(), 0);
assert_eq!(
manager
.runtime_status("conn")
.expect("status should be readable"),
"connected"
);
}
+489
View File
@@ -0,0 +1,489 @@
//! Integration tests for connection persistence and the connect/disconnect
//! flow.
//!
//! Connection configs are persisted to a JSON file with secrets (API tokens)
//! stripped, so they never round-trip through the file; after a restart the
//! request layer falls back to the OS keyring.
use httpmock::prelude::*;
use proxmox_desktop::{ConnectionConfig, ConnectionManager, EndpointConfig, Error};
/// Builds a token-mode connection config with a real token set.
fn token_config(id: &str, url: &str, token: &str, accept_untrusted: bool) -> ConnectionConfig {
ConnectionConfig {
id: id.to_string(),
name: id.to_string(),
primary: EndpointConfig {
url: url.to_string(),
node: None,
token: Some(token.to_string()),
},
fallbacks: vec![],
cert_fingerprint: None,
trusted: false,
accept_untrusted,
status: "disconnected".to_string(),
cluster_name: None,
is_cluster: false,
auth_mode: "token".to_string(),
username: None,
nodes: vec![],
cluster_id: None,
}
}
#[tokio::test]
async fn persisted_configs_round_trip_without_secrets() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
let config = ConnectionConfig {
id: "conn-1".to_string(),
name: "Proxmox".to_string(),
primary: EndpointConfig {
url: "https://pve.local:8006".to_string(),
node: None,
token: Some("root@pam!primary-secret".to_string()),
},
fallbacks: vec![EndpointConfig {
url: "https://backup.local:8006".to_string(),
node: None,
token: Some("root@pam!backup-secret".to_string()),
}],
cert_fingerprint: None,
trusted: false,
accept_untrusted: false,
status: "disconnected".to_string(),
cluster_name: None,
is_cluster: false,
auth_mode: "token".to_string(),
username: None,
nodes: vec![],
cluster_id: None,
};
manager
.add_connection(config, &path)
.await
.expect("connection should be added");
// The persisted file must contain the connection but never its secrets.
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
assert_eq!(json["activeConnectionId"], serde_json::Value::Null);
let conn = &json["connections"][0];
assert_eq!(conn["id"], "conn-1");
assert_eq!(conn["name"], "Proxmox");
assert!(
conn["primary"].get("token").is_none(),
"primary token must not be serialized"
);
assert!(
conn["fallbacks"][0].get("token").is_none(),
"fallback token must not be serialized"
);
assert!(
conn.get("certFingerprint").is_none(),
"absent certFingerprint must not be serialized"
);
// Reloading from disk rebuilds the connection; the token does not
// round-trip and every connection starts disconnected.
let mut reloaded = ConnectionManager::new();
let result = reloaded
.load_connections(&path)
.await
.expect("connections should load");
assert_eq!(result.connections.len(), 1);
let loaded = &result.connections[0];
assert_eq!(loaded.id, "conn-1");
assert_eq!(loaded.name, "Proxmox");
assert!(
loaded.primary.token.is_none(),
"token must not round-trip through the file"
);
assert_eq!(loaded.status, "disconnected");
}
#[tokio::test]
async fn load_connections_with_missing_file_returns_empty() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("missing").join("connections.json");
let mut manager = ConnectionManager::new();
let result = manager
.load_connections(&path)
.await
.expect("missing file is not an error");
assert!(result.active_connection_id.is_none());
assert!(result.connections.is_empty());
}
#[tokio::test]
async fn remove_connection_persists_and_keeps_active() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
manager
.add_connection(
token_config("conn-1", "https://one.local:8006", "tok-1", false),
&path,
)
.await
.expect("connection 1 should be added");
manager
.add_connection(
token_config("conn-2", "https://two.local:8006", "tok-2", false),
&path,
)
.await
.expect("connection 2 should be added");
manager
.set_active_connection("conn-2".to_string(), &path)
.await
.expect("active connection should be set");
manager
.remove_connection("conn-1", &path)
.await
.expect("connection 1 should be removed");
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
let ids: Vec<&str> = json["connections"]
.as_array()
.expect("connections should be an array")
.iter()
.filter_map(|c| c["id"].as_str())
.collect();
assert_eq!(
ids,
vec!["conn-2"],
"removed connection must not be persisted"
);
assert_eq!(
json["activeConnectionId"], "conn-2",
"removing a non-active connection must keep the active id"
);
let mut reloaded = ConnectionManager::new();
let result = reloaded
.load_connections(&path)
.await
.expect("connections should load");
assert_eq!(result.connections.len(), 1);
assert_eq!(result.connections[0].id, "conn-2");
assert_eq!(result.active_connection_id.as_deref(), Some("conn-2"));
}
#[tokio::test]
async fn update_connection_preserves_cert_settings_and_replaces_config() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
manager
.add_connection(
token_config("conn-1", "https://one.local:8006", "tok-1", true),
&path,
)
.await
.expect("connection should be added");
manager
.trust_certificate("conn-1", "AB:CD:EF", &path)
.await
.expect("certificate should be pinned");
// Update the name while omitting the certificate fields; the pinned
// fingerprint and trust settings must survive the update.
let updated = ConnectionConfig {
id: "conn-1".to_string(),
name: "Renamed".to_string(),
primary: EndpointConfig {
url: "https://one.local:8006".to_string(),
node: None,
token: None,
},
fallbacks: vec![],
cert_fingerprint: None,
trusted: false,
accept_untrusted: false,
status: "disconnected".to_string(),
cluster_name: None,
is_cluster: false,
auth_mode: "token".to_string(),
username: None,
nodes: vec![],
cluster_id: None,
};
manager
.update_connection(updated, &path)
.await
.expect("connection should be updated");
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
let conn = &json["connections"][0];
assert_eq!(conn["id"], "conn-1");
assert_eq!(conn["name"], "Renamed");
assert_eq!(
conn["certFingerprint"], "AB:CD:EF",
"cert_fingerprint must be preserved when omitted"
);
assert_eq!(
conn["trusted"], true,
"trusted must be preserved when omitted"
);
assert_eq!(
conn["acceptUntrusted"], true,
"accept_untrusted must be preserved when omitted"
);
// The updated config is what the reloaded manager serves.
let mut reloaded = ConnectionManager::new();
let result = reloaded
.load_connections(&path)
.await
.expect("connections should load");
assert_eq!(result.connections.len(), 1);
assert_eq!(result.connections[0].name, "Renamed");
}
#[tokio::test]
async fn update_connection_unknown_id_fails() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
let error = manager
.update_connection(
token_config("missing", "https://nope.local:8006", "tok", false),
&path,
)
.await
.expect_err("updating an unknown connection must fail");
assert!(
matches!(error, Error::ConnectionNotFound(ref id) if id == "missing"),
"expected ConnectionNotFound for the missing id, got: {}",
error
);
}
#[tokio::test]
async fn remove_active_connection_clears_active_id() {
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
manager
.add_connection(
token_config("conn-1", "https://one.local:8006", "tok-1", false),
&path,
)
.await
.expect("connection should be added");
manager
.set_active_connection("conn-1".to_string(), &path)
.await
.expect("active connection should be set");
manager
.remove_connection("conn-1", &path)
.await
.expect("connection should be removed");
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
assert!(
json["activeConnectionId"].is_null(),
"removing the active connection must clear the active id"
);
}
#[tokio::test]
async fn connect_token_mode_validates_version_endpoint() {
let server = MockServer::start();
let token = "root@pam!test-token";
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/version")
.header("Authorization", format!("PVEAPIToken={}", token));
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":{"version":"8.2","release":"8.2.4","repoid":"abc"}}"#);
});
// Connect also discovers the cluster's nodes, so the discovery endpoints
// are stubbed (empty cluster: no node entries, no cluster identity).
server.mock(|when, then| {
when.method(GET).path("/api2/json/nodes");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":[]}"#);
});
server.mock(|when, then| {
when.method(GET).path("/api2/json/cluster/status");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":[]}"#);
});
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
// `accept_untrusted` skips the TLS capture, which is required here because
// the mock server speaks plain HTTP.
manager
.add_connection(
token_config("conn-token", &server.base_url(), token, true),
&path,
)
.await
.expect("connection should be added");
let result = manager
.connect("conn-token", &path)
.await
.expect("connect should succeed against the mock server");
assert_eq!(result.status, "connected");
assert_eq!(result.merged_into, None);
mock.assert();
// The status update is persisted alongside the connection.
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
assert_eq!(
json["connections"][0]["status"], "connected",
"connect must persist the connected status"
);
}
#[tokio::test]
async fn connect_without_cert_pin_and_no_escape_hatch_fails() {
let server = MockServer::start();
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
manager
.add_connection(
token_config("conn-untrusted", &server.base_url(), "tok", false),
&path,
)
.await
.expect("connection should be added");
// The guard rejects the connect before any TLS is attempted (the guard is
// hit regardless of the scheme, so the http:// mock URL is fine here).
let error = manager
.connect("conn-untrusted", &path)
.await
.expect_err("connect must fail without a pin or escape hatch");
assert!(
matches!(error, Error::CertificateError(ref message) if message.contains("not been trusted")),
"expected CertificateError about the untrusted certificate, got: {}",
error
);
}
#[tokio::test]
async fn connect_accept_untrusted_escape_hatch_reaches_version() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api2/json/version");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":{"version":"8.2"}}"#);
});
server.mock(|when, then| {
when.method(GET).path("/api2/json/nodes");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":[]}"#);
});
server.mock(|when, then| {
when.method(GET).path("/api2/json/cluster/status");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":[]}"#);
});
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
manager
.add_connection(
token_config("conn-escape", &server.base_url(), "tok", true),
&path,
)
.await
.expect("connection should be added");
let result = manager
.connect("conn-escape", &path)
.await
.expect("the escape hatch must skip certificate verification");
assert_eq!(result.status, "connected");
mock.assert();
let raw = std::fs::read_to_string(&path).expect("file should be written");
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
assert_eq!(json["connections"][0]["status"], "connected");
}
#[tokio::test]
async fn disconnect_clears_session_and_status() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api2/json/version");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":{"version":"8.2"}}"#);
});
server.mock(|when, then| {
when.method(GET).path("/api2/json/nodes");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":[]}"#);
});
server.mock(|when, then| {
when.method(GET).path("/api2/json/cluster/status");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":[]}"#);
});
let dir = tempfile::tempdir().expect("temp dir should be created");
let path = dir.path().join("connections.json");
let mut manager = ConnectionManager::new();
manager
.add_connection(
token_config("conn-disc", &server.base_url(), "tok", true),
&path,
)
.await
.expect("connection should be added");
let result = manager
.connect("conn-disc", &path)
.await
.expect("connect should succeed");
assert_eq!(result.status, "connected");
manager
.disconnect("conn-disc")
.await
.expect("disconnect should succeed");
// Reconnecting after a disconnect works, so the credentials used to
// authenticate (the in-config token) must still be available. This
// triggers a second request to the version endpoint.
let result = manager
.connect("conn-disc", &path)
.await
.expect("reconnect after disconnect should succeed");
assert_eq!(result.status, "connected");
mock.assert_hits(2);
}
+186
View File
@@ -0,0 +1,186 @@
//! Integration tests for the application-layer TLS certificate capture and
//! TOFU (Trust On First Use) pinning helpers.
//!
//! Each test spawns a local TLS server with a freshly generated self-signed
//! certificate and exercises the capture/verify helpers against it.
use proxmox_desktop::tls::{
capture_fingerprint, fetch_certificate_info, verify_pin, verify_server_certificate,
};
use proxmox_desktop::Error;
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use rustls::ServerConfig;
use sha2::{Digest, Sha256};
use std::sync::Arc;
use tokio_rustls::TlsAcceptor;
/// A plausible but wrong fingerprint (SHA-256 size, hex-uppercase, colon-separated).
const WRONG_PIN: &str = "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99";
/// Computes the expected `AA:BB:CC:...` uppercase fingerprint from raw DER.
fn fingerprint_of(der: &[u8]) -> String {
let digest = Sha256::digest(der);
let hex = hex::encode_upper(digest);
hex.as_bytes()
.chunks(2)
.map(|pair| std::str::from_utf8(pair).expect("hex digits are ASCII"))
.collect::<Vec<_>>()
.join(":")
}
/// Generates a self-signed certificate and key for `127.0.0.1`.
fn generate_self_signed() -> (CertificateDer<'static>, PrivateKeyDer<'static>) {
let certified = rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()])
.expect("self-signed cert generation should succeed");
let cert_der = certified.cert.der().clone();
let key_der =
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(certified.key_pair.serialize_der()));
(cert_der, key_der)
}
/// Spawns a TLS server on an ephemeral port serving `cert_der`/`key_der` and
/// returns the `https://` URL and the expected certificate fingerprint.
async fn spawn_tls_server(
cert_der: CertificateDer<'static>,
key_der: PrivateKeyDer<'static>,
) -> (String, String) {
// rustls cannot auto-select a process-level CryptoProvider when the
// dependency graph enables both `ring` (reqwest/tokio-rustls) and the
// rustls default `aws-lc-rs`. The production capture helpers install ring
// explicitly; the spawned server below must do the same or it races with
// (and loses to) the client's install before building its config.
let _ = rustls::crypto::ring::default_provider().install_default();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind should succeed");
let address = listener.local_addr().expect("local addr should exist");
let config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![cert_der.clone()], key_der)
.expect("server config should build");
let acceptor = Arc::new(TlsAcceptor::from(Arc::new(config)));
tokio::spawn(async move {
loop {
let (stream, _) = match listener.accept().await {
Ok(accepted) => accepted,
Err(_) => continue,
};
let acceptor = Arc::clone(&acceptor);
tokio::spawn(async move {
// Complete the handshake, then drop the connection. The client
// captures the certificate as soon as the handshake finishes.
let _ = acceptor.accept(stream).await;
});
}
});
let expected = fingerprint_of(cert_der.as_ref());
(format!("https://{}", address), expected)
}
#[tokio::test(flavor = "multi_thread")]
async fn fetch_certificate_info_returns_real_certificate_data() {
let (cert_der, key_der) = generate_self_signed();
let (url, expected) = spawn_tls_server(cert_der, key_der).await;
let info = fetch_certificate_info(&url)
.await
.expect("certificate info should be fetched");
assert_eq!(
info.fingerprint, expected,
"fingerprint must match the SHA-256 of the served certificate"
);
assert!(info.self_signed, "generated certificate is self-signed");
}
#[tokio::test(flavor = "multi_thread")]
async fn capture_fingerprint_matches_served_certificate() {
let (cert_der, key_der) = generate_self_signed();
let (url, expected) = spawn_tls_server(cert_der, key_der).await;
let fingerprint = capture_fingerprint(&url)
.await
.expect("fingerprint should be captured");
assert_eq!(fingerprint, expected);
}
#[tokio::test(flavor = "multi_thread")]
async fn verify_server_certificate_with_correct_pin_succeeds() {
let (cert_der, key_der) = generate_self_signed();
let (url, expected) = spawn_tls_server(cert_der, key_der).await;
let fingerprint = verify_server_certificate(&url, Some(&expected), false)
.await
.expect("matching pin should verify");
assert_eq!(fingerprint, expected);
}
#[tokio::test(flavor = "multi_thread")]
async fn verify_server_certificate_with_wrong_pin_fails() {
let (cert_der, key_der) = generate_self_signed();
let (url, _expected) = spawn_tls_server(cert_der, key_der).await;
let error = verify_server_certificate(&url, Some(WRONG_PIN), false)
.await
.expect_err("mismatched pin should fail");
assert!(
matches!(error, Error::CertificateError(ref message) if message.contains("fingerprint")),
"expected CertificateError, got: {}",
error
);
}
#[tokio::test(flavor = "multi_thread")]
async fn verify_server_certificate_accepts_untrusted_on_mismatch() {
let (cert_der, key_der) = generate_self_signed();
let (url, expected) = spawn_tls_server(cert_der, key_der).await;
let fingerprint = verify_server_certificate(&url, Some(WRONG_PIN), true)
.await
.expect("accept_untrusted should bypass the pin mismatch");
assert_eq!(fingerprint, expected);
}
#[tokio::test(flavor = "multi_thread")]
async fn verify_server_certificate_without_pin_returns_fingerprint() {
let (cert_der, key_der) = generate_self_signed();
let (url, expected) = spawn_tls_server(cert_der, key_der).await;
let fingerprint = verify_server_certificate(&url, None, false)
.await
.expect("first use should return the fingerprint");
assert_eq!(fingerprint, expected);
}
#[tokio::test(flavor = "multi_thread")]
async fn fetch_certificate_info_rejects_non_https_urls() {
let error = fetch_certificate_info("http://127.0.0.1:8006")
.await
.expect_err("http URL must be rejected");
assert!(
matches!(error, Error::InvalidUrl(_)),
"expected InvalidUrl, got: {}",
error
);
}
#[test]
fn verify_pin_is_case_insensitive_and_separator_agnostic() {
let canonical = "AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90";
assert!(verify_pin(canonical, canonical));
assert!(verify_pin(canonical, &canonical.to_lowercase()));
assert!(verify_pin(&canonical.replace(':', ""), canonical));
assert!(verify_pin(canonical, &canonical.replace(':', " ")));
assert!(!verify_pin(canonical, WRONG_PIN));
}