tftsr-devops_investigation/src-tauri/src/proxmox/client.rs

564 lines
18 KiB
Rust
Raw Normal View History

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<String>,
pub ticket: Option<String>,
pub csrf_token: Option<String>,
client: Client,
}
/// Outer envelope wrapping every Proxmox API response.
#[derive(Debug, Deserialize)]
struct ProxmoxEnvelope<T> {
data: T,
}
/// Authentication response from Proxmox (inner `data` object).
#[derive(Debug, Deserialize)]
pub struct AuthResponse {
/// Cookie value — `PVEAuthCookie=<ticket>`.
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<String>,
/// Capability map — structure varies, only needed for display/debug.
#[serde(default)]
pub cap: Option<serde_json::Value>,
/// Cluster name
#[serde(default)]
pub clustername: Option<String>,
}
/// API token for authentication
#[derive(Debug, Serialize, Deserialize)]
pub struct ApiToken {
pub token_id: String,
pub name: String,
pub expire: u64,
pub permissions: Vec<String>,
}
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<String> {
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(&params)
.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<AuthResponse> = 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<T: for<'de> Deserialize<'de>>(
&self,
path: &str,
ticket: Option<&str>,
) -> Result<T> {
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<T: for<'de> Deserialize<'de>, B: Serialize>(
&self,
path: &str,
body: &B,
ticket: Option<&str>,
) -> Result<T> {
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<T: for<'de> Deserialize<'de>, B: Serialize>(
&self,
path: &str,
body: &B,
ticket: Option<&str>,
) -> Result<T> {
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<T: for<'de> Deserialize<'de>>(
&self,
path: &str,
ticket: Option<&str>,
) -> Result<T> {
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<T: for<'de> Deserialize<'de>>(
&self,
response: reqwest::Response,
) -> Result<T> {
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<String, serde_json::Value> = 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<AuthResponse>.
// 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<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",
"clustername": "TFTSR"
}
}"#;
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"));
}
#[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<u32>,
name: Option<String>,
r#type: Option<String>,
node: Option<String>,
status: Option<String>,
}
let result: Result<Vec<Resource>, _> = 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<Vec<Node>, _> = 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<u32>,
name: Option<String>,
r#type: Option<String>,
status: Option<String>,
}
let result: Result<Vec<Resource>, _> = 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);
}
}
}
}