use anyhow::{anyhow, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::Duration; /// Proxmox VE/PBS API client /// Implements authentication and request handling for Proxmox APIs pub struct ProxmoxClient { base_url: String, port: u16, username: String, api_token: Option, pub ticket: Option, pub csrf_token: Option, client: Client, } /// Outer envelope wrapping every Proxmox API response. #[derive(Debug, Deserialize)] struct ProxmoxEnvelope { data: T, } /// Authentication response from Proxmox (inner `data` object). #[derive(Debug, Deserialize)] pub struct AuthResponse { /// Cookie value — `PVEAuthCookie=`. pub ticket: String, pub username: String, /// Seconds since epoch when the ticket expires. #[serde(default)] pub expire: u64, /// Required on mutating requests as `CSRFPreventionToken` header. #[serde(rename = "CSRFPreventionToken")] pub csrf_prevention_token: Option, /// Capability map — structure varies, only needed for display/debug. #[serde(default)] pub cap: Option, /// Cluster name #[serde(default)] pub clustername: Option, } /// API token for authentication #[derive(Debug, Serialize, Deserialize)] pub struct ApiToken { pub token_id: String, pub name: String, pub expire: u64, pub permissions: Vec, } impl ProxmoxClient { /// Create a new Proxmox client pub fn new(base_url: &str, port: u16, username: &str) -> Self { Self { base_url: base_url.trim_end_matches('/').to_string(), port, username: username.to_string(), api_token: None, ticket: None, csrf_token: None, client: Client::builder() .danger_accept_invalid_certs(true) .timeout(Duration::from_secs(30)) .build() .expect("Failed to create HTTP client"), } } /// Set the ticket for cookie-based authentication. pub fn set_ticket(&mut self, ticket: &str) { self.ticket = Some(ticket.to_string()); } /// Set the CSRF prevention token (required for mutating requests). pub fn set_csrf_token(&mut self, token: &str) { 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 { let url = format!( "https://{}:{}/api2/json/access/ticket", self.base_url, self.port ); let params = vec![("username", self.username.as_str()), ("password", password)]; let response = self .client .post(&url) .form(¶ms) .send() .await .map_err(|e| anyhow!("Authentication request failed: {}", e))?; if !response.status().is_success() { let status = response.status(); let text = response.text().await.unwrap_or_default(); return Err(anyhow!( "Authentication failed with status {}: {}", status, text )); } let envelope: ProxmoxEnvelope = response .json() .await .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) } /// Authenticate with API token pub fn authenticate_with_token(&mut self, token: &str) { self.api_token = Some(token.to_string()); } /// Get the full API URL for a given path fn get_api_url(&self, path: &str) -> String { format!( "https://{}:{}/api2/json/{}", self.base_url, self.port, path.trim_start_matches('/') ) } /// Build request headers with authentication. /// `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(); if let Some(token) = &self.api_token { headers.insert( reqwest::header::AUTHORIZATION, format!("PVEAPIAuth {}", token) .parse() .expect("Invalid auth header"), ); } else if let Some(ticket) = ticket { headers.insert( "Cookie", format!("PVEAuthCookie={}", ticket) .parse() .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( reqwest::header::CONTENT_TYPE, "application/x-www-form-urlencoded" .parse() .expect("Invalid content type"), ); headers } /// GET request to Proxmox API pub async fn get Deserialize<'de>>( &self, path: &str, ticket: Option<&str>, ) -> Result { let url = self.get_api_url(path); let headers = self.build_headers(ticket, false); let response = self .client .get(&url) .headers(headers) .send() .await .map_err(|e| anyhow!("GET request failed: {}", e))?; self.handle_response(response).await } /// POST request to Proxmox API pub async fn post Deserialize<'de>, B: Serialize>( &self, path: &str, body: &B, ticket: Option<&str>, ) -> Result { let url = self.get_api_url(path); let headers = self.build_headers(ticket, true); let response = self .client .post(&url) .headers(headers) .json(body) .send() .await .map_err(|e| anyhow!("POST request failed: {}", e))?; self.handle_response(response).await } /// PUT request to Proxmox API pub async fn put Deserialize<'de>, B: Serialize>( &self, path: &str, body: &B, ticket: Option<&str>, ) -> Result { let url = self.get_api_url(path); let headers = self.build_headers(ticket, true); let response = self .client .put(&url) .headers(headers) .json(body) .send() .await .map_err(|e| anyhow!("PUT request failed: {}", e))?; self.handle_response(response).await } /// DELETE request to Proxmox API pub async fn delete Deserialize<'de>>( &self, path: &str, ticket: Option<&str>, ) -> Result { let url = self.get_api_url(path); let headers = self.build_headers(ticket, true); let response = self .client .delete(&url) .headers(headers) .send() .await .map_err(|e| anyhow!("DELETE request failed: {}", e))?; self.handle_response(response).await } /// Handle API response async fn handle_response Deserialize<'de>>( &self, response: reqwest::Response, ) -> Result { if !response.status().is_success() { let status = response.status(); let text = response.text().await.unwrap_or_default(); return Err(anyhow!( "API request failed with status {}: {}", status, text )); } let data: HashMap = response .json() .await .map_err(|e| anyhow!("Failed to parse API response: {}", e))?; // Proxmox API wraps data in "data" field data.get("data") .ok_or_else(|| anyhow!("Response missing 'data' field")) .and_then(|d| { serde_json::from_value(d.clone()) .map_err(|e| anyhow!("Failed to deserialize data: {}", e)) }) } /// Get the base URL pub fn base_url(&self) -> &str { &self.base_url } /// Get the port pub fn port(&self) -> u16 { self.port } /// Get the username pub fn username(&self) -> &str { &self.username } } #[cfg(test)] mod tests { use super::*; // The frontend strips the protocol via parseRemoteUrl before sending to the backend, // so ProxmoxClient always receives a bare hostname (no scheme, no port). // get_api_url() is responsible for constructing the full https URL with port. #[test] fn test_proxmox_client_new() { let client = ProxmoxClient::new("pve.example.com", 8006, "root@pam"); assert_eq!(client.base_url(), "pve.example.com"); assert_eq!(client.port(), 8006); assert_eq!(client.username(), "root@pam"); assert!(client.ticket.is_none()); assert!(client.csrf_token.is_none()); } #[test] fn test_proxmox_client_with_trailing_slash() { let client = ProxmoxClient::new("pve.example.com/", 8006, "root@pam"); assert_eq!(client.base_url(), "pve.example.com"); } #[test] fn test_get_api_url() { let client = ProxmoxClient::new("pve.example.com", 8006, "root@pam"); assert_eq!( client.get_api_url("cluster/resources"), "https://pve.example.com:8006/api2/json/cluster/resources" ); assert_eq!( client.get_api_url("/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. // Note: Proxmox returns lowercase fields (ticket, username, clustername) // except for CSRFPreventionToken which is PascalCase. let json = r#"{ "data": { "ticket": "PVE:root@pam:12345", "username": "root@pam", "expire": 1800, "CSRFPreventionToken": "abc123", "cap": null, "clustername": "TFTSR" } }"#; let envelope: ProxmoxEnvelope = 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", "clustername": "TFTSR" } }"#; let envelope: ProxmoxEnvelope = 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")); } #[tokio::test] async fn test_real_proxmox_auth() { let password = match std::env::var("PROXMOX_PASSWORD") { Ok(p) => p, Err(_) => { println!("Skipping test: PROXMOX_PASSWORD env var not set"); return; } }; let mut client = ProxmoxClient::new("172.0.0.18", 8006, "root@pam"); let result = client.authenticate(&password).await; match result { Ok(ticket) => { println!("✓ Authentication successful"); println!(" Ticket: {}", &ticket[..50]); assert!(client.ticket.is_some()); assert!(client.csrf_token.is_some()); } Err(e) => { panic!("Authentication failed: {}", e); } } } #[tokio::test] async fn test_real_proxmox_cluster_resources() { let password = match std::env::var("PROXMOX_PASSWORD") { Ok(p) => p, Err(_) => { println!("Skipping test: PROXMOX_PASSWORD env var not set"); return; } }; let mut client = ProxmoxClient::new("172.0.0.18", 8006, "root@pam"); client.authenticate(&password).await.expect("Authentication failed"); #[derive(serde::Deserialize, Debug)] struct Resource { #[serde(default)] vmid: Option, name: Option, r#type: Option, node: Option, status: Option, } let result: Result, _> = client.get("cluster/resources", client.ticket.as_deref()).await; match result { Ok(resources) => { println!("✓ Cluster resources fetched successfully"); println!(" Found {} resources", resources.len()); } Err(e) => { panic!("Failed to get cluster resources: {}", e); } } } fn get_test_client() -> ProxmoxClient { let host = std::env::var("PROXMOX_HOST").unwrap_or_else(|_| "172.0.0.18".to_string()); ProxmoxClient::new(&host, 8006, "root@pam") } #[tokio::test] async fn test_real_proxmox_nodes() { let password = match std::env::var("PROXMOX_PASSWORD") { Ok(p) => p, Err(_) => { println!("Skipping test: PROXMOX_PASSWORD env var not set"); return; } }; let mut client = get_test_client(); client.authenticate(&password).await.expect("Authentication failed"); #[derive(serde::Deserialize, Debug)] struct Node { node: String, status: String, #[serde(default)] level: String, #[serde(default)] cpu: f64, #[serde(default)] uptime: u64, } let result: Result, _> = client.get("nodes", client.ticket.as_deref()).await; match result { Ok(nodes) => { println!("✓ Nodes fetched successfully"); for node in &nodes { println!(" Node: {} - Status: {}", node.node, node.status); } } Err(e) => { panic!("Failed to get nodes: {}", e); } } } #[tokio::test] async fn test_real_proxmox_vms() { let password = match std::env::var("PROXMOX_PASSWORD") { Ok(p) => p, Err(_) => { println!("Skipping test: PROXMOX_PASSWORD env var not set"); return; } }; let mut client = get_test_client(); client.authenticate(&password).await.expect("Authentication failed"); #[derive(serde::Deserialize, Debug)] struct Resource { #[serde(default)] vmid: Option, name: Option, r#type: Option, status: Option, } let result: Result, _> = client.get("cluster/resources", client.ticket.as_deref()).await; match result { Ok(resources) => { let vms: Vec<_> = resources.into_iter().filter(|r| r.r#type.as_deref() == Some("qemu")).collect(); println!("✓ VMs fetched successfully"); println!(" Found {} VMs", vms.len()); } Err(e) => { panic!("Failed to get VMs: {}", e); } } } }