Skip to main content

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