bws_web_server/ssl/
acme.rs

1use instant_acme::{
2    Account, AuthorizationStatus, ChallengeType, Identifier, LetsEncrypt, NewAccount, NewOrder,
3    OrderStatus,
4};
5use log::{debug, error, info, warn};
6use rcgen::{Certificate as RcgenCertificate, CertificateParams, DnType};
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9use std::time::Duration;
10use tokio::fs;
11use tokio::time::sleep;
12
13#[derive(Debug, Clone, Deserialize, Serialize)]
14pub struct AcmeConfig {
15    pub directory_url: String,
16    pub contact_email: String,
17    pub terms_agreed: bool,
18    pub challenge_dir: String,
19    pub account_key_file: String,
20    pub enabled: bool,
21    pub staging: bool,
22}
23
24impl Default for AcmeConfig {
25    fn default() -> Self {
26        Self {
27            directory_url: "https://acme-v02.api.letsencrypt.org/directory".to_string(),
28            contact_email: "admin@example.com".to_string(),
29            terms_agreed: false,
30            challenge_dir: "./acme-challenges".to_string(),
31            account_key_file: "./acme-account.key".to_string(),
32            enabled: false,
33            staging: false,
34        }
35    }
36}
37
38#[derive(Debug)]
39pub struct AcmeClient {
40    config: AcmeConfig,
41}
42
43impl AcmeClient {
44    pub fn new(config: AcmeConfig) -> Self {
45        Self { config }
46    }
47
48    /// Initialize the ACME client by creating or loading an account
49    pub fn initialize(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
50        if !self.config.enabled {
51            return Ok(());
52        }
53
54        // For now, just verify the config is valid
55        Ok(())
56    }
57
58    /// Request a certificate for the given domains
59    pub async fn obtain_certificate(
60        &self,
61        domains: &[String],
62    ) -> Result<(String, String), Box<dyn std::error::Error + Send + Sync>> {
63        if !self.config.enabled {
64            return Err("ACME is disabled".into());
65        }
66
67        info!("Creating ACME account for domains: {:?}", domains);
68
69        let le_url = if self.config.staging {
70            LetsEncrypt::Staging.url()
71        } else {
72            LetsEncrypt::Production.url()
73        };
74        info!("Using Let's Encrypt URL: {}", le_url);
75
76        // Create account
77        let (account, _credentials) = Account::create(
78            &NewAccount {
79                contact: &[&format!("mailto:{}", self.config.contact_email)],
80                terms_of_service_agreed: self.config.terms_agreed,
81                only_return_existing: false,
82            },
83            le_url,
84            None, // Let instant-acme generate the key
85        )
86        .await?;
87
88        // Create identifiers for all domains
89        let identifiers: Vec<Identifier> = domains
90            .iter()
91            .map(|domain| Identifier::Dns(domain.clone()))
92            .collect();
93
94        info!("Requesting certificate for domains: {:?}", domains);
95        info!(
96            "ACME identifiers being sent to Let's Encrypt: {:?}",
97            identifiers
98        );
99
100        // Create a new order
101        let mut order = account
102            .new_order(&NewOrder {
103                identifiers: &identifiers,
104            })
105            .await?;
106
107        info!("Created ACME order, processing authorizations");
108
109        // Process all authorizations
110        let authorizations = order.authorizations().await?;
111        for authz in authorizations {
112            let challenge = authz
113                .challenges
114                .iter()
115                .find(|c| matches!(c.r#type, ChallengeType::Http01))
116                .ok_or("No HTTP-01 challenge found")?;
117
118            let Identifier::Dns(domain) = &authz.identifier;
119
120            info!("Processing HTTP-01 challenge for domain: {}", domain);
121
122            // Get the key authorization
123            let key_auth = order.key_authorization(challenge);
124            let key_auth_str = key_auth.as_str();
125
126            info!(
127                "Challenge details for domain {}: token={}, key_auth={}",
128                domain, challenge.token, key_auth_str
129            );
130
131            // Save challenge response to file system
132            let challenge_dir = PathBuf::from(&self.config.challenge_dir)
133                .join(".well-known")
134                .join("acme-challenge");
135
136            tokio::fs::create_dir_all(&challenge_dir).await?;
137            let challenge_file = challenge_dir.join(&challenge.token);
138            fs::write(&challenge_file, key_auth_str).await?;
139
140            info!(
141                "Saved challenge for domain {} to {} with content: {}",
142                domain,
143                challenge_file.display(),
144                key_auth_str
145            );
146
147            // Verify the file was written correctly
148            match fs::read_to_string(&challenge_file).await {
149                Ok(content) => {
150                    info!("Verified challenge file content: {}", content);
151                }
152                Err(e) => {
153                    error!("Failed to verify challenge file: {}", e);
154                }
155            }
156
157            // Give the challenge a moment to propagate and be available
158            sleep(Duration::from_millis(500)).await;
159            info!(
160                "Challenge file ready, signaling to Let's Encrypt for domain: {}",
161                domain
162            );
163
164            // Tell the server we're ready
165            order.set_challenge_ready(&challenge.url).await?;
166
167            info!("Challenge ready signal sent for domain: {}", domain);
168        }
169
170        // Wait for all challenges to be validated
171        for identifier in &identifiers {
172            let Identifier::Dns(domain) = identifier;
173            self.wait_for_challenge_validation(&account, &mut order, domain)
174                .await?;
175        }
176
177        // Wait for the order to be ready
178        self.wait_for_order_ready(&account, &mut order).await?;
179
180        // Generate a CSR with properly configured subject
181        info!("Generating CSR for domains: {:?}", domains);
182        for (i, domain) in domains.iter().enumerate() {
183            info!(
184                "Domain {}: '{}' (length: {}, chars: {:?})",
185                i,
186                domain,
187                domain.len(),
188                domain.chars().collect::<Vec<_>>()
189            );
190        }
191
192        // Create certificate parameters with proper subject configuration
193        let mut params = CertificateParams::new(domains.to_vec());
194
195        // Set the subject to the first domain to avoid "rcgen self signed cert"
196        if let Some(primary_domain) = domains.first() {
197            params
198                .distinguished_name
199                .push(DnType::CommonName, primary_domain.clone());
200            info!("Set CSR subject CN to: {}", primary_domain);
201        }
202
203        // Generate the certificate with proper subject
204        let cert = RcgenCertificate::from_params(params)?;
205        let csr = cert.serialize_request_der()?;
206
207        // Finalize the order
208        info!("Finalizing ACME order with CSR");
209        order.finalize(&csr).await?;
210        info!("Order finalized successfully");
211
212        // Wait for certificate to be ready and download it
213        info!("Waiting for certificate to be ready...");
214        let cert_chain = self.wait_for_certificate(&account, &mut order).await?;
215        info!(
216            "Certificate downloaded successfully, length: {} bytes",
217            cert_chain.len()
218        );
219
220        // Convert to the format expected by rustls
221        let private_key = cert.serialize_private_key_pem();
222
223        info!(
224            "Successfully obtained certificate for domains: {:?}",
225            domains
226        );
227        Ok((cert_chain, private_key))
228    }
229
230    async fn wait_for_challenge_validation(
231        &self,
232        account: &Account,
233        order: &mut instant_acme::Order,
234        domain: &str,
235    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
236        info!("Waiting for challenge validation for: {}", domain);
237
238        for attempt in 0..30 {
239            // Wait up to 5 minutes
240            sleep(Duration::from_secs(10)).await;
241
242            info!(
243                "Challenge validation attempt {} for domain: {}",
244                attempt + 1,
245                domain
246            );
247
248            // Refresh order state
249            *order = account.order(order.url().to_string()).await?;
250            let authorizations = order.authorizations().await?;
251
252            for authz in authorizations {
253                let Identifier::Dns(auth_domain) = &authz.identifier;
254                if auth_domain == domain {
255                    info!("Authorization status for {}: {:?}", domain, authz.status);
256                    match authz.status {
257                        AuthorizationStatus::Valid => {
258                            info!("Challenge validated for: {}", domain);
259                            return Ok(());
260                        }
261                        AuthorizationStatus::Invalid => {
262                            // Log more details about why it failed
263                            error!(
264                                "Challenge validation failed for: {}. Authorization details: {:?}",
265                                domain, authz
266                            );
267                            for challenge in &authz.challenges {
268                                if challenge.r#type == ChallengeType::Http01 {
269                                    error!("HTTP-01 challenge details for {}: status={:?}, error={:?}, url={:?}", 
270                                           domain, challenge.status, challenge.error, challenge.url);
271                                }
272                            }
273                            return Err(
274                                format!("Challenge validation failed for: {}", domain).into()
275                            );
276                        }
277                        _ => {
278                            info!(
279                                "Authorization status for {}: {:?}, continuing to wait...",
280                                domain, authz.status
281                            );
282                        }
283                    }
284                }
285            }
286        }
287
288        Err(format!("Timeout waiting for challenge validation for: {}", domain).into())
289    }
290
291    async fn wait_for_order_ready(
292        &self,
293        account: &Account,
294        order: &mut instant_acme::Order,
295    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
296        for _ in 0..30 {
297            // Wait up to 5 minutes
298            sleep(Duration::from_secs(10)).await;
299
300            *order = account.order(order.url().to_string()).await?;
301            match order.state().status {
302                OrderStatus::Ready => {
303                    info!("Order is ready for finalization");
304                    return Ok(());
305                }
306                OrderStatus::Invalid => {
307                    return Err("Order became invalid".into());
308                }
309                _ => {
310                    debug!("Order status: {:?}", order.state().status);
311                }
312            }
313        }
314
315        Err("Timeout waiting for order to be ready".into())
316    }
317
318    async fn wait_for_certificate(
319        &self,
320        account: &Account,
321        order: &mut instant_acme::Order,
322    ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
323        for _ in 0..30 {
324            // Wait up to 5 minutes
325            sleep(Duration::from_secs(10)).await;
326
327            *order = account.order(order.url().to_string()).await?;
328            match order.state().status {
329                OrderStatus::Valid => {
330                    // Try to get the certificate directly from the order
331                    if let Some(cert_pem) = order.certificate().await? {
332                        info!("Certificate obtained successfully");
333                        return Ok(cert_pem);
334                    }
335                }
336                OrderStatus::Invalid => {
337                    return Err("Order became invalid while waiting for certificate".into());
338                }
339                _ => {
340                    debug!(
341                        "Order status while waiting for certificate: {:?}",
342                        order.state().status
343                    );
344                }
345            }
346        }
347
348        Err("Timeout waiting for certificate".into())
349    }
350
351    pub fn handles_acme_challenge(&self, path: &str) -> bool {
352        path.starts_with("/.well-known/acme-challenge/")
353    }
354
355    pub async fn get_acme_challenge_response(&self, token: &str) -> Option<String> {
356        if !self.config.enabled {
357            info!(
358                "ACME not enabled, cannot retrieve challenge for token: {}",
359                token
360            );
361            return None;
362        }
363
364        // Try to read from filesystem
365        let challenge_path = PathBuf::from(&self.config.challenge_dir)
366            .join(".well-known")
367            .join("acme-challenge")
368            .join(token);
369
370        info!(
371            "Looking for challenge token '{}' at path: {:?}",
372            token, challenge_path
373        );
374
375        if let Ok(content) = fs::read_to_string(&challenge_path).await {
376            info!("Found challenge content for token '{}': {}", token, content);
377            Some(content)
378        } else {
379            warn!(
380                "Challenge file not found for token '{}' at path: {:?}",
381                token, challenge_path
382            );
383            None
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use std::path::PathBuf;
392    use tempfile::TempDir;
393
394    #[tokio::test]
395    async fn test_challenge_file_saving_and_reading() {
396        // Create a temporary directory for testing
397        let temp_dir = TempDir::new().expect("Failed to create temp directory");
398        let challenge_dir = temp_dir.path().to_string_lossy().to_string();
399
400        // Create ACME config
401        let config = AcmeConfig {
402            enabled: true,
403            staging: true,
404            directory_url: "https://acme-staging-v02.api.letsencrypt.org/directory".to_string(),
405            contact_email: "test@example.com".to_string(),
406            terms_agreed: true,
407            challenge_dir: challenge_dir.clone(),
408            account_key_file: format!("{}/acme-account.key", challenge_dir),
409        };
410
411        // Create ACME client (won't actually connect to Let's Encrypt)
412        let client = AcmeClient::new(config.clone());
413
414        // Test token and key authorization
415        let test_token = "test_challenge_token_12345";
416        let test_key_auth = "test_key_authorization_67890.test_key_thumbprint";
417
418        // Manually create the challenge file structure (simulating what save_challenge does)
419        let full_challenge_dir = PathBuf::from(&challenge_dir)
420            .join(".well-known")
421            .join("acme-challenge");
422
423        tokio::fs::create_dir_all(&full_challenge_dir)
424            .await
425            .expect("Failed to create challenge directory");
426
427        let challenge_file = full_challenge_dir.join(test_token);
428        tokio::fs::write(&challenge_file, test_key_auth)
429            .await
430            .expect("Failed to write challenge file");
431
432        // Test reading the challenge response
433        let response = client.get_acme_challenge_response(test_token).await;
434
435        assert!(response.is_some(), "Challenge response should be found");
436        if let Some(response_content) = response {
437            assert_eq!(
438                response_content, test_key_auth,
439                "Challenge response content should match"
440            );
441        }
442
443        // Test with non-existent token
444        let missing_response = client
445            .get_acme_challenge_response("non_existent_token")
446            .await;
447        assert!(
448            missing_response.is_none(),
449            "Non-existent challenge should return None"
450        );
451
452        // Test handles_acme_challenge
453        assert!(client.handles_acme_challenge("/.well-known/acme-challenge/some_token"));
454        assert!(!client.handles_acme_challenge("/some/other/path"));
455        assert!(!client.handles_acme_challenge("/.well-known/other-challenge/token"));
456    }
457
458    #[test]
459    fn test_challenge_path_handling() {
460        let config = AcmeConfig {
461            enabled: true,
462            staging: true,
463            directory_url: "https://acme-staging-v02.api.letsencrypt.org/directory".to_string(),
464            contact_email: "test@example.com".to_string(),
465            terms_agreed: true,
466            challenge_dir: "./test-challenges".to_string(),
467            account_key_file: "./test-acme-account.key".to_string(),
468        };
469
470        let client = AcmeClient::new(config);
471
472        // Test various challenge paths
473        assert!(client.handles_acme_challenge("/.well-known/acme-challenge/token123"));
474        assert!(client.handles_acme_challenge("/.well-known/acme-challenge/"));
475        assert!(!client.handles_acme_challenge("/.well-known/acme-challenge"));
476        assert!(!client.handles_acme_challenge("/well-known/acme-challenge/token"));
477        assert!(!client.handles_acme_challenge("/.well-known/other/token"));
478        assert!(!client.handles_acme_challenge("/api/health"));
479        assert!(!client.handles_acme_challenge("/"));
480    }
481}