use std::collections::HashMap;
use std::time::Duration;
use tokio::time::Instant;
use tracing::{error, info, warn};
use super::AcmeManager;
use crate::SharedCertResolver;
const RETRY_TICK: Duration = Duration::from_secs(60);
const SWEEP_INTERVAL: Duration = Duration::from_secs(24 * 3600);
const PROVISION_TIMEOUT: Duration = Duration::from_secs(60);
struct RetryState {
failures: u32,
next_attempt: Instant,
}
fn retry_delay(failures: u32) -> Duration {
match failures {
0 | 1 => Duration::from_secs(60),
2 => Duration::from_secs(5 * 60),
_ => Duration::from_secs(15 * 60),
}
}
pub fn spawn_renewal_task(manager: AcmeManager, resolver: SharedCertResolver) {
tokio::spawn(async move {
info!("ACME renewal task started (24h sweep + fast retry for missing certs)");
let mut retries: HashMap<String, RetryState> = HashMap::new();
let mut last_sweep = Instant::now();
loop {
tokio::time::sleep(RETRY_TICK).await;
retry_missing_certs(&manager, &resolver, &mut retries).await;
if last_sweep.elapsed() >= SWEEP_INTERVAL {
last_sweep = Instant::now();
check_and_renew(&manager, &resolver).await;
}
}
});
}
async fn retry_missing_certs(
manager: &AcmeManager,
resolver: &SharedCertResolver,
retries: &mut HashMap<String, RetryState>,
) {
let now = Instant::now();
for domain in manager.domains().await {
if resolver.has_cert(&domain) {
retries.remove(&domain);
continue;
}
if retries.get(&domain).is_some_and(|r| now < r.next_attempt) {
continue;
}
info!(domain = %domain, "Provisioning missing certificate");
let provision = manager.ensure_cert_for_resolver(&domain, resolver);
let failure = match tokio::time::timeout(PROVISION_TIMEOUT, provision).await {
Ok(Ok(())) => {
retries.remove(&domain);
info!(domain = %domain, "Certificate provisioned");
None
}
Ok(Err(e)) => Some(e.to_string()),
Err(_) => Some(format!("timed out after {}s", PROVISION_TIMEOUT.as_secs())),
};
if let Some(reason) = failure {
let failures = retries.get(&domain).map_or(1, |r| r.failures + 1);
let delay = retry_delay(failures);
warn!(
domain = %domain,
error = %reason,
retry_in_secs = delay.as_secs(),
"Certificate provisioning did not succeed, will retry"
);
retries.insert(
domain,
RetryState {
failures,
next_attempt: now + delay,
},
);
}
}
}
async fn check_and_renew(manager: &AcmeManager, resolver: &SharedCertResolver) {
let domains = manager.domains().await;
if domains.is_empty() {
return;
}
info!(count = domains.len(), "Checking certificates for renewal");
for domain in &domains {
if !manager.needs_renewal(domain) {
continue;
}
info!(domain = %domain, "Certificate needs renewal, re-provisioning");
match manager.ensure_cert_for_resolver(domain, resolver).await {
Ok(()) => info!(domain = %domain, "Certificate renewed successfully"),
Err(e) => error!(domain = %domain, error = %e, "Certificate renewal failed"),
}
}
}
pub async fn check_and_renew_from_cache(manager: &AcmeManager, resolver: &SharedCertResolver) {
let cache_dir = &manager.cache_dir;
let entries = match std::fs::read_dir(cache_dir) {
Ok(e) => e,
Err(e) => {
warn!(error = %e, "Cannot read cert cache directory");
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
let domain = match name.strip_suffix(".cert.pem") {
Some(d) => d.to_string(),
None => continue,
};
if !manager.needs_renewal(&domain) {
continue;
}
info!(domain = %domain, "Cached certificate needs renewal");
manager.add_domain(&domain).await;
match manager.ensure_cert_for_resolver(&domain, resolver).await {
Ok(()) => info!(domain = %domain, "Certificate renewed from cache scan"),
Err(e) => error!(domain = %domain, error = %e, "Renewal from cache failed"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_cert_needs_renewal_when_expiring_soon() {
let tmp = TempDir::new().unwrap();
let mgr = AcmeManager::new("test@example.com", tmp.path());
let path = super::super::certs::mint_cert_expiring_in(tmp.path(), 10);
std::fs::rename(path, mgr.cert_path("old.example.com")).unwrap();
assert!(mgr.needs_renewal("old.example.com"));
}
#[test]
fn test_cert_ok_when_validity_remains() {
let tmp = TempDir::new().unwrap();
let mgr = AcmeManager::new("test@example.com", tmp.path());
let path = super::super::certs::mint_cert_expiring_in(tmp.path(), 60);
std::fs::rename(path, mgr.cert_path("fresh.example.com")).unwrap();
assert!(!mgr.needs_renewal("fresh.example.com"));
}
#[test]
fn test_garbage_cert_needs_renewal() {
let tmp = TempDir::new().unwrap();
let mgr = AcmeManager::new("test@example.com", tmp.path());
std::fs::write(mgr.cert_path("junk.example.com"), b"fake-cert-data").unwrap();
assert!(mgr.needs_renewal("junk.example.com"));
}
#[test]
fn test_retry_backoff_is_one_five_fifteen_capped() {
assert_eq!(retry_delay(1), Duration::from_secs(60));
assert_eq!(retry_delay(2), Duration::from_secs(300));
assert_eq!(retry_delay(3), Duration::from_secs(900));
assert_eq!(retry_delay(100), Duration::from_secs(900));
}
}