//! ACME (Let's Encrypt) integration using instant-acme. //! //! This module handles HTTP-01 challenge creation and certificate provisioning. //! Account credentials are ephemeral — the consumer owns all persistence. use instant_acme::{ Account, NewAccount, NewOrder, Identifier, ChallengeType, OrderStatus, AccountCredentials, }; use rcgen::{CertificateParams, KeyPair}; use thiserror::Error; use tracing::{debug, info}; #[derive(Debug, Error)] pub enum AcmeError { #[error("ACME account creation failed: {0}")] AccountCreation(String), #[error("ACME order failed: {0}")] OrderFailed(String), #[error("Challenge failed: {0}")] ChallengeFailed(String), #[error("Certificate finalization failed: {0}")] FinalizationFailed(String), #[error("No HTTP-01 challenge found")] NoHttp01Challenge, #[error("Timeout waiting for order: {0}")] Timeout(String), } /// Pending HTTP-01 challenge that needs to be served. pub struct PendingChallenge { pub token: String, pub key_authorization: String, pub domain: String, } /// ACME client wrapper around instant-acme. pub struct AcmeClient { use_production: bool, email: String, } impl AcmeClient { pub fn new(email: String, use_production: bool) -> Self { Self { use_production, email, } } /// Create a new ACME account (ephemeral — not persisted). async fn get_or_create_account(&self) -> Result { let directory_url = self.directory_url(); let contact = format!("mailto:{}", self.email); let (account, _credentials) = Account::create( &NewAccount { contact: &[&contact], terms_of_service_agreed: true, only_return_existing: false, }, directory_url, None, ) .await .map_err(|e| AcmeError::AccountCreation(e.to_string()))?; debug!("ACME account created"); Ok(account) } /// Request a certificate for a domain using the HTTP-01 challenge. /// /// Returns (cert_chain_pem, private_key_pem) on success. /// /// The caller must serve the HTTP-01 challenge at: /// `http:///.well-known/acme-challenge/` /// /// The `challenge_handler` closure is called with a `PendingChallenge` /// and must arrange for the challenge response to be served. It should /// return once the challenge is ready to be validated. pub async fn provision( &self, domain: &str, challenge_handler: F, ) -> Result<(String, String), AcmeError> where F: FnOnce(PendingChallenge) -> Fut, Fut: std::future::Future>, { info!("Starting ACME provisioning for {} via {}", domain, self.directory_url()); // 1. Get or create ACME account let account = self.get_or_create_account().await?; // 2. Create order let identifier = Identifier::Dns(domain.to_string()); let mut order = account .new_order(&NewOrder { identifiers: &[identifier], }) .await .map_err(|e| AcmeError::OrderFailed(e.to_string()))?; debug!("ACME order created"); // 3. Get authorizations and find HTTP-01 challenge let authorizations = order .authorizations() .await .map_err(|e| AcmeError::OrderFailed(e.to_string()))?; // Find the HTTP-01 challenge let (challenge_token, challenge_url) = authorizations .iter() .flat_map(|auth| auth.challenges.iter()) .find(|c| c.r#type == ChallengeType::Http01) .map(|c| { let key_auth = order.key_authorization(c); ( PendingChallenge { token: c.token.clone(), key_authorization: key_auth.as_str().to_string(), domain: domain.to_string(), }, c.url.clone(), ) }) .ok_or(AcmeError::NoHttp01Challenge)?; // Call the handler to set up challenge serving challenge_handler(challenge_token).await?; // 4. Notify ACME server that challenge is ready order .set_challenge_ready(&challenge_url) .await .map_err(|e| AcmeError::ChallengeFailed(e.to_string()))?; debug!("Challenge marked as ready, waiting for validation..."); // 5. Poll for order to become ready let mut attempts = 0; let state = loop { tokio::time::sleep(std::time::Duration::from_secs(2)).await; let state = order .refresh() .await .map_err(|e| AcmeError::OrderFailed(e.to_string()))?; match state.status { OrderStatus::Ready | OrderStatus::Valid => break state.status, OrderStatus::Invalid => { return Err(AcmeError::ChallengeFailed( "Order became invalid (challenge failed)".to_string(), )); } _ => { attempts += 1; if attempts > 30 { return Err(AcmeError::Timeout( "Order did not become ready within 60 seconds".to_string(), )); } } } }; debug!("Order ready, finalizing..."); // 6. Generate CSR and finalize let key_pair = KeyPair::generate().map_err(|e| { AcmeError::FinalizationFailed(format!("Key generation failed: {}", e)) })?; let mut params = CertificateParams::new(vec![domain.to_string()]).map_err(|e| { AcmeError::FinalizationFailed(format!("CSR params failed: {}", e)) })?; params.distinguished_name.push(rcgen::DnType::CommonName, domain); let csr = params.serialize_request(&key_pair).map_err(|e| { AcmeError::FinalizationFailed(format!("CSR serialization failed: {}", e)) })?; if state == OrderStatus::Ready { order .finalize(csr.der()) .await .map_err(|e| AcmeError::FinalizationFailed(e.to_string()))?; } // 7. Wait for certificate to be issued let mut attempts = 0; loop { let state = order .refresh() .await .map_err(|e| AcmeError::OrderFailed(e.to_string()))?; if state.status == OrderStatus::Valid { break; } if state.status == OrderStatus::Invalid { return Err(AcmeError::FinalizationFailed( "Order became invalid during finalization".to_string(), )); } attempts += 1; if attempts > 15 { return Err(AcmeError::Timeout( "Certificate not issued within 30 seconds".to_string(), )); } tokio::time::sleep(std::time::Duration::from_secs(2)).await; } // 8. Download certificate let cert_chain_pem = order .certificate() .await .map_err(|e| AcmeError::FinalizationFailed(e.to_string()))? .ok_or_else(|| { AcmeError::FinalizationFailed("No certificate returned".to_string()) })?; let private_key_pem = key_pair.serialize_pem(); info!("Certificate provisioned successfully for {}", domain); Ok((cert_chain_pem, private_key_pem)) } /// Restore an ACME account from stored credentials. pub async fn restore_account( &self, credentials: AccountCredentials, ) -> Result { Account::from_credentials(credentials) .await .map_err(|e| AcmeError::AccountCreation(e.to_string())) } /// Get the ACME directory URL based on production/staging. pub fn directory_url(&self) -> &str { if self.use_production { "https://acme-v02.api.letsencrypt.org/directory" } else { "https://acme-staging-v02.api.letsencrypt.org/directory" } } /// Whether this client is configured for production. pub fn is_production(&self) -> bool { self.use_production } } #[cfg(test)] mod tests { use super::*; #[test] fn test_directory_url_staging() { let client = AcmeClient::new("test@example.com".to_string(), false); assert!(client.directory_url().contains("staging")); assert!(!client.is_production()); } #[test] fn test_directory_url_production() { let client = AcmeClient::new("test@example.com".to_string(), true); assert!(!client.directory_url().contains("staging")); assert!(client.is_production()); } }