use std::sync::Arc;
use std::time::Duration;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use chrono::{DateTime, Utc};
use instant_acme::{
Account, AuthorizationStatus, ChallengeType, Identifier, LetsEncrypt, NewAccount, NewOrder,
Order, OrderStatus, RetryPolicy,
};
use tokio::sync::RwLock;
use tracing::{debug, error, info, trace, warn};
use zentinel_config::server::AcmeConfig;
use super::dns::challenge::{create_challenge_info, Dns01ChallengeInfo};
use super::error::AcmeError;
use super::storage::{CertificateStorage, StoredAccountCredentials};
const LETSENCRYPT_PRODUCTION: &str = "https://acme-v02.api.letsencrypt.org/directory";
const LETSENCRYPT_STAGING: &str = "https://acme-staging-v02.api.letsencrypt.org/directory";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
const CHALLENGE_TIMEOUT: Duration = Duration::from_secs(120);
pub struct AcmeClient {
account: Arc<RwLock<Option<Account>>>,
config: AcmeConfig,
storage: Arc<CertificateStorage>,
}
impl AcmeClient {
pub fn new(config: AcmeConfig, storage: Arc<CertificateStorage>) -> Self {
Self {
account: Arc::new(RwLock::new(None)),
config,
storage,
}
}
pub fn config(&self) -> &AcmeConfig {
&self.config
}
pub fn storage(&self) -> &CertificateStorage {
&self.storage
}
fn directory_url(&self) -> &str {
if let Some(ref url) = self.config.server_url {
url
} else if self.config.staging {
LETSENCRYPT_STAGING
} else {
LETSENCRYPT_PRODUCTION
}
}
pub async fn init_account(&self) -> Result<(), AcmeError> {
if let Some(creds_json) = self.storage.load_credentials_json()? {
info!("Loading existing ACME account from storage");
let credentials: instant_acme::AccountCredentials = serde_json::from_str(&creds_json)
.map_err(|e| {
AcmeError::AccountCreation(format!("Failed to deserialize credentials: {}", e))
})?;
let account = Account::builder()
.map_err(|e| AcmeError::AccountCreation(e.to_string()))?
.from_credentials(credentials)
.await
.map_err(|e| AcmeError::AccountCreation(e.to_string()))?;
*self.account.write().await = Some(account);
info!("ACME account loaded successfully");
return Ok(());
}
info!(
email = %self.config.email,
server_url = %self.directory_url(),
key_type = ?self.config.key_type,
"Creating new ACME account"
);
let eab = if let Some(ref eab_config) = self.config.eab {
let hmac_key = URL_SAFE_NO_PAD.decode(&eab_config.hmac_key).map_err(|e| {
AcmeError::AccountCreation(format!("Invalid EAB HMAC key (base64url): {}", e))
})?;
Some(instant_acme::ExternalAccountKey::new(
eab_config.kid.clone(),
&hmac_key,
))
} else {
None
};
let (account, credentials) = Account::builder()
.map_err(|e| AcmeError::AccountCreation(e.to_string()))?
.create(
&NewAccount {
contact: &[&format!("mailto:{}", self.config.email)],
terms_of_service_agreed: true,
only_return_existing: false,
},
self.directory_url().to_owned(),
eab.as_ref(),
)
.await
.map_err(|e| AcmeError::AccountCreation(e.to_string()))?;
let creds_json = serde_json::to_string_pretty(&credentials).map_err(|e| {
AcmeError::AccountCreation(format!("Failed to serialize credentials: {}", e))
})?;
self.storage.save_credentials_json(&creds_json)?;
*self.account.write().await = Some(account);
info!("ACME account created successfully");
Ok(())
}
pub async fn create_order(&self) -> Result<(Order, Vec<ChallengeInfo>), AcmeError> {
let account_guard = self.account.read().await;
let account = account_guard.as_ref().ok_or(AcmeError::NoAccount)?;
let identifiers: Vec<Identifier> = self
.config
.domains
.iter()
.map(|d: &String| Identifier::Dns(d.clone()))
.collect();
info!(domains = ?self.config.domains, "Creating certificate order");
let mut order = account
.new_order(&NewOrder::new(&identifiers))
.await
.map_err(|e| AcmeError::OrderCreation(e.to_string()))?;
let mut authorizations = order.authorizations();
let mut challenges = Vec::new();
while let Some(result) = authorizations.next().await {
let mut authz = result.map_err(|e| {
AcmeError::OrderCreation(format!("Failed to get authorization: {}", e))
})?;
let identifier = authz.identifier();
let domain = match &identifier.identifier {
Identifier::Dns(domain) => domain.clone(),
_ => continue,
};
debug!(domain = %domain, status = ?authz.status, "Processing authorization");
if authz.status == AuthorizationStatus::Valid {
debug!(domain = %domain, "Authorization already valid");
continue;
}
let http01_challenge = authz
.challenge(ChallengeType::Http01)
.ok_or_else(|| AcmeError::NoHttp01Challenge(domain.clone()))?;
let key_authorization = http01_challenge.key_authorization();
challenges.push(ChallengeInfo {
domain,
token: http01_challenge.token.clone(),
key_authorization: key_authorization.as_str().to_string(),
url: http01_challenge.url.clone(),
});
}
Ok((order, challenges))
}
pub async fn create_order_dns01(&self) -> Result<(Order, Vec<Dns01ChallengeInfo>), AcmeError> {
let account_guard = self.account.read().await;
let account = account_guard.as_ref().ok_or(AcmeError::NoAccount)?;
let identifiers: Vec<Identifier> = self
.config
.domains
.iter()
.map(|d: &String| Identifier::Dns(d.clone()))
.collect();
info!(domains = ?self.config.domains, "Creating certificate order with DNS-01 challenges");
let mut order = account
.new_order(&NewOrder::new(&identifiers))
.await
.map_err(|e| AcmeError::OrderCreation(e.to_string()))?;
let mut authorizations = order.authorizations();
let mut challenges = Vec::new();
while let Some(result) = authorizations.next().await {
let mut authz = result.map_err(|e| {
AcmeError::OrderCreation(format!("Failed to get authorization: {}", e))
})?;
let identifier = authz.identifier();
let domain = match &identifier.identifier {
Identifier::Dns(domain) => domain.clone(),
_ => continue,
};
debug!(domain = %domain, status = ?authz.status, "Processing DNS-01 authorization");
if authz.status == AuthorizationStatus::Valid {
debug!(domain = %domain, "Authorization already valid");
continue;
}
let dns01_challenge = authz
.challenge(ChallengeType::Dns01)
.ok_or_else(|| AcmeError::NoDns01Challenge(domain.clone()))?;
let key_authorization = dns01_challenge.key_authorization();
let challenge_info =
create_challenge_info(&domain, key_authorization.as_str(), &dns01_challenge.url);
challenges.push(challenge_info);
}
Ok((order, challenges))
}
pub async fn validate_challenge(
&self,
order: &mut Order,
challenge_url: &str,
) -> Result<(), AcmeError> {
debug!(challenge_url = %challenge_url, "Setting challenge ready");
let mut authorizations = order.authorizations();
while let Some(result) = authorizations.next().await {
let mut authz = result.map_err(|e| AcmeError::ChallengeValidation {
domain: "unknown".to_string(),
message: format!("Failed to get authorization: {}", e),
})?;
let matching_type = authz
.challenges
.iter()
.find(|c| c.url == challenge_url)
.map(|c| c.r#type.clone());
if let Some(challenge_type) = matching_type {
if let Some(mut challenge) = authz.challenge(challenge_type) {
challenge
.set_ready()
.await
.map_err(|e| AcmeError::ChallengeValidation {
domain: "unknown".to_string(),
message: e.to_string(),
})?;
return Ok(());
}
}
}
Err(AcmeError::ChallengeValidation {
domain: "unknown".to_string(),
message: format!("Challenge not found for URL: {}", challenge_url),
})
}
pub async fn wait_for_order_ready(&self, order: &mut Order) -> Result<(), AcmeError> {
let deadline = tokio::time::Instant::now() + CHALLENGE_TIMEOUT;
loop {
let state = order
.refresh()
.await
.map_err(|e| AcmeError::OrderCreation(format!("Failed to refresh order: {}", e)))?;
match state.status {
OrderStatus::Ready => {
info!("Order is ready for finalization");
return Ok(());
}
OrderStatus::Invalid => {
error!("Order became invalid");
return Err(AcmeError::OrderCreation("Order became invalid".to_string()));
}
OrderStatus::Valid => {
info!("Order is already valid (certificate issued)");
return Ok(());
}
OrderStatus::Pending | OrderStatus::Processing => {
if tokio::time::Instant::now() > deadline {
return Err(AcmeError::Timeout(
"Timed out waiting for order to become ready".to_string(),
));
}
trace!(status = ?state.status, "Order not ready yet, waiting...");
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
}
}
pub async fn finalize_order(
&self,
order: &mut Order,
) -> Result<(String, String, DateTime<Utc>), AcmeError> {
info!("Finalizing certificate order");
use zentinel_config::server::AcmeKeyType;
let algo = match self.config.key_type {
AcmeKeyType::EcdsaP256 => &rcgen::PKCS_ECDSA_P256_SHA256,
AcmeKeyType::EcdsaP384 => &rcgen::PKCS_ECDSA_P384_SHA384,
};
let cert_key = rcgen::KeyPair::generate_for(algo)
.map_err(|e| AcmeError::Finalization(format!("Failed to generate key: {}", e)))?;
let mut params = rcgen::CertificateParams::new(self.config.domains.clone())
.map_err(|e| AcmeError::Finalization(format!("Failed to create CSR params: {}", e)))?;
let mut dn = rcgen::DistinguishedName::new();
dn.push(rcgen::DnType::CommonName, self.config.domains[0].clone());
params.distinguished_name = dn;
let csr_request = params
.serialize_request(&cert_key)
.map_err(|e| AcmeError::Finalization(format!("Failed to serialize CSR: {}", e)))?;
let csr = csr_request.der().to_vec();
order
.finalize_csr(&csr)
.await
.map_err(|e| AcmeError::Finalization(format!("Failed to finalize order: {}", e)))?;
let deadline = tokio::time::Instant::now() + DEFAULT_TIMEOUT;
let cert_chain = loop {
let state = order
.refresh()
.await
.map_err(|e| AcmeError::Finalization(format!("Failed to refresh order: {}", e)))?;
match state.status {
OrderStatus::Valid => {
let cert_chain = order.certificate().await.map_err(|e| {
AcmeError::Finalization(format!("Failed to get certificate: {}", e))
})?;
break cert_chain.ok_or_else(|| {
AcmeError::Finalization("No certificate in response".to_string())
})?;
}
OrderStatus::Invalid => {
return Err(AcmeError::Finalization("Order became invalid".to_string()));
}
_ => {
if tokio::time::Instant::now() > deadline {
return Err(AcmeError::Timeout(
"Timed out waiting for certificate".to_string(),
));
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
};
let key_pem = cert_key.serialize_pem();
let expiry = parse_certificate_expiry(&cert_chain)?;
info!(
domains = ?self.config.domains,
expires = %expiry,
"Certificate issued successfully"
);
Ok((cert_chain, key_pem, expiry))
}
pub fn needs_renewal(&self, domain: &str) -> Result<bool, AcmeError> {
Ok(self
.storage
.needs_renewal(domain, self.config.renew_before_days)?)
}
}
#[derive(Debug, Clone)]
pub struct ChallengeInfo {
pub domain: String,
pub token: String,
pub key_authorization: String,
pub url: String,
}
fn parse_certificate_expiry(cert_pem: &str) -> Result<DateTime<Utc>, AcmeError> {
use x509_parser::prelude::*;
let (_, pem) = pem::parse_x509_pem(cert_pem.as_bytes())
.map_err(|e| AcmeError::CertificateParse(format!("Failed to parse PEM: {}", e)))?;
let (_, cert) = X509Certificate::from_der(&pem.contents)
.map_err(|e| AcmeError::CertificateParse(format!("Failed to parse certificate: {}", e)))?;
let not_after = cert.validity().not_after;
let timestamp = not_after.timestamp();
DateTime::from_timestamp(timestamp, 0)
.ok_or_else(|| AcmeError::CertificateParse("Invalid expiry timestamp".to_string()))
}
impl std::fmt::Debug for AcmeClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AcmeClient")
.field("config", &self.config)
.field(
"has_account",
&self
.account
.try_read()
.map(|a| a.is_some())
.unwrap_or(false),
)
.finish()
}
}