Skip to main content

grapsus_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 grapsus_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 Grapsus-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 = serde_json::from_str(&creds_json)
98                .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.as_ref().ok_or(AcmeError::NoAccount)?;
165
166        // Create identifiers for all domains
167        let identifiers: Vec<Identifier> = self
168            .config
169            .domains
170            .iter()
171            .map(|d: &String| Identifier::Dns(d.clone()))
172            .collect();
173
174        info!(domains = ?self.config.domains, "Creating certificate order");
175
176        // Create the order
177        let mut order = account
178            .new_order(&NewOrder::new(&identifiers))
179            .await
180            .map_err(|e| AcmeError::OrderCreation(e.to_string()))?;
181
182        // Get authorizations and extract HTTP-01 challenges
183        let mut authorizations = order.authorizations();
184        let mut challenges = Vec::new();
185
186        while let Some(result) = authorizations.next().await {
187            let mut authz = result.map_err(|e| {
188                AcmeError::OrderCreation(format!("Failed to get authorization: {}", e))
189            })?;
190
191            let identifier = authz.identifier();
192            let domain = match &identifier.identifier {
193                Identifier::Dns(domain) => domain.clone(),
194                _ => continue,
195            };
196
197            debug!(domain = %domain, status = ?authz.status, "Processing authorization");
198
199            // Skip if already valid
200            if authz.status == AuthorizationStatus::Valid {
201                debug!(domain = %domain, "Authorization already valid");
202                continue;
203            }
204
205            // Find HTTP-01 challenge
206            let http01_challenge = authz
207                .challenge(ChallengeType::Http01)
208                .ok_or_else(|| AcmeError::NoHttp01Challenge(domain.clone()))?;
209
210            let key_authorization = http01_challenge.key_authorization();
211
212            challenges.push(ChallengeInfo {
213                domain,
214                token: http01_challenge.token.clone(),
215                key_authorization: key_authorization.as_str().to_string(),
216                url: http01_challenge.url.clone(),
217            });
218        }
219
220        Ok((order, challenges))
221    }
222
223    /// Order a certificate using DNS-01 challenges
224    ///
225    /// Creates a new certificate order and returns it along with the
226    /// DNS-01 challenge information for each domain.
227    ///
228    /// # Returns
229    ///
230    /// A tuple of (Order, Vec<`Dns01ChallengeInfo`>) containing the order and
231    /// DNS-01 challenge information for each domain.
232    pub async fn create_order_dns01(&self) -> Result<(Order, Vec<Dns01ChallengeInfo>), AcmeError> {
233        let account_guard = self.account.read().await;
234        let account = account_guard.as_ref().ok_or(AcmeError::NoAccount)?;
235
236        // Create identifiers for all domains
237        let identifiers: Vec<Identifier> = self
238            .config
239            .domains
240            .iter()
241            .map(|d: &String| Identifier::Dns(d.clone()))
242            .collect();
243
244        info!(domains = ?self.config.domains, "Creating certificate order with DNS-01 challenges");
245
246        // Create the order
247        let mut order = account
248            .new_order(&NewOrder::new(&identifiers))
249            .await
250            .map_err(|e| AcmeError::OrderCreation(e.to_string()))?;
251
252        // Get authorizations and extract DNS-01 challenges
253        let mut authorizations = order.authorizations();
254        let mut challenges = Vec::new();
255
256        while let Some(result) = authorizations.next().await {
257            let mut authz = result.map_err(|e| {
258                AcmeError::OrderCreation(format!("Failed to get authorization: {}", e))
259            })?;
260
261            let identifier = authz.identifier();
262            let domain = match &identifier.identifier {
263                Identifier::Dns(domain) => domain.clone(),
264                _ => continue,
265            };
266
267            debug!(domain = %domain, status = ?authz.status, "Processing DNS-01 authorization");
268
269            // Skip if already valid
270            if authz.status == AuthorizationStatus::Valid {
271                debug!(domain = %domain, "Authorization already valid");
272                continue;
273            }
274
275            // Find DNS-01 challenge
276            let dns01_challenge = authz
277                .challenge(ChallengeType::Dns01)
278                .ok_or_else(|| AcmeError::NoDns01Challenge(domain.clone()))?;
279
280            let key_authorization = dns01_challenge.key_authorization();
281
282            // Create DNS-01 challenge info with computed value
283            let challenge_info =
284                create_challenge_info(&domain, key_authorization.as_str(), &dns01_challenge.url);
285
286            challenges.push(challenge_info);
287        }
288
289        Ok((order, challenges))
290    }
291
292    /// Notify the ACME server that a challenge is ready for validation
293    ///
294    /// Iterates through the order's authorizations to find the challenge
295    /// matching the given URL and marks it as ready.
296    ///
297    /// # Arguments
298    ///
299    /// * `order` - The certificate order
300    /// * `challenge_url` - The URL of the challenge to validate
301    pub async fn validate_challenge(
302        &self,
303        order: &mut Order,
304        challenge_url: &str,
305    ) -> Result<(), AcmeError> {
306        debug!(challenge_url = %challenge_url, "Setting challenge ready");
307
308        // Iterate authorizations to find the matching challenge by URL
309        let mut authorizations = order.authorizations();
310        while let Some(result) = authorizations.next().await {
311            let mut authz = result.map_err(|e| AcmeError::ChallengeValidation {
312                domain: "unknown".to_string(),
313                message: format!("Failed to get authorization: {}", e),
314            })?;
315
316            // Determine which challenge type matches the URL
317            let matching_type = authz
318                .challenges
319                .iter()
320                .find(|c| c.url == challenge_url)
321                .map(|c| c.r#type.clone());
322
323            if let Some(challenge_type) = matching_type {
324                if let Some(mut challenge) = authz.challenge(challenge_type) {
325                    challenge
326                        .set_ready()
327                        .await
328                        .map_err(|e| AcmeError::ChallengeValidation {
329                            domain: "unknown".to_string(),
330                            message: e.to_string(),
331                        })?;
332                    return Ok(());
333                }
334            }
335        }
336
337        Err(AcmeError::ChallengeValidation {
338            domain: "unknown".to_string(),
339            message: format!("Challenge not found for URL: {}", challenge_url),
340        })
341    }
342
343    /// Wait for the order to become ready (all challenges validated)
344    ///
345    /// Polls the order status until it becomes ready or times out.
346    pub async fn wait_for_order_ready(&self, order: &mut Order) -> Result<(), AcmeError> {
347        let deadline = tokio::time::Instant::now() + CHALLENGE_TIMEOUT;
348
349        loop {
350            let state = order
351                .refresh()
352                .await
353                .map_err(|e| AcmeError::OrderCreation(format!("Failed to refresh order: {}", e)))?;
354
355            match state.status {
356                OrderStatus::Ready => {
357                    info!("Order is ready for finalization");
358                    return Ok(());
359                }
360                OrderStatus::Invalid => {
361                    error!("Order became invalid");
362                    return Err(AcmeError::OrderCreation("Order became invalid".to_string()));
363                }
364                OrderStatus::Valid => {
365                    info!("Order is already valid (certificate issued)");
366                    return Ok(());
367                }
368                OrderStatus::Pending | OrderStatus::Processing => {
369                    if tokio::time::Instant::now() > deadline {
370                        return Err(AcmeError::Timeout(
371                            "Timed out waiting for order to become ready".to_string(),
372                        ));
373                    }
374                    trace!(status = ?state.status, "Order not ready yet, waiting...");
375                    tokio::time::sleep(Duration::from_secs(2)).await;
376                }
377            }
378        }
379    }
380
381    /// Finalize the order and retrieve the certificate
382    ///
383    /// Generates a CSR, submits it to the ACME server, and retrieves
384    /// the issued certificate.
385    ///
386    /// # Returns
387    ///
388    /// A tuple of (certificate_pem, private_key_pem, expiry_date)
389    pub async fn finalize_order(
390        &self,
391        order: &mut Order,
392    ) -> Result<(String, String, DateTime<Utc>), AcmeError> {
393        info!("Finalizing certificate order");
394
395        // Generate a new private key for the certificate
396        let cert_key = rcgen::KeyPair::generate()
397            .map_err(|e| AcmeError::Finalization(format!("Failed to generate key: {}", e)))?;
398
399        // Create CSR with all domains
400        let mut params = rcgen::CertificateParams::new(self.config.domains.clone())
401            .map_err(|e| AcmeError::Finalization(format!("Failed to create CSR params: {}", e)))?;
402
403        // Set the Common Name to the first domain — rcgen defaults to "rcgen self signed cert"
404        // which ACME CAs reject as an invalid domain name
405        let mut dn = rcgen::DistinguishedName::new();
406        dn.push(rcgen::DnType::CommonName, self.config.domains[0].clone());
407        params.distinguished_name = dn;
408
409        // Serialize CSR with the key pair (rcgen 0.14 API)
410        let csr_request = params
411            .serialize_request(&cert_key)
412            .map_err(|e| AcmeError::Finalization(format!("Failed to serialize CSR: {}", e)))?;
413        let csr = csr_request.der().to_vec();
414
415        // Submit CSR and finalize
416        order
417            .finalize_csr(&csr)
418            .await
419            .map_err(|e| AcmeError::Finalization(format!("Failed to finalize order: {}", e)))?;
420
421        // Wait for certificate to be issued
422        let deadline = tokio::time::Instant::now() + DEFAULT_TIMEOUT;
423        let cert_chain = loop {
424            let state = order
425                .refresh()
426                .await
427                .map_err(|e| AcmeError::Finalization(format!("Failed to refresh order: {}", e)))?;
428
429            match state.status {
430                OrderStatus::Valid => {
431                    let cert_chain = order.certificate().await.map_err(|e| {
432                        AcmeError::Finalization(format!("Failed to get certificate: {}", e))
433                    })?;
434                    break cert_chain.ok_or_else(|| {
435                        AcmeError::Finalization("No certificate in response".to_string())
436                    })?;
437                }
438                OrderStatus::Invalid => {
439                    return Err(AcmeError::Finalization("Order became invalid".to_string()));
440                }
441                _ => {
442                    if tokio::time::Instant::now() > deadline {
443                        return Err(AcmeError::Timeout(
444                            "Timed out waiting for certificate".to_string(),
445                        ));
446                    }
447                    tokio::time::sleep(Duration::from_secs(1)).await;
448                }
449            }
450        };
451
452        // Get the private key PEM
453        let key_pem = cert_key.serialize_pem();
454
455        // Parse certificate to get expiry date
456        let expiry = parse_certificate_expiry(&cert_chain)?;
457
458        info!(
459            domains = ?self.config.domains,
460            expires = %expiry,
461            "Certificate issued successfully"
462        );
463
464        Ok((cert_chain, key_pem, expiry))
465    }
466
467    /// Check if a certificate exists and needs renewal
468    pub fn needs_renewal(&self, domain: &str) -> Result<bool, AcmeError> {
469        Ok(self
470            .storage
471            .needs_renewal(domain, self.config.renew_before_days)?)
472    }
473}
474
475/// Information about an HTTP-01 challenge
476#[derive(Debug, Clone)]
477pub struct ChallengeInfo {
478    /// Domain this challenge is for
479    pub domain: String,
480    /// Challenge token (appears in URL path)
481    pub token: String,
482    /// Key authorization (the response content)
483    pub key_authorization: String,
484    /// Challenge URL for validation notification
485    pub url: String,
486}
487
488/// Parse certificate PEM to extract expiry date
489fn parse_certificate_expiry(cert_pem: &str) -> Result<DateTime<Utc>, AcmeError> {
490    use x509_parser::prelude::*;
491
492    // Parse PEM
493    let (_, pem) = pem::parse_x509_pem(cert_pem.as_bytes())
494        .map_err(|e| AcmeError::CertificateParse(format!("Failed to parse PEM: {}", e)))?;
495
496    // Parse X.509 certificate
497    let (_, cert) = X509Certificate::from_der(&pem.contents)
498        .map_err(|e| AcmeError::CertificateParse(format!("Failed to parse certificate: {}", e)))?;
499
500    // Get expiry time
501    let not_after = cert.validity().not_after;
502    let timestamp = not_after.timestamp();
503
504    DateTime::from_timestamp(timestamp, 0)
505        .ok_or_else(|| AcmeError::CertificateParse("Invalid expiry timestamp".to_string()))
506}
507
508impl std::fmt::Debug for AcmeClient {
509    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510        f.debug_struct("AcmeClient")
511            .field("config", &self.config)
512            .field(
513                "has_account",
514                &self
515                    .account
516                    .try_read()
517                    .map(|a| a.is_some())
518                    .unwrap_or(false),
519            )
520            .finish()
521    }
522}