#![allow(dead_code)]
use crate::error::Result;
use crate::proxy::acme::{AcmeConfig, ChallengeStore};
use crate::proxy::acme_client::AcmeClient;
use std::sync::Arc;
use std::time::Duration;
pub struct AcmeManager {
client: AcmeClient,
check_interval: Duration,
}
impl AcmeManager {
pub fn new(config: AcmeConfig, challenges: Arc<ChallengeStore>) -> Result<Self> {
let check_interval = Duration::from_secs(12 * 3600);
let client = AcmeClient::new(config, challenges)?;
Ok(Self {
client,
check_interval,
})
}
pub fn with_check_interval(mut self, interval: Duration) -> Self {
self.check_interval = interval;
self
}
pub fn client(&self) -> &AcmeClient {
&self.client
}
pub fn client_mut(&mut self) -> &mut AcmeClient {
&mut self.client
}
pub async fn check_and_renew(&mut self) -> Result<Vec<String>> {
let mut renewed = Vec::new();
let renewal_days = self.client.config.renewal_days;
let domains = self.client.config.domains.clone();
for domain in &domains {
let needs_action = if self.client.storage.exists(domain) {
match self.client.storage.load(domain) {
Ok(info) => {
let status = info.status(renewal_days);
matches!(
status,
crate::proxy::acme::CertStatus::Expired
| crate::proxy::acme::CertStatus::ExpiringSoon
)
}
Err(_) => true, }
} else {
true };
if needs_action {
tracing::info!(domain = domain, "Certificate needs issuance/renewal");
renewed.push(domain.clone());
}
}
if !renewed.is_empty() {
self.client.issue_certificate().await?;
}
Ok(renewed)
}
pub async fn run(mut self) {
tracing::info!(
interval_hours = self.check_interval.as_secs() / 3600,
domains = ?self.client.config.domains,
"ACME manager started"
);
loop {
match self.check_and_renew().await {
Ok(renewed) => {
if !renewed.is_empty() {
tracing::info!(
domains = ?renewed,
"Certificates issued/renewed"
);
}
}
Err(e) => {
tracing::error!(error = %e, "ACME renewal check failed");
}
}
tokio::time::sleep(self.check_interval).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::proxy::acme::{AcmeConfig, CertInfo, CertStorage, ChallengeStore};
use std::time::SystemTime;
fn test_config() -> AcmeConfig {
AcmeConfig {
email: "test@example.com".to_string(),
domains: vec!["example.com".to_string()],
staging: true,
storage_path: std::path::PathBuf::from("/tmp/acme-test"),
..Default::default()
}
}
#[test]
fn test_manager_new() {
let challenges = Arc::new(ChallengeStore::new());
let manager = AcmeManager::new(test_config(), challenges).unwrap();
assert_eq!(manager.check_interval, Duration::from_secs(12 * 3600));
}
#[test]
fn test_manager_with_check_interval() {
let challenges = Arc::new(ChallengeStore::new());
let manager = AcmeManager::new(test_config(), challenges)
.unwrap()
.with_check_interval(Duration::from_secs(3600));
assert_eq!(manager.check_interval, Duration::from_secs(3600));
}
#[tokio::test]
async fn test_manager_check_and_renew_missing_cert() {
let dir = tempfile::tempdir().unwrap();
let challenges = Arc::new(ChallengeStore::new());
let config = AcmeConfig {
email: "test@example.com".to_string(),
domains: vec!["example.com".to_string()],
storage_path: dir.path().to_path_buf(),
staging: true,
..Default::default()
};
let mut manager = AcmeManager::new(config, challenges).unwrap();
let result = manager.check_and_renew().await;
assert!(result.is_err()); }
#[tokio::test]
async fn test_manager_check_valid_cert_no_renewal() {
let dir = tempfile::tempdir().unwrap();
let challenges = Arc::new(ChallengeStore::new());
let config = AcmeConfig {
email: "test@example.com".to_string(),
domains: vec!["test.example.com".to_string()],
storage_path: dir.path().to_path_buf(),
staging: true,
..Default::default()
};
let storage = CertStorage::new(dir.path());
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let cert_info = CertInfo {
domain: "test.example.com".to_string(),
cert_pem: "cert-data".to_string(),
key_pem: "key-data".to_string(),
expires_at: now + 60 * 86400, issued_at: now,
};
storage.save(&cert_info).unwrap();
let mut manager = AcmeManager::new(config, challenges).unwrap();
let renewed = manager.check_and_renew().await.unwrap();
assert!(renewed.is_empty()); }
}