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