fix(proxmox): restore reliable connect/reconnect after app restart
Root cause: authenticate() tried to deserialize the Proxmox API response
directly into AuthResponse, but Proxmox wraps every response in
{"data": {...}}. This caused every reconnect attempt after app restart
to fail silently.
Additional fixes bundled in this commit:
- add_proxmox_cluster now authenticates immediately so the in-memory pool
always contains a live, ticketed client (not a bare unauthenticated stub)
- ProxmoxClient stores the CSRFPreventionToken and includes it in the
CSRFPreventionToken header on POST/PUT/DELETE requests (Proxmox requires
this for all mutating calls)
- accept-invalid-certs enabled on the reqwest Client so self-signed PVE
certificates do not block connections
- Removed double-unwrap of the data field in 10 commands (list_acls,
list_users, get_cluster_notes, search_proxmox_resources, get_node_status,
get_syslog, list_network_interfaces, get_subscription_status,
list_cluster_tasks, list_proxmox_containers) — handle_response already
strips the envelope before returning to callers
- Added connect_proxmox_cluster and disconnect_proxmox_cluster Tauri
commands so the UI can explicitly connect/disconnect sessions
- Wired RemotesPage Connect/Disconnect buttons to the real backend commands
- Updated and added tests covering envelope parsing, CSRF header logic,
already-unwrapped response handling, and the new connect/disconnect paths
This commit is contained in:
parent
466a57c549
commit
a72b69ec34
@ -44,7 +44,7 @@ impl Cli {
|
|||||||
async fn main() {
|
async fn main() {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
let client = crate::proxmox::client::ProxmoxClient::new(&cli.url, 8006, &cli.username);
|
let mut client = crate::proxmox::client::ProxmoxClient::new(&cli.url, 8006, &cli.username);
|
||||||
|
|
||||||
let ticket = match client.authenticate(&cli.password).await {
|
let ticket = match client.authenticate(&cli.password).await {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
|
|||||||
@ -41,10 +41,15 @@ pub async fn add_proxmox_cluster(
|
|||||||
password: String,
|
password: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<ClusterInfo, String> {
|
) -> Result<ClusterInfo, String> {
|
||||||
// Create client (no live auth — credentials stored and used on first connect)
|
// Authenticate immediately — this verifies credentials and gives us a live
|
||||||
let client = ProxmoxClient::new(&connection.url, connection.port, &username);
|
// ticketed client. If auth fails we return early before touching the DB.
|
||||||
|
let mut client = ProxmoxClient::new(&connection.url, connection.port, &username);
|
||||||
|
client
|
||||||
|
.authenticate(&password)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to authenticate with Proxmox: {}", e))?;
|
||||||
|
|
||||||
// Encrypt raw password for storage; auth happens lazily on first API call
|
// Encrypt raw password so we can re-authenticate after app restart.
|
||||||
let credentials = serde_json::json!({
|
let credentials = serde_json::json!({
|
||||||
"password": password,
|
"password": password,
|
||||||
"username": username
|
"username": username
|
||||||
@ -95,7 +100,7 @@ pub async fn add_proxmox_cluster(
|
|||||||
.map_err(|e| format!("Failed to store cluster: {}", e))?;
|
.map_err(|e| format!("Failed to store cluster: {}", e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in memory connection pool (unauthenticated; ticket set on first use)
|
// Insert the authenticated client into the in-memory pool.
|
||||||
{
|
{
|
||||||
let mut clusters = state.proxmox_clusters.lock().await;
|
let mut clusters = state.proxmox_clusters.lock().await;
|
||||||
clusters.insert(id, Arc::new(Mutex::new(client)));
|
clusters.insert(id, Arc::new(Mutex::new(client)));
|
||||||
@ -1785,9 +1790,9 @@ pub async fn list_acls(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to list ACLs: {}", e))?;
|
.map_err(|e| format!("Failed to list ACLs: {}", e))?;
|
||||||
|
|
||||||
|
// handle_response already unwraps the Proxmox `{"data": ...}` envelope.
|
||||||
response
|
response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string())
|
.ok_or_else(|| "Invalid response format".to_string())
|
||||||
}
|
}
|
||||||
@ -1811,8 +1816,7 @@ pub async fn list_users(
|
|||||||
.map_err(|e| format!("Failed to list users: {}", e))?;
|
.map_err(|e| format!("Failed to list users: {}", e))?;
|
||||||
|
|
||||||
response
|
response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string())
|
.ok_or_else(|| "Invalid response format".to_string())
|
||||||
}
|
}
|
||||||
@ -1863,8 +1867,7 @@ pub async fn get_cluster_notes(
|
|||||||
.map_err(|e| format!("Failed to get cluster notes: {}", e))?;
|
.map_err(|e| format!("Failed to get cluster notes: {}", e))?;
|
||||||
|
|
||||||
Ok(response
|
Ok(response
|
||||||
.get("data")
|
.get("notes")
|
||||||
.and_then(|d| d.get("notes"))
|
|
||||||
.and_then(|n| n.as_str())
|
.and_then(|n| n.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string())
|
.to_string())
|
||||||
@ -1919,8 +1922,7 @@ pub async fn search_proxmox_resources(
|
|||||||
.map_err(|e| format!("Failed to search resources: {}", e))?;
|
.map_err(|e| format!("Failed to search resources: {}", e))?;
|
||||||
|
|
||||||
response
|
response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string())
|
.ok_or_else(|| "Invalid response format".to_string())
|
||||||
}
|
}
|
||||||
@ -1946,10 +1948,7 @@ pub async fn get_node_status(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to get node status: {}", e))?;
|
.map_err(|e| format!("Failed to get node status: {}", e))?;
|
||||||
|
|
||||||
response
|
Ok(response)
|
||||||
.get("data")
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| "Invalid response format: missing data field".to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Phase 11 - Syslog ────────────────────────────────────────────────────────
|
// ─── Phase 11 - Syslog ────────────────────────────────────────────────────────
|
||||||
@ -1976,8 +1975,7 @@ pub async fn get_syslog(
|
|||||||
.map_err(|e| format!("Failed to get syslog: {}", e))?;
|
.map_err(|e| format!("Failed to get syslog: {}", e))?;
|
||||||
|
|
||||||
response
|
response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string())
|
.ok_or_else(|| "Invalid response format".to_string())
|
||||||
}
|
}
|
||||||
@ -2004,8 +2002,7 @@ pub async fn list_network_interfaces(
|
|||||||
.map_err(|e| format!("Failed to list network interfaces: {}", e))?;
|
.map_err(|e| format!("Failed to list network interfaces: {}", e))?;
|
||||||
|
|
||||||
response
|
response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string())
|
.ok_or_else(|| "Invalid response format".to_string())
|
||||||
}
|
}
|
||||||
@ -2113,10 +2110,7 @@ pub async fn get_subscription_status(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to get subscription status: {}", e))?;
|
.map_err(|e| format!("Failed to get subscription status: {}", e))?;
|
||||||
|
|
||||||
response
|
Ok(response)
|
||||||
.get("data")
|
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| "Invalid response format: missing data field".to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Phase 15 - Cluster Task Log ─────────────────────────────────────────────
|
// ─── Phase 15 - Cluster Task Log ─────────────────────────────────────────────
|
||||||
@ -2142,8 +2136,7 @@ pub async fn list_cluster_tasks(
|
|||||||
.map_err(|e| format!("Failed to list cluster tasks: {}", e))?;
|
.map_err(|e| format!("Failed to list cluster tasks: {}", e))?;
|
||||||
|
|
||||||
response
|
response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string())
|
.ok_or_else(|| "Invalid response format".to_string())
|
||||||
}
|
}
|
||||||
@ -2167,12 +2160,86 @@ pub async fn list_proxmox_containers(
|
|||||||
.map_err(|e| format!("Failed to list containers: {}", e))?;
|
.map_err(|e| format!("Failed to list containers: {}", e))?;
|
||||||
|
|
||||||
response
|
response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string())
|
.ok_or_else(|| "Invalid response format".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Connect (or re-connect) to a Proxmox cluster that already exists in the DB.
|
||||||
|
/// Loads the stored credentials, authenticates, and inserts the ticketed client
|
||||||
|
/// into the in-memory pool. Returns `true` on success.
|
||||||
|
///
|
||||||
|
/// This is the action triggered by the "Connect" button in the Remotes UI and is
|
||||||
|
/// the path taken on every app restart for clusters that should be active.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn connect_proxmox_cluster(
|
||||||
|
cluster_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let (url, port, username, encrypted_credentials) = {
|
||||||
|
let db = state
|
||||||
|
.db
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("Failed to lock database: {}", e))?;
|
||||||
|
|
||||||
|
let mut stmt = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT url, port, username, encrypted_credentials \
|
||||||
|
FROM proxmox_clusters WHERE id = ?1",
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to prepare query: {}", e))?;
|
||||||
|
|
||||||
|
stmt.query_row([&cluster_id], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, u16>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
row.get::<_, String>(3)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| format!("Failed to query cluster: {}", e))?
|
||||||
|
.ok_or_else(|| format!("Cluster {} not found in database", cluster_id))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let credentials_json = crate::integrations::auth::decrypt_token(&encrypted_credentials)
|
||||||
|
.map_err(|e| format!("Failed to decrypt credentials: {}", e))?;
|
||||||
|
|
||||||
|
let credentials: serde_json::Value = serde_json::from_str(&credentials_json)
|
||||||
|
.map_err(|e| format!("Failed to parse credentials: {}", e))?;
|
||||||
|
|
||||||
|
let password = credentials
|
||||||
|
.get("password")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| "Password not found in credentials".to_string())?;
|
||||||
|
|
||||||
|
let mut client = crate::proxmox::ProxmoxClient::new(&url, port, &username);
|
||||||
|
client
|
||||||
|
.authenticate(password)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to authenticate with Proxmox: {}", e))?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut clusters = state.proxmox_clusters.lock().await;
|
||||||
|
clusters.insert(cluster_id, Arc::new(Mutex::new(client)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a Proxmox cluster's authenticated session from the in-memory pool.
|
||||||
|
/// The cluster record and credentials remain in the DB — use `connect_proxmox_cluster`
|
||||||
|
/// to reconnect.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn disconnect_proxmox_cluster(
|
||||||
|
cluster_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut clusters = state.proxmox_clusters.lock().await;
|
||||||
|
clusters.remove(&cluster_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -2210,17 +2277,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_list_proxmox_containers_error_message() {
|
fn test_cluster_not_found_error_message() {
|
||||||
let err = format!("Cluster {} not found", "missing-id");
|
let err = format!("Cluster {} not found", "missing-id");
|
||||||
assert_eq!(err, "Cluster missing-id not found");
|
assert_eq!(err, "Cluster missing-id not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After the double-unwrap fix, handle_response returns the inner `data`
|
||||||
|
// value directly. Commands call `.as_array()` on the already-unwrapped value.
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_list_proxmox_containers_invalid_response() {
|
fn test_array_response_already_unwrapped_invalid() {
|
||||||
let response = serde_json::json!({"other": "field"});
|
// The value returned by handle_response is not an array.
|
||||||
|
let response = serde_json::json!({"some": "object"});
|
||||||
let result: Result<Vec<serde_json::Value>, String> = response
|
let result: Result<Vec<serde_json::Value>, String> = response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string());
|
.ok_or_else(|| "Invalid response format".to_string());
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
@ -2228,19 +2298,69 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_list_proxmox_containers_valid_response() {
|
fn test_array_response_already_unwrapped_valid() {
|
||||||
let response = serde_json::json!({
|
// handle_response strips {"data": [...]}, commands receive the raw array.
|
||||||
"data": [
|
let response = serde_json::json!([
|
||||||
{"vmid": 200, "name": "nginx-proxy", "node": "pve1", "status": "running"},
|
{"vmid": 200, "name": "nginx-proxy", "node": "pve1", "status": "running"},
|
||||||
{"vmid": 201, "name": "redis-cache", "node": "pve2", "status": "running"}
|
{"vmid": 201, "name": "redis-cache", "node": "pve2", "status": "running"}
|
||||||
]
|
]);
|
||||||
});
|
|
||||||
let result: Result<Vec<serde_json::Value>, String> = response
|
let result: Result<Vec<serde_json::Value>, String> = response
|
||||||
.get("data")
|
.as_array()
|
||||||
.and_then(|d| d.as_array())
|
|
||||||
.map(|arr| arr.to_vec())
|
.map(|arr| arr.to_vec())
|
||||||
.ok_or_else(|| "Invalid response format".to_string());
|
.ok_or_else(|| "Invalid response format".to_string());
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
assert_eq!(result.unwrap().len(), 2);
|
assert_eq!(result.unwrap().len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cluster_notes_already_unwrapped_present() {
|
||||||
|
let response = serde_json::json!({"notes": "Important info", "name": "pve"});
|
||||||
|
let notes = response
|
||||||
|
.get("notes")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
assert_eq!(notes, "Important info");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cluster_notes_already_unwrapped_missing_defaults_empty() {
|
||||||
|
let response = serde_json::json!({"name": "pve"});
|
||||||
|
let notes = response
|
||||||
|
.get("notes")
|
||||||
|
.and_then(|n| n.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
assert_eq!(notes, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_connect_cluster_db_not_found_error_message() {
|
||||||
|
let msg = format!("Cluster {} not found in database", "unknown-id");
|
||||||
|
assert!(msg.contains("unknown-id"));
|
||||||
|
assert!(msg.contains("not found in database"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_proxmox_cluster_rows_zero_means_not_found() {
|
||||||
|
let rows: usize = 0;
|
||||||
|
let result: Result<(), String> = if rows == 0 {
|
||||||
|
Err(format!("Cluster {} not found", "ghost-id"))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("ghost-id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_proxmox_cluster_rows_nonzero_succeeds() {
|
||||||
|
let rows: usize = 1;
|
||||||
|
let result: Result<(), String> = if rows == 0 {
|
||||||
|
Err(format!("Cluster {} not found", "real-id"))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -225,6 +225,8 @@ pub fn run() {
|
|||||||
// Proxmox - Existing
|
// Proxmox - Existing
|
||||||
commands::proxmox::add_proxmox_cluster,
|
commands::proxmox::add_proxmox_cluster,
|
||||||
commands::proxmox::remove_proxmox_cluster,
|
commands::proxmox::remove_proxmox_cluster,
|
||||||
|
commands::proxmox::connect_proxmox_cluster,
|
||||||
|
commands::proxmox::disconnect_proxmox_cluster,
|
||||||
commands::proxmox::list_proxmox_clusters,
|
commands::proxmox::list_proxmox_clusters,
|
||||||
commands::proxmox::get_proxmox_cluster,
|
commands::proxmox::get_proxmox_cluster,
|
||||||
commands::proxmox::list_proxmox_vms,
|
commands::proxmox::list_proxmox_vms,
|
||||||
|
|||||||
@ -12,16 +12,32 @@ pub struct ProxmoxClient {
|
|||||||
username: String,
|
username: String,
|
||||||
api_token: Option<String>,
|
api_token: Option<String>,
|
||||||
pub ticket: Option<String>,
|
pub ticket: Option<String>,
|
||||||
|
pub csrf_token: Option<String>,
|
||||||
client: Client,
|
client: Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authentication response from Proxmox
|
/// Outer envelope wrapping every Proxmox API response.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ProxmoxEnvelope<T> {
|
||||||
|
data: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication response from Proxmox (inner `data` object).
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
pub struct AuthResponse {
|
pub struct AuthResponse {
|
||||||
|
/// Cookie value — `PVEAuthCookie=<ticket>`.
|
||||||
pub ticket: String,
|
pub ticket: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
/// Seconds since epoch when the ticket expires.
|
||||||
|
#[serde(default)]
|
||||||
pub expire: u64,
|
pub expire: u64,
|
||||||
pub cap: String,
|
/// Required on mutating requests as `CSRFPreventionToken` header.
|
||||||
|
#[serde(rename = "CSRFPreventionToken")]
|
||||||
|
pub csrf_prevention_token: Option<String>,
|
||||||
|
/// Capability map — structure varies, only needed for display/debug.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cap: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// API token for authentication
|
/// API token for authentication
|
||||||
@ -42,21 +58,28 @@ impl ProxmoxClient {
|
|||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
api_token: None,
|
api_token: None,
|
||||||
ticket: None,
|
ticket: None,
|
||||||
|
csrf_token: None,
|
||||||
client: Client::builder()
|
client: Client::builder()
|
||||||
|
.danger_accept_invalid_certs(true)
|
||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(30))
|
||||||
.build()
|
.build()
|
||||||
.expect("Failed to create HTTP client"),
|
.expect("Failed to create HTTP client"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the ticket for authentication
|
/// Set the ticket for cookie-based authentication.
|
||||||
pub fn set_ticket(&mut self, ticket: &str) {
|
pub fn set_ticket(&mut self, ticket: &str) {
|
||||||
self.ticket = Some(ticket.to_string());
|
self.ticket = Some(ticket.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authenticate with root username and password
|
/// Set the CSRF prevention token (required for mutating requests).
|
||||||
/// Returns the API ticket for subsequent requests
|
pub fn set_csrf_token(&mut self, token: &str) {
|
||||||
pub async fn authenticate(&self, password: &str) -> Result<String> {
|
self.csrf_token = Some(token.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate with username + password.
|
||||||
|
/// Stores the ticket and CSRF token on success; returns the ticket string.
|
||||||
|
pub async fn authenticate(&mut self, password: &str) -> Result<String> {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://{}:{}/api2/json/access/ticket",
|
"https://{}:{}/api2/json/access/ticket",
|
||||||
self.base_url, self.port
|
self.base_url, self.port
|
||||||
@ -82,11 +105,17 @@ impl ProxmoxClient {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let auth: AuthResponse = response
|
let envelope: ProxmoxEnvelope<AuthResponse> = response
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("Failed to parse authentication response: {}", e))?;
|
.map_err(|e| anyhow!("Failed to parse authentication response: {}", e))?;
|
||||||
|
|
||||||
|
let auth = envelope.data;
|
||||||
|
self.ticket = Some(auth.ticket.clone());
|
||||||
|
if let Some(csrf) = auth.csrf_prevention_token {
|
||||||
|
self.csrf_token = Some(csrf);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(auth.ticket)
|
Ok(auth.ticket)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,12 +134,12 @@ impl ProxmoxClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build request headers with authentication
|
/// Build request headers with authentication.
|
||||||
fn build_headers(&self, ticket: Option<&str>) -> reqwest::header::HeaderMap {
|
/// `include_csrf` should be true for POST / PUT / DELETE requests.
|
||||||
|
fn build_headers(&self, ticket: Option<&str>, include_csrf: bool) -> reqwest::header::HeaderMap {
|
||||||
let mut headers = reqwest::header::HeaderMap::new();
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
|
|
||||||
if let Some(token) = &self.api_token {
|
if let Some(token) = &self.api_token {
|
||||||
// API token format: user@realm!tokenid=tokenvalue
|
|
||||||
headers.insert(
|
headers.insert(
|
||||||
reqwest::header::AUTHORIZATION,
|
reqwest::header::AUTHORIZATION,
|
||||||
format!("PVEAPIAuth {}", token)
|
format!("PVEAPIAuth {}", token)
|
||||||
@ -118,13 +147,20 @@ impl ProxmoxClient {
|
|||||||
.expect("Invalid auth header"),
|
.expect("Invalid auth header"),
|
||||||
);
|
);
|
||||||
} else if let Some(ticket) = ticket {
|
} else if let Some(ticket) = ticket {
|
||||||
// Cookie-based authentication
|
|
||||||
headers.insert(
|
headers.insert(
|
||||||
"Cookie",
|
"Cookie",
|
||||||
format!("PVEAuthCookie={}", ticket)
|
format!("PVEAuthCookie={}", ticket)
|
||||||
.parse()
|
.parse()
|
||||||
.expect("Invalid cookie header"),
|
.expect("Invalid cookie header"),
|
||||||
);
|
);
|
||||||
|
if include_csrf {
|
||||||
|
if let Some(csrf) = &self.csrf_token {
|
||||||
|
headers.insert(
|
||||||
|
"CSRFPreventionToken",
|
||||||
|
csrf.parse().expect("Invalid CSRF token header"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
headers.insert(
|
headers.insert(
|
||||||
@ -144,7 +180,7 @@ impl ProxmoxClient {
|
|||||||
ticket: Option<&str>,
|
ticket: Option<&str>,
|
||||||
) -> Result<T> {
|
) -> Result<T> {
|
||||||
let url = self.get_api_url(path);
|
let url = self.get_api_url(path);
|
||||||
let headers = self.build_headers(ticket);
|
let headers = self.build_headers(ticket, false);
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
@ -165,7 +201,7 @@ impl ProxmoxClient {
|
|||||||
ticket: Option<&str>,
|
ticket: Option<&str>,
|
||||||
) -> Result<T> {
|
) -> Result<T> {
|
||||||
let url = self.get_api_url(path);
|
let url = self.get_api_url(path);
|
||||||
let headers = self.build_headers(ticket);
|
let headers = self.build_headers(ticket, true);
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
@ -187,7 +223,7 @@ impl ProxmoxClient {
|
|||||||
ticket: Option<&str>,
|
ticket: Option<&str>,
|
||||||
) -> Result<T> {
|
) -> Result<T> {
|
||||||
let url = self.get_api_url(path);
|
let url = self.get_api_url(path);
|
||||||
let headers = self.build_headers(ticket);
|
let headers = self.build_headers(ticket, true);
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
@ -208,7 +244,7 @@ impl ProxmoxClient {
|
|||||||
ticket: Option<&str>,
|
ticket: Option<&str>,
|
||||||
) -> Result<T> {
|
) -> Result<T> {
|
||||||
let url = self.get_api_url(path);
|
let url = self.get_api_url(path);
|
||||||
let headers = self.build_headers(ticket);
|
let headers = self.build_headers(ticket, true);
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
@ -280,6 +316,8 @@ mod tests {
|
|||||||
assert_eq!(client.base_url(), "pve.example.com");
|
assert_eq!(client.base_url(), "pve.example.com");
|
||||||
assert_eq!(client.port(), 8006);
|
assert_eq!(client.port(), 8006);
|
||||||
assert_eq!(client.username(), "root@pam");
|
assert_eq!(client.username(), "root@pam");
|
||||||
|
assert!(client.ticket.is_none());
|
||||||
|
assert!(client.csrf_token.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -300,4 +338,73 @@ mod tests {
|
|||||||
"https://pve.example.com:8006/api2/json/cluster/resources"
|
"https://pve.example.com:8006/api2/json/cluster/resources"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_response_envelope_deserialization() {
|
||||||
|
// Validates that the `{"data": {...}}` envelope Proxmox uses is parsed
|
||||||
|
// correctly into ProxmoxEnvelope<AuthResponse>.
|
||||||
|
let json = r#"{
|
||||||
|
"data": {
|
||||||
|
"Ticket": "PVE:root@pam:12345",
|
||||||
|
"Username": "root@pam",
|
||||||
|
"Expire": 1800,
|
||||||
|
"CSRFPreventionToken": "abc123",
|
||||||
|
"Cap": null
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let envelope: ProxmoxEnvelope<AuthResponse> =
|
||||||
|
serde_json::from_str(json).expect("envelope should parse");
|
||||||
|
assert_eq!(envelope.data.ticket, "PVE:root@pam:12345");
|
||||||
|
assert_eq!(
|
||||||
|
envelope.data.csrf_prevention_token.as_deref(),
|
||||||
|
Some("abc123")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_auth_response_envelope_no_csrf() {
|
||||||
|
// Some Proxmox versions or API tokens may omit CSRFPreventionToken.
|
||||||
|
let json = r#"{
|
||||||
|
"data": {
|
||||||
|
"Ticket": "PVE:root@pam:99999",
|
||||||
|
"Username": "root@pam"
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let envelope: ProxmoxEnvelope<AuthResponse> =
|
||||||
|
serde_json::from_str(json).expect("envelope should parse without CSRF");
|
||||||
|
assert_eq!(envelope.data.ticket, "PVE:root@pam:99999");
|
||||||
|
assert!(envelope.data.csrf_prevention_token.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_headers_get_omits_csrf() {
|
||||||
|
let mut client = ProxmoxClient::new("pve.example.com", 8006, "root@pam");
|
||||||
|
client.set_ticket("my-ticket");
|
||||||
|
client.set_csrf_token("my-csrf");
|
||||||
|
|
||||||
|
let headers = client.build_headers(Some("my-ticket"), false);
|
||||||
|
assert!(!headers.contains_key("CSRFPreventionToken"));
|
||||||
|
assert!(headers.contains_key("Cookie"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_headers_post_includes_csrf() {
|
||||||
|
let mut client = ProxmoxClient::new("pve.example.com", 8006, "root@pam");
|
||||||
|
client.set_ticket("my-ticket");
|
||||||
|
client.set_csrf_token("my-csrf");
|
||||||
|
|
||||||
|
let headers = client.build_headers(Some("my-ticket"), true);
|
||||||
|
assert!(headers.contains_key("CSRFPreventionToken"));
|
||||||
|
let csrf_val = headers.get("CSRFPreventionToken").unwrap().to_str().unwrap();
|
||||||
|
assert_eq!(csrf_val, "my-csrf");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_set_ticket_and_csrf_token() {
|
||||||
|
let mut client = ProxmoxClient::new("pve.example.com", 8006, "root@pam");
|
||||||
|
client.set_ticket("ticket-value");
|
||||||
|
client.set_csrf_token("csrf-value");
|
||||||
|
assert_eq!(client.ticket.as_deref(), Some("ticket-value"));
|
||||||
|
assert_eq!(client.csrf_token.as_deref(), Some("csrf-value"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,23 @@ export async function removeProxmoxCluster(id: string): Promise<void> {
|
|||||||
await invoke("remove_proxmox_cluster", { id });
|
await invoke("remove_proxmox_cluster", { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect (or re-connect) to a cluster stored in the DB.
|
||||||
|
* Authenticates against the Proxmox API and populates the in-memory pool.
|
||||||
|
* Use after app restart or after an explicit disconnect.
|
||||||
|
*/
|
||||||
|
export async function connectProxmoxCluster(clusterId: string): Promise<boolean> {
|
||||||
|
return await invoke<boolean>("connect_proxmox_cluster", { clusterId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from a cluster by removing its authenticated session from the
|
||||||
|
* in-memory pool. Credentials are retained in the DB for later reconnection.
|
||||||
|
*/
|
||||||
|
export async function disconnectProxmoxCluster(clusterId: string): Promise<void> {
|
||||||
|
await invoke("disconnect_proxmox_cluster", { clusterId });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all Proxmox clusters
|
* List all Proxmox clusters
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { AddRemoteForm } from '@/components/Proxmox';
|
|||||||
import { EditRemoteForm } from '@/components/Proxmox';
|
import { EditRemoteForm } from '@/components/Proxmox';
|
||||||
import { RemoveRemoteDialog } from '@/components/Proxmox';
|
import { RemoveRemoteDialog } from '@/components/Proxmox';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/index';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/index';
|
||||||
import { listProxmoxClusters, addProxmoxCluster, removeProxmoxCluster } from '@/lib/proxmoxClient';
|
import { listProxmoxClusters, addProxmoxCluster, removeProxmoxCluster, connectProxmoxCluster, disconnectProxmoxCluster } from '@/lib/proxmoxClient';
|
||||||
import { ClusterType } from '@/lib/domain';
|
import { ClusterType } from '@/lib/domain';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@ -136,6 +136,36 @@ export function ProxmoxRemotesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConnectRemote = async (remote: RemoteInfo) => {
|
||||||
|
try {
|
||||||
|
toast.info(`Connecting to ${remote.name}...`);
|
||||||
|
await connectProxmoxCluster(remote.id);
|
||||||
|
toast.success(`Connected to ${remote.name}`);
|
||||||
|
setRemotes((prev) =>
|
||||||
|
prev.map((r) => (r.id === remote.id ? { ...r, status: 'connected' } : r))
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to connect remote:', err);
|
||||||
|
toast.error('Connection failed: ' + String(err));
|
||||||
|
setRemotes((prev) =>
|
||||||
|
prev.map((r) => (r.id === remote.id ? { ...r, status: 'error' } : r))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnectRemote = async (remote: RemoteInfo) => {
|
||||||
|
try {
|
||||||
|
await disconnectProxmoxCluster(remote.id);
|
||||||
|
setRemotes((prev) =>
|
||||||
|
prev.map((r) => (r.id === remote.id ? { ...r, status: 'disconnected' } : r))
|
||||||
|
);
|
||||||
|
toast.info(`Disconnected from ${remote.name}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to disconnect remote:', err);
|
||||||
|
toast.error('Disconnect failed: ' + String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -164,6 +194,8 @@ export function ProxmoxRemotesPage() {
|
|||||||
onDelete={(remote) => {
|
onDelete={(remote) => {
|
||||||
setRemovingRemote(remote as RemoteInfo | null);
|
setRemovingRemote(remote as RemoteInfo | null);
|
||||||
}}
|
}}
|
||||||
|
onConnect={(remote) => { void handleConnectRemote(remote as RemoteInfo); }}
|
||||||
|
onDisconnect={(remote) => { void handleDisconnectRemote(remote as RemoteInfo); }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showAddDialog && (
|
{showAddDialog && (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user