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