#![feature(error_generic_member_access)]
mod auth;
mod core;
pub use crate::core::domain::error::{ProxmoxError, ProxmoxResult, ValidationError};
use crate::{
auth::application::service::login_service::LoginService,
core::domain::{
model::{proxmox_auth::ProxmoxAuth, proxmox_connection::ProxmoxConnection},
value_object::{
ProxmoxCSRFToken, ProxmoxHost, ProxmoxPassword, ProxmoxPort, ProxmoxRealm,
ProxmoxTicket, ProxmoxUrl, ProxmoxUsername, validate_host, validate_password,
validate_port, validate_realm, validate_url, validate_username,
},
},
};
use std::backtrace::Backtrace;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct ValidationConfig {
pub password_min_score: Option<zxcvbn::Score>,
pub resolve_dns: bool,
pub block_reserved_usernames: bool,
pub ticket_lifetime: Duration,
pub csrf_lifetime: Duration,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
password_min_score: None,
resolve_dns: false,
block_reserved_usernames: false,
ticket_lifetime: Duration::from_secs(7200),
csrf_lifetime: Duration::from_secs(300),
}
}
}
#[derive(Debug)]
pub struct ProxmoxClient {
connection: ProxmoxConnection,
auth: Option<ProxmoxAuth>,
config: ValidationConfig,
}
#[derive(Debug)]
pub struct ProxmoxClientBuilder {
host: Option<String>,
port: Option<u16>,
username: Option<String>,
password: Option<String>,
realm: Option<String>,
secure: bool,
accept_invalid_certs: bool,
config: ValidationConfig,
}
impl Default for ProxmoxClientBuilder {
fn default() -> Self {
Self {
host: None,
port: None,
username: None,
password: None,
realm: None,
secure: true, accept_invalid_certs: false,
config: ValidationConfig::default(),
}
}
}
impl ProxmoxClientBuilder {
#[must_use]
pub fn host(mut self, host: impl Into<String>) -> Self {
self.host = Some(host.into());
self
}
#[must_use]
pub fn port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
#[must_use]
pub fn credentials(
mut self,
username: impl Into<String>,
password: impl Into<String>,
realm: impl Into<String>,
) -> Self {
self.username = Some(username.into());
self.password = Some(password.into());
self.realm = Some(realm.into());
self
}
#[must_use]
pub fn secure(mut self, secure: bool) -> Self {
self.secure = secure;
if !secure {
self.accept_invalid_certs = true;
}
self
}
#[must_use]
pub fn accept_invalid_certs(mut self, accept: bool) -> Self {
self.accept_invalid_certs = accept;
self
}
#[must_use]
pub fn with_validation_config(mut self, config: ValidationConfig) -> Self {
self.config = config;
self
}
#[must_use]
pub fn enable_password_strength(mut self, min_score: u8) -> Self {
self.config.password_min_score = Some(match min_score {
0 => zxcvbn::Score::Zero,
1 => zxcvbn::Score::One,
2 => zxcvbn::Score::Two,
3 => zxcvbn::Score::Three,
4 => zxcvbn::Score::Four,
_ => zxcvbn::Score::Three,
});
self
}
#[must_use]
pub fn enable_dns_resolution(mut self) -> Self {
self.config.resolve_dns = true;
self
}
#[must_use]
pub fn block_reserved_usernames(mut self) -> Self {
self.config.block_reserved_usernames = true;
self
}
pub async fn build(self) -> ProxmoxResult<ProxmoxClient> {
let host_str = self.host.ok_or_else(|| ProxmoxError::Validation {
source: ValidationError::Field {
field: "host".to_string(),
message: "Host is required".to_string(),
},
backtrace: Backtrace::capture(),
})?;
let port_num = self.port.unwrap_or(8006);
let username_str = self.username.ok_or_else(|| ProxmoxError::Validation {
source: ValidationError::Field {
field: "username".to_string(),
message: "Username is required".to_string(),
},
backtrace: Backtrace::capture(),
})?;
let password_str = self.password.ok_or_else(|| ProxmoxError::Validation {
source: ValidationError::Field {
field: "password".to_string(),
message: "Password is required".to_string(),
},
backtrace: Backtrace::capture(),
})?;
let realm_str = self.realm.ok_or_else(|| ProxmoxError::Validation {
source: ValidationError::Field {
field: "realm".to_string(),
message: "Realm is required".to_string(),
},
backtrace: Backtrace::capture(),
})?;
validate_host(&host_str, self.config.resolve_dns).map_err(|e| {
ProxmoxError::Validation {
source: e,
backtrace: Backtrace::capture(),
}
})?;
validate_port(port_num).map_err(|e| ProxmoxError::Validation {
source: e,
backtrace: Backtrace::capture(),
})?;
validate_username(&username_str, self.config.block_reserved_usernames).map_err(|e| {
ProxmoxError::Validation {
source: e,
backtrace: Backtrace::capture(),
}
})?;
validate_password(&password_str, self.config.password_min_score).map_err(|e| {
ProxmoxError::Validation {
source: e,
backtrace: Backtrace::capture(),
}
})?;
validate_realm(&realm_str).map_err(|e| ProxmoxError::Validation {
source: e,
backtrace: Backtrace::capture(),
})?;
let scheme = if self.secure { "https" } else { "http" };
let url_str = format!("{}://{}:{}/", scheme, host_str, port_num);
validate_url(&url_str).map_err(|e| ProxmoxError::Validation {
source: e,
backtrace: Backtrace::capture(),
})?;
let host = ProxmoxHost::new_unchecked(host_str);
let port = ProxmoxPort::new_unchecked(port_num);
let username = ProxmoxUsername::new_unchecked(username_str);
let password = ProxmoxPassword::new_unchecked(password_str);
let realm = ProxmoxRealm::new_unchecked(realm_str);
let url = ProxmoxUrl::new_unchecked(url_str);
let connection = ProxmoxConnection::new(
host,
port,
username,
password,
realm,
self.secure,
self.accept_invalid_certs,
url,
);
Ok(ProxmoxClient {
connection,
auth: None,
config: self.config,
})
}
}
impl ProxmoxClient {
#[must_use]
pub fn builder() -> ProxmoxClientBuilder {
ProxmoxClientBuilder::default()
}
pub async fn login(&mut self) -> ProxmoxResult<()> {
let service = LoginService::new();
self.auth = Some(service.execute(&self.connection).await?);
Ok(())
}
#[must_use]
pub fn is_authenticated(&self) -> bool {
self.auth.is_some()
}
#[must_use]
pub fn auth_token(&self) -> Option<&ProxmoxTicket> {
self.auth.as_ref().map(|a| a.ticket())
}
#[must_use]
pub fn csrf_token(&self) -> Option<&ProxmoxCSRFToken> {
self.auth.as_ref().and_then(|a| a.csrf_token())
}
#[must_use]
pub fn is_ticket_expired(&self) -> bool {
self.auth_token()
.map(|t| t.is_expired(self.config.ticket_lifetime))
.unwrap_or(true)
}
#[must_use]
pub fn is_csrf_expired(&self) -> bool {
self.csrf_token()
.map(|c| c.is_expired(self.config.csrf_lifetime))
.unwrap_or(true)
}
}
#[cfg(test)]
mod tests {
mod integration;
use super::*;
use std::time::Duration;
#[test]
fn test_builder_default_secure() {
let builder = ProxmoxClientBuilder::default();
assert!(builder.secure);
assert!(!builder.accept_invalid_certs);
}
#[tokio::test]
async fn test_builder_missing_host() {
let builder = ProxmoxClientBuilder::default()
.port(8006)
.credentials("user", "pass", "pam");
let err = builder.build().await.unwrap_err();
assert!(
matches!(err, ProxmoxError::Validation { source: ValidationError::Field { field, .. }, .. } if field == "host")
);
}
#[tokio::test]
async fn test_builder_missing_username() {
let builder = ProxmoxClientBuilder::default()
.host("example.com")
.port(8006);
let err = builder.build().await.unwrap_err();
assert!(
matches!(err, ProxmoxError::Validation { source: ValidationError::Field { field, .. }, .. } if field == "username")
);
}
#[tokio::test]
async fn test_builder_valid_minimal() {
let client = ProxmoxClientBuilder::default()
.host("example.com")
.port(8006)
.credentials("user", "password123", "pam")
.build()
.await
.unwrap();
assert!(!client.is_authenticated());
assert!(client.auth_token().is_none());
assert!(client.csrf_token().is_none());
assert!(client.is_ticket_expired());
assert!(client.is_csrf_expired());
}
#[tokio::test]
async fn test_builder_with_validation_config() {
let config = ValidationConfig {
password_min_score: Some(zxcvbn::Score::Three),
resolve_dns: true,
block_reserved_usernames: true,
..Default::default()
};
let builder = ProxmoxClientBuilder::default()
.host("example.com")
.port(8006)
.credentials("user", "password", "pam") .with_validation_config(config.clone());
let err = builder.build().await.unwrap_err();
assert!(matches!(
err,
ProxmoxError::Validation {
source: ValidationError::ConstraintViolation(_),
..
}
));
let builder = ProxmoxClientBuilder::default()
.host("example.com")
.port(8006)
.credentials("user", "Str0ng!P@ss", "pam")
.with_validation_config(config);
assert!(builder.build().await.is_ok());
}
#[tokio::test]
async fn test_client_login_no_auth() {
let client = ProxmoxClientBuilder::default()
.host("example.com")
.port(8006)
.credentials("user", "password123", "pam")
.build()
.await
.unwrap();
assert!(!client.is_authenticated());
}
#[tokio::test]
async fn test_builder_enable_methods() {
let builder = ProxmoxClientBuilder::default()
.host("example.com")
.port(8006)
.credentials("root", "password", "pam") .enable_password_strength(3)
.enable_dns_resolution()
.block_reserved_usernames();
let err = builder.build().await.unwrap_err();
assert!(matches!(err, ProxmoxError::Validation { .. }));
}
#[test]
fn test_validation_config_default() {
let config = ValidationConfig::default();
assert_eq!(config.password_min_score, None);
assert!(!config.resolve_dns);
assert!(!config.block_reserved_usernames);
assert_eq!(config.ticket_lifetime, Duration::from_secs(7200));
assert_eq!(config.csrf_lifetime, Duration::from_secs(300));
}
#[test]
fn test_expiration_checks() {
let ticket = ProxmoxTicket::new_unchecked("PVE:ticket".to_string());
let csrf = ProxmoxCSRFToken::new_unchecked("id:val".to_string());
let auth = ProxmoxAuth::new(ticket, Some(csrf));
let client = ProxmoxClient {
connection: ProxmoxConnection::new(
ProxmoxHost::new_unchecked("host".to_string()),
ProxmoxPort::new_unchecked(8006),
ProxmoxUsername::new_unchecked("user".to_string()),
ProxmoxPassword::new_unchecked("pass".to_string()),
ProxmoxRealm::new_unchecked("pam".to_string()),
true,
false,
ProxmoxUrl::new_unchecked("https://host:8006/".to_string()),
),
auth: Some(auth),
config: ValidationConfig::default(),
};
assert!(!client.is_ticket_expired());
assert!(!client.is_csrf_expired());
}
}