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