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 pub fn initialize(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
50 if !self.config.enabled {
51 return Ok(());
52 }
53
54 Ok(())
56 }
57
58 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 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, )
86 .await?;
87
88 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 let mut order = account
102 .new_order(&NewOrder {
103 identifiers: &identifiers,
104 })
105 .await?;
106
107 info!("Created ACME order, processing authorizations");
108
109 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 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 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 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 sleep(Duration::from_millis(500)).await;
159 info!(
160 "Challenge file ready, signaling to Let's Encrypt for domain: {}",
161 domain
162 );
163
164 order.set_challenge_ready(&challenge.url).await?;
166
167 info!("Challenge ready signal sent for domain: {}", domain);
168 }
169
170 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 self.wait_for_order_ready(&account, &mut order).await?;
179
180 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 let mut params = CertificateParams::new(domains.to_vec());
194
195 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 let cert = RcgenCertificate::from_params(params)?;
205 let csr = cert.serialize_request_der()?;
206
207 info!("Finalizing ACME order with CSR");
209 order.finalize(&csr).await?;
210 info!("Order finalized successfully");
211
212 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 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 sleep(Duration::from_secs(10)).await;
241
242 info!(
243 "Challenge validation attempt {} for domain: {}",
244 attempt + 1,
245 domain
246 );
247
248 *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 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 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 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 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 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 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 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 let client = AcmeClient::new(config.clone());
413
414 let test_token = "test_challenge_token_12345";
416 let test_key_auth = "test_key_authorization_67890.test_key_thumbprint";
417
418 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 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 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 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 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}