- Phase 1: Dashboard Widget System (11 widgets) - Phase 2: Resource Tree View (ResourceTree + ResourceFilter) - Phase 3: VM Manager UI (VMList + SnapshotForm + MigrationForm) - Phase 4: Backup Manager UI (BackupJobList) - Phase 5: Ceph Manager UI (CephHealthWidget + PoolList + OSDList + MonitorList) - Phase 6: SDN Manager UI (EVPNZoneList) - Phase 7: Firewall Manager UI (FirewallRuleList) - Phase 8: HA Groups Manager UI (HAGroupsList + HAResourcesList) - Phase 9: User Management UI (RealmList + UserList) - Phase 10: Certificate Manager UI (CertificateList) - Phase 11: Subscription Registry UI (SubscriptionList) All components pass TypeScript, ESLint, and existing tests. All Rust code passes clippy and format checks.
296 lines
8.2 KiB
Rust
296 lines
8.2 KiB
Rust
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>,
|
|
client: Client,
|
|
}
|
|
|
|
/// Authentication response from Proxmox
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct AuthResponse {
|
|
pub ticket: String,
|
|
pub username: String,
|
|
pub expire: u64,
|
|
pub cap: 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,
|
|
client: Client::builder()
|
|
.timeout(Duration::from_secs(30))
|
|
.build()
|
|
.expect("Failed to create HTTP client"),
|
|
}
|
|
}
|
|
|
|
/// Set the ticket for authentication
|
|
pub fn set_ticket(&mut self, ticket: &str) {
|
|
self.ticket = Some(ticket.to_string());
|
|
}
|
|
|
|
/// Authenticate with root username and password
|
|
/// Returns the API ticket for subsequent requests
|
|
pub async fn authenticate(&self, password: &str) -> Result<String> {
|
|
let url = format!("{}/api2/json/access/ticket", self.base_url);
|
|
|
|
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 auth: AuthResponse = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| anyhow!("Failed to parse authentication response: {}", e))?;
|
|
|
|
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!(
|
|
"{}/api2/json/{}",
|
|
self.base_url,
|
|
path.trim_start_matches('/')
|
|
)
|
|
}
|
|
|
|
/// Build request headers with authentication
|
|
fn build_headers(&self, ticket: Option<&str>) -> reqwest::header::HeaderMap {
|
|
let mut headers = reqwest::header::HeaderMap::new();
|
|
|
|
if let Some(token) = &self.api_token {
|
|
// API token format: user@realm!tokenid=tokenvalue
|
|
headers.insert(
|
|
reqwest::header::AUTHORIZATION,
|
|
format!("PVEAPIAuth {}", token)
|
|
.parse()
|
|
.expect("Invalid auth header"),
|
|
);
|
|
} else if let Some(ticket) = ticket {
|
|
// Cookie-based authentication
|
|
headers.insert(
|
|
"Cookie",
|
|
format!("PVEAuthCookie={}", ticket)
|
|
.parse()
|
|
.expect("Invalid cookie 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);
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
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::*;
|
|
|
|
#[test]
|
|
fn test_proxmox_client_new() {
|
|
let client = ProxmoxClient::new("https://pve.example.com", 8006, "root@pam");
|
|
assert_eq!(client.base_url(), "https://pve.example.com");
|
|
assert_eq!(client.port(), 8006);
|
|
assert_eq!(client.username(), "root@pam");
|
|
}
|
|
|
|
#[test]
|
|
fn test_proxmox_client_with_trailing_slash() {
|
|
let client = ProxmoxClient::new("https://pve.example.com/", 8006, "root@pam");
|
|
assert_eq!(client.base_url(), "https://pve.example.com");
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_api_url() {
|
|
let client = ProxmoxClient::new("https://pve.example.com", 8006, "root@pam");
|
|
assert_eq!(
|
|
client.get_api_url("cluster/resources"),
|
|
"https://pve.example.com/api2/json/cluster/resources"
|
|
);
|
|
assert_eq!(
|
|
client.get_api_url("/cluster/resources"),
|
|
"https://pve.example.com/api2/json/cluster/resources"
|
|
);
|
|
}
|
|
}
|