feat: add cluster discovery, failover, VM management, and console support
This commit is contained in:
Generated
+288
@@ -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"
|
||||
|
||||
@@ -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 @@
|
||||
{}
|
||||
{"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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 @@
|
||||
{"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":{}}
|
||||
-2
@@ -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"}
|
||||
-2
@@ -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"}
|
||||
-2
@@ -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"}
|
||||
-2
@@ -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"}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+2
-2
@@ -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:
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+2
-2
@@ -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:
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+2
-2
@@ -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:
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+2
-2
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user