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!("ACME identifiers being sent to Let's Encrypt: {identifiers:?}");
96
97 let mut order = account
99 .new_order(&NewOrder {
100 identifiers: &identifiers,
101 })
102 .await?;
103
104 info!("Created ACME order, processing authorizations");
105
106 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 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 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 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 sleep(Duration::from_millis(500)).await;
156 info!("Challenge file ready, signaling to Let's Encrypt for domain: {domain}");
157
158 order.set_challenge_ready(&challenge.url).await?;
160
161 info!("Challenge ready signal sent for domain: {domain}");
162 }
163
164 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 self.wait_for_order_ready(&account, &mut order).await?;
173
174 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 let mut params = CertificateParams::new(domains.to_vec());
188
189 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 let cert = RcgenCertificate::from_params(params)?;
199 let csr = cert.serialize_request_der()?;
200
201 info!("Finalizing ACME order with CSR");
203 order.finalize(&csr).await?;
204 info!("Order finalized successfully");
205
206 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 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 sleep(Duration::from_secs(10)).await;
232
233 info!(
234 "Challenge validation attempt {} for domain: {}",
235 attempt + 1,
236 domain
237 );
238
239 *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 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 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 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 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 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 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 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 let client = AcmeClient::new(config.clone());
401
402 let test_token = "test_challenge_token_12345";
404 let test_key_auth = "test_key_authorization_67890.test_key_thumbprint";
405
406 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 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 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 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 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}