Skip to main content

zentinel_proxy/acme/
client.rs

1//! ACME client wrapper around instant-acme
2//!
3//! Provides a high-level interface for ACME protocol operations including:
4//! - Account creation and management
5//! - Certificate ordering
6//! - Challenge handling (HTTP-01 and DNS-01)
7//! - Certificate finalization
8
9use std::sync::Arc;
10use std::time::Duration;
11
12use base64::engine::general_purpose::URL_SAFE_NO_PAD;
13use base64::Engine;
14use chrono::{DateTime, Utc};
15use instant_acme::{
16    Account, AuthorizationStatus, ChallengeType, Identifier, LetsEncrypt, NewAccount, NewOrder,
17    Order, OrderStatus, RetryPolicy,
18};
19use tokio::sync::RwLock;
20use tracing::{debug, error, info, trace, warn};
21
22use zentinel_config::server::AcmeConfig;
23
24use super::dns::challenge::{create_challenge_info, Dns01ChallengeInfo};
25use super::error::{is_retryable_acme_error, AcmeError, ACME_RETRY_BACKOFF, ACME_RETRY_MAX};
26use super::storage::{CertificateStorage, StoredAccountCredentials};
27
28/// Let's Encrypt production directory URL
29const LETSENCRYPT_PRODUCTION: &str = "https://acme-v02.api.letsencrypt.org/directory";
30/// Let's Encrypt staging directory URL
31const LETSENCRYPT_STAGING: &str = "https://acme-staging-v02.api.letsencrypt.org/directory";
32
33/// Default timeout for ACME operations
34const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
35/// Timeout for challenge validation
36const CHALLENGE_TIMEOUT: Duration = Duration::from_secs(120);
37
38/// Retry transient ACME transport failures with exponential backoff.
39///
40/// Note on `create_order` retries: a retry creates a new ACME Order;
41/// the previous `pending` order is left to expire by the CA. This is
42/// harmless (CA auto-expires pending orders) and `429 rateLimited` is
43/// explicitly non-retryable, so quota is not burned.
44async fn retry_acme<F, Fut, T>(mut op: F) -> Result<T, AcmeError>
45where
46    F: FnMut() -> Fut,
47    Fut: std::future::Future<Output = Result<T, AcmeError>>,
48{
49    let mut backoff = ACME_RETRY_BACKOFF;
50    for attempt in 0..ACME_RETRY_MAX {
51        match op().await {
52            Ok(v) => return Ok(v),
53            Err(e) if is_retryable_acme_error(&e) && attempt + 1 < ACME_RETRY_MAX => {
54                tracing::info!(
55                    attempt = attempt + 1,
56                    max_retries = ACME_RETRY_MAX,
57                    backoff_secs = backoff.as_secs(),
58                    error = %e,
59                    "ACME transient failure, retrying"
60                );
61                tokio::time::sleep(backoff).await;
62                backoff = backoff.saturating_mul(2);
63            }
64            Err(e) => return Err(e),
65        }
66    }
67    unreachable!("retry loop always returns")
68}
69
70/// ACME client for automatic certificate management
71///
72/// Wraps the `instant-acme` library and provides Zentinel-specific functionality
73/// for certificate ordering, challenge handling, and persistence.
74pub struct AcmeClient {
75    /// ACME account (lazy initialized)
76    account: Arc<RwLock<Option<Account>>>,
77    /// Configuration
78    config: AcmeConfig,
79    /// Certificate storage
80    storage: Arc<CertificateStorage>,
81}
82
83impl AcmeClient {
84    /// Create a new ACME client
85    ///
86    /// # Arguments
87    ///
88    /// * `config` - ACME configuration from the listener
89    /// * `storage` - Certificate storage instance
90    pub fn new(config: AcmeConfig, storage: Arc<CertificateStorage>) -> Self {
91        Self {
92            account: Arc::new(RwLock::new(None)),
93            config,
94            storage,
95        }
96    }
97
98    /// Get the ACME configuration
99    pub fn config(&self) -> &AcmeConfig {
100        &self.config
101    }
102
103    /// Get the certificate storage
104    pub fn storage(&self) -> &CertificateStorage {
105        &self.storage
106    }
107
108    /// Get the ACME directory URL based on configuration
109    fn directory_url(&self) -> &str {
110        if let Some(ref url) = self.config.server_url {
111            url
112        } else if self.config.staging {
113            LETSENCRYPT_STAGING
114        } else {
115            LETSENCRYPT_PRODUCTION
116        }
117    }
118
119    /// Initialize or load the ACME account
120    ///
121    /// If account credentials exist in storage, loads them. Otherwise,
122    /// creates a new account with Let's Encrypt.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error if account creation or loading fails.
127    pub async fn init_account(&self) -> Result<(), AcmeError> {
128        retry_acme(|| async { self.init_account_once().await }).await
129    }
130
131    /// Ensure ACME account is initialized, initializing if needed.
132    ///
133    /// Used by `RenewalScheduler` to lazily recover after a transient
134    /// `init_account` failure that was deferred to background during
135    /// startup. If the account is already present the call is a no-op.
136    pub async fn ensure_account(&self) -> Result<(), AcmeError> {
137        if self.account.read().await.is_some() {
138            return Ok(());
139        }
140        self.init_account().await
141    }
142
143    async fn init_account_once(&self) -> Result<(), AcmeError> {
144        // Check for existing account credentials (stored as JSON)
145        if let Some(creds_json) = self.storage.load_credentials_json()? {
146            info!("Loading existing ACME account from storage");
147
148            // Deserialize credentials
149            let credentials: instant_acme::AccountCredentials = serde_json::from_str(&creds_json)
150                .map_err(|e| {
151                AcmeError::AccountCreation(format!("Failed to deserialize credentials: {}", e))
152            })?;
153
154            // Reconstruct account from stored credentials
155            let account = Account::builder()
156                .map_err(|e| AcmeError::AccountCreation(e.to_string()))?
157                .from_credentials(credentials)
158                .await
159                .map_err(|e| AcmeError::AccountCreation(e.to_string()))?;
160
161            *self.account.write().await = Some(account);
162            info!("ACME account loaded successfully");
163            return Ok(());
164        }
165
166        // Create new account
167        info!(
168            email = %self.config.email,
169            server_url = %self.directory_url(),
170            key_type = ?self.config.key_type,
171            "Creating new ACME account"
172        );
173
174        let eab = if let Some(ref eab_config) = self.config.eab {
175            let hmac_key = URL_SAFE_NO_PAD.decode(&eab_config.hmac_key).map_err(|e| {
176                AcmeError::AccountCreation(format!("Invalid EAB HMAC key (base64url): {}", e))
177            })?;
178            Some(instant_acme::ExternalAccountKey::new(
179                eab_config.kid.clone(),
180                &hmac_key,
181            ))
182        } else {
183            None
184        };
185
186        let (account, credentials) = Account::builder()
187            .map_err(|e| AcmeError::AccountCreation(e.to_string()))?
188            .create(
189                &NewAccount {
190                    contact: &[&format!("mailto:{}", self.config.email)],
191                    terms_of_service_agreed: true,
192                    only_return_existing: false,
193                },
194                self.directory_url().to_owned(),
195                eab.as_ref(),
196            )
197            .await
198            .map_err(|e| AcmeError::AccountCreation(e.to_string()))?;
199
200        // Store credentials as JSON (AccountCredentials is serializable)
201        let creds_json = serde_json::to_string_pretty(&credentials).map_err(|e| {
202            AcmeError::AccountCreation(format!("Failed to serialize credentials: {}", e))
203        })?;
204        self.storage.save_credentials_json(&creds_json)?;
205
206        *self.account.write().await = Some(account);
207        info!("ACME account created successfully");
208
209        Ok(())
210    }
211
212    /// Order a certificate for the configured domains
213    ///
214    /// Creates a new certificate order and returns it along with the
215    /// authorization challenges that need to be completed.
216    ///
217    /// # Returns
218    ///
219    /// A tuple of (Order, Vec<`ChallengeInfo`>) containing the order and
220    /// HTTP-01 challenge information for each domain.
221    pub async fn create_order(&self) -> Result<(Order, Vec<ChallengeInfo>), AcmeError> {
222        retry_acme(|| async { self.create_order_once().await }).await
223    }
224
225    async fn create_order_once(&self) -> Result<(Order, Vec<ChallengeInfo>), AcmeError> {
226        let account_guard = self.account.read().await;
227        let account = account_guard.as_ref().ok_or(AcmeError::NoAccount)?;
228
229        // Create identifiers for all domains
230        let identifiers: Vec<Identifier> = self
231            .config
232            .domains
233            .iter()
234            .map(|d: &String| Identifier::Dns(d.clone()))
235            .collect();
236
237        info!(domains = ?self.config.domains, "Creating certificate order");
238
239        // Create the order
240        let mut order = account
241            .new_order(&NewOrder::new(&identifiers))
242            .await
243            .map_err(|e| AcmeError::OrderCreation(e.to_string()))?;
244
245        // Get authorizations and extract HTTP-01 challenges
246        let mut authorizations = order.authorizations();
247        let mut challenges = Vec::new();
248
249        while let Some(result) = authorizations.next().await {
250            let mut authz = result.map_err(|e| {
251                AcmeError::OrderCreation(format!("Failed to get authorization: {}", e))
252            })?;
253
254            let identifier = authz.identifier();
255            let domain = match &identifier.identifier {
256                Identifier::Dns(domain) => domain.clone(),
257                _ => continue,
258            };
259
260            debug!(domain = %domain, status = ?authz.status, "Processing authorization");
261
262            // Skip if already valid
263            if authz.status == AuthorizationStatus::Valid {
264                debug!(domain = %domain, "Authorization already valid");
265                continue;
266            }
267
268            // Find HTTP-01 challenge
269            let http01_challenge = authz
270                .challenge(ChallengeType::Http01)
271                .ok_or_else(|| AcmeError::NoHttp01Challenge(domain.clone()))?;
272
273            let key_authorization = http01_challenge.key_authorization();
274
275            challenges.push(ChallengeInfo {
276                domain,
277                token: http01_challenge.token.clone(),
278                key_authorization: key_authorization.as_str().to_string(),
279                url: http01_challenge.url.clone(),
280            });
281        }
282
283        Ok((order, challenges))
284    }
285
286    /// Order a certificate using DNS-01 challenges
287    ///
288    /// Creates a new certificate order and returns it along with the
289    /// DNS-01 challenge information for each domain.
290    ///
291    /// # Returns
292    ///
293    /// A tuple of (Order, Vec<`Dns01ChallengeInfo`>) containing the order and
294    /// DNS-01 challenge information for each domain.
295    pub async fn create_order_dns01(&self) -> Result<(Order, Vec<Dns01ChallengeInfo>), AcmeError> {
296        retry_acme(|| async { self.create_order_dns01_once().await }).await
297    }
298
299    async fn create_order_dns01_once(&self) -> Result<(Order, Vec<Dns01ChallengeInfo>), AcmeError> {
300        let account_guard = self.account.read().await;
301        let account = account_guard.as_ref().ok_or(AcmeError::NoAccount)?;
302
303        // Create identifiers for all domains
304        let identifiers: Vec<Identifier> = self
305            .config
306            .domains
307            .iter()
308            .map(|d: &String| Identifier::Dns(d.clone()))
309            .collect();
310
311        info!(domains = ?self.config.domains, "Creating certificate order with DNS-01 challenges");
312
313        // Create the order
314        let mut order = account
315            .new_order(&NewOrder::new(&identifiers))
316            .await
317            .map_err(|e| AcmeError::OrderCreation(e.to_string()))?;
318
319        // Get authorizations and extract DNS-01 challenges
320        let mut authorizations = order.authorizations();
321        let mut challenges = Vec::new();
322
323        while let Some(result) = authorizations.next().await {
324            let mut authz = result.map_err(|e| {
325                AcmeError::OrderCreation(format!("Failed to get authorization: {}", e))
326            })?;
327
328            let identifier = authz.identifier();
329            let domain = match &identifier.identifier {
330                Identifier::Dns(domain) => domain.clone(),
331                _ => continue,
332            };
333
334            debug!(domain = %domain, status = ?authz.status, "Processing DNS-01 authorization");
335
336            // Skip if already valid
337            if authz.status == AuthorizationStatus::Valid {
338                debug!(domain = %domain, "Authorization already valid");
339                continue;
340            }
341
342            // Find DNS-01 challenge
343            let dns01_challenge = authz
344                .challenge(ChallengeType::Dns01)
345                .ok_or_else(|| AcmeError::NoDns01Challenge(domain.clone()))?;
346
347            let key_authorization = dns01_challenge.key_authorization();
348
349            // Create DNS-01 challenge info with computed value
350            let challenge_info =
351                create_challenge_info(&domain, key_authorization.as_str(), &dns01_challenge.url);
352
353            challenges.push(challenge_info);
354        }
355
356        Ok((order, challenges))
357    }
358
359    /// Notify the ACME server that a challenge is ready for validation
360    ///
361    /// Iterates through the order's authorizations to find the challenge
362    /// matching the given URL and marks it as ready.
363    ///
364    /// # Arguments
365    ///
366    /// * `order` - The certificate order
367    /// * `challenge_url` - The URL of the challenge to validate
368    pub async fn validate_challenge(
369        &self,
370        order: &mut Order,
371        challenge_url: &str,
372    ) -> Result<(), AcmeError> {
373        debug!(challenge_url = %challenge_url, "Setting challenge ready");
374
375        // Iterate authorizations to find the matching challenge by URL
376        let mut authorizations = order.authorizations();
377        while let Some(result) = authorizations.next().await {
378            let mut authz = result.map_err(|e| AcmeError::ChallengeValidation {
379                domain: "unknown".to_string(),
380                message: format!("Failed to get authorization: {}", e),
381            })?;
382
383            // Determine which challenge type matches the URL
384            let matching_type = authz
385                .challenges
386                .iter()
387                .find(|c| c.url == challenge_url)
388                .map(|c| c.r#type.clone());
389
390            if let Some(challenge_type) = matching_type {
391                if let Some(mut challenge) = authz.challenge(challenge_type) {
392                    challenge
393                        .set_ready()
394                        .await
395                        .map_err(|e| AcmeError::ChallengeValidation {
396                            domain: "unknown".to_string(),
397                            message: e.to_string(),
398                        })?;
399                    return Ok(());
400                }
401            }
402        }
403
404        Err(AcmeError::ChallengeValidation {
405            domain: "unknown".to_string(),
406            message: format!("Challenge not found for URL: {}", challenge_url),
407        })
408    }
409
410    /// Wait for the order to become ready (all challenges validated)
411    ///
412    /// Polls the order status until it becomes ready or times out.
413    pub async fn wait_for_order_ready(&self, order: &mut Order) -> Result<(), AcmeError> {
414        let deadline = tokio::time::Instant::now() + CHALLENGE_TIMEOUT;
415
416        loop {
417            let state = order
418                .refresh()
419                .await
420                .map_err(|e| AcmeError::OrderCreation(format!("Failed to refresh order: {}", e)))?;
421
422            match state.status {
423                OrderStatus::Ready => {
424                    info!("Order is ready for finalization");
425                    return Ok(());
426                }
427                OrderStatus::Invalid => {
428                    error!("Order became invalid");
429                    return Err(AcmeError::OrderCreation("Order became invalid".to_string()));
430                }
431                OrderStatus::Valid => {
432                    info!("Order is already valid (certificate issued)");
433                    return Ok(());
434                }
435                OrderStatus::Pending | OrderStatus::Processing => {
436                    if tokio::time::Instant::now() > deadline {
437                        return Err(AcmeError::Timeout(
438                            "Timed out waiting for order to become ready".to_string(),
439                        ));
440                    }
441                    trace!(status = ?state.status, "Order not ready yet, waiting...");
442                    tokio::time::sleep(Duration::from_secs(2)).await;
443                }
444            }
445        }
446    }
447
448    /// Finalize the order and retrieve the certificate
449    ///
450    /// Generates a CSR, submits it to the ACME server, and retrieves
451    /// the issued certificate.
452    ///
453    /// # Returns
454    ///
455    /// A tuple of (certificate_pem, private_key_pem, expiry_date)
456    pub async fn finalize_order(
457        &self,
458        order: &mut Order,
459    ) -> Result<(String, String, DateTime<Utc>), AcmeError> {
460        // retry_acme cannot hold &mut Order across the FnMut boundary
461        // (E0658: async block escapes with borrowed &mut). Keep the
462        // retry loop inline for this &mut case — same policy as
463        // retry_acme (ACME_RETRY_MAX / BACKOFF + is_retryable).
464        let mut backoff = ACME_RETRY_BACKOFF;
465        for attempt in 0..ACME_RETRY_MAX {
466            match self.finalize_order_once(order).await {
467                Ok(v) => return Ok(v),
468                Err(e) if is_retryable_acme_error(&e) && attempt + 1 < ACME_RETRY_MAX => {
469                    tracing::info!(
470                        attempt = attempt + 1,
471                        max_retries = ACME_RETRY_MAX,
472                        backoff_secs = backoff.as_secs(),
473                        error = %e,
474                        "ACME transient failure, retrying"
475                    );
476                    tokio::time::sleep(backoff).await;
477                    backoff = backoff.saturating_mul(2);
478                }
479                Err(e) => return Err(e),
480            }
481        }
482        unreachable!("retry loop always returns")
483    }
484
485    async fn finalize_order_once(
486        &self,
487        order: &mut Order,
488    ) -> Result<(String, String, DateTime<Utc>), AcmeError> {
489        info!("Finalizing certificate order");
490
491        // Map config key type to rcgen signature algorithm
492        use zentinel_config::server::AcmeKeyType;
493        let algo = match self.config.key_type {
494            AcmeKeyType::EcdsaP256 => &rcgen::PKCS_ECDSA_P256_SHA256,
495            AcmeKeyType::EcdsaP384 => &rcgen::PKCS_ECDSA_P384_SHA384,
496        };
497
498        // Generate a new private key for the certificate
499        let cert_key = rcgen::KeyPair::generate_for(algo)
500            .map_err(|e| AcmeError::Finalization(format!("Failed to generate key: {}", e)))?;
501
502        // Create CSR with all domains
503        let mut params = rcgen::CertificateParams::new(self.config.domains.clone())
504            .map_err(|e| AcmeError::Finalization(format!("Failed to create CSR params: {}", e)))?;
505
506        // Set the Common Name to the first domain — rcgen defaults to "rcgen self signed cert"
507        // which ACME CAs reject as an invalid domain name
508        let mut dn = rcgen::DistinguishedName::new();
509        dn.push(rcgen::DnType::CommonName, self.config.domains[0].clone());
510        params.distinguished_name = dn;
511
512        // Serialize CSR with the key pair (rcgen 0.14 API)
513        let csr_request = params
514            .serialize_request(&cert_key)
515            .map_err(|e| AcmeError::Finalization(format!("Failed to serialize CSR: {}", e)))?;
516        let csr = csr_request.der().to_vec();
517
518        // Submit CSR and finalize
519        order
520            .finalize_csr(&csr)
521            .await
522            .map_err(|e| AcmeError::Finalization(format!("Failed to finalize order: {}", e)))?;
523
524        // Wait for certificate to be issued
525        let deadline = tokio::time::Instant::now() + DEFAULT_TIMEOUT;
526        let cert_chain = loop {
527            let state = order
528                .refresh()
529                .await
530                .map_err(|e| AcmeError::Finalization(format!("Failed to refresh order: {}", e)))?;
531
532            match state.status {
533                OrderStatus::Valid => {
534                    let cert_chain = order.certificate().await.map_err(|e| {
535                        AcmeError::Finalization(format!("Failed to get certificate: {}", e))
536                    })?;
537                    break cert_chain.ok_or_else(|| {
538                        AcmeError::Finalization("No certificate in response".to_string())
539                    })?;
540                }
541                OrderStatus::Invalid => {
542                    return Err(AcmeError::Finalization("Order became invalid".to_string()));
543                }
544                _ => {
545                    if tokio::time::Instant::now() > deadline {
546                        return Err(AcmeError::Timeout(
547                            "Timed out waiting for certificate".to_string(),
548                        ));
549                    }
550                    tokio::time::sleep(ACME_RETRY_BACKOFF).await;
551                }
552            }
553        };
554
555        // Get the private key PEM
556        let key_pem = cert_key.serialize_pem();
557
558        // Parse certificate to get expiry date
559        let expiry = parse_certificate_expiry(&cert_chain)?;
560
561        info!(
562            domains = ?self.config.domains,
563            expires = %expiry,
564            "Certificate issued successfully"
565        );
566
567        Ok((cert_chain, key_pem, expiry))
568    }
569
570    /// Check if a certificate exists and needs renewal
571    pub fn needs_renewal(&self, domain: &str) -> Result<bool, AcmeError> {
572        Ok(self
573            .storage
574            .needs_renewal(domain, self.config.renew_before_days)?)
575    }
576}
577
578/// Information about an HTTP-01 challenge
579#[derive(Debug, Clone)]
580pub struct ChallengeInfo {
581    /// Domain this challenge is for
582    pub domain: String,
583    /// Challenge token (appears in URL path)
584    pub token: String,
585    /// Key authorization (the response content)
586    pub key_authorization: String,
587    /// Challenge URL for validation notification
588    pub url: String,
589}
590
591/// Parse certificate PEM to extract expiry date
592fn parse_certificate_expiry(cert_pem: &str) -> Result<DateTime<Utc>, AcmeError> {
593    use x509_parser::prelude::*;
594
595    // Parse PEM
596    let (_, pem) = pem::parse_x509_pem(cert_pem.as_bytes())
597        .map_err(|e| AcmeError::CertificateParse(format!("Failed to parse PEM: {}", e)))?;
598
599    // Parse X.509 certificate
600    let (_, cert) = X509Certificate::from_der(&pem.contents)
601        .map_err(|e| AcmeError::CertificateParse(format!("Failed to parse certificate: {}", e)))?;
602
603    // Get expiry time
604    let not_after = cert.validity().not_after;
605    let timestamp = not_after.timestamp();
606
607    DateTime::from_timestamp(timestamp, 0)
608        .ok_or_else(|| AcmeError::CertificateParse("Invalid expiry timestamp".to_string()))
609}
610
611impl std::fmt::Debug for AcmeClient {
612    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
613        f.debug_struct("AcmeClient")
614            .field("config", &self.config)
615            .field(
616                "has_account",
617                &self
618                    .account
619                    .try_read()
620                    .map(|a| a.is_some())
621                    .unwrap_or(false),
622            )
623            .finish()
624    }
625}