1mod crypto;
49mod client;
50#[doc(hidden)]
51pub mod dns01;
52pub mod dns_api;
53pub mod on_demand;
54#[doc(hidden)]
55pub mod shared;
56pub mod tls_alpn;
57
58#[cfg(test)]
59mod tests;
60
61use crate::entry_point::Config;
62use client::AcmeHttpClient;
63use crypto::AccountKey;
64use tokio::io::{AsyncReadExt, AsyncWriteExt};
65
66#[cfg(fuzzing)]
68#[doc(hidden)]
69pub mod fuzzing {
70 pub use super::crypto::cert_info_from_der;
71 pub use super::{json_object_keys, parse_rfc3339, parse_suggested_window};
72}
73
74#[derive(Clone, Debug)]
78pub struct AcmeConfig {
79 pub domains: Vec<String>,
81 pub email: String,
83 pub directory_url: String,
85 pub cert_path: String,
87 pub key_path: String,
89 pub challenge_port: u16,
91 pub renew_before_days: Option<i64>,
95 pub profile: Option<String>,
98 pub account_key_path: String,
100 pub challenge: ChallengeType,
102 pub dns: Option<DnsConfig>,
105}
106
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub enum ChallengeType {
110 Http01,
112 TlsAlpn01,
115 Dns01,
118 DnsPersist01,
123}
124
125impl ChallengeType {
126 pub fn parse(value: &str) -> Option<ChallengeType> {
128 match value.trim().to_ascii_lowercase().as_str() {
129 "http-01" => Some(ChallengeType::Http01),
130 "tls-alpn-01" => Some(ChallengeType::TlsAlpn01),
131 "dns-01" => Some(ChallengeType::Dns01),
132 "dns-persist-01" => Some(ChallengeType::DnsPersist01),
133 _ => None,
134 }
135 }
136
137 pub fn as_str(&self) -> &'static str {
139 match self {
140 ChallengeType::Http01 => "http-01",
141 ChallengeType::TlsAlpn01 => "tls-alpn-01",
142 ChallengeType::Dns01 => "dns-01",
143 ChallengeType::DnsPersist01 => "dns-persist-01",
144 }
145 }
146
147 pub fn configured() -> ChallengeType {
150 non_empty_env(Config::RWS_CONFIG_ACME_CHALLENGE).and_then(|v| ChallengeType::parse(&v)).unwrap_or(ChallengeType::Http01)
151 }
152}
153
154#[derive(Clone, Debug)]
156pub struct DnsConfig {
157 pub provider: DnsProvider,
159 pub zone: Option<String>,
161 pub propagation_secs: u64,
163}
164
165#[derive(Clone, Debug)]
167pub enum DnsProvider {
168 Rfc2136 { server: String, tsig: Option<Tsig> },
171 Exec { program: String, server: Option<String> },
176 Cloudflare { token: dns_api::Secret, api: String, zone_id: Option<String>, server: Option<String> },
179 Route53 { credentials: dns_api::AwsCredentials, api: String, zone_id: Option<String>, server: Option<String> },
182 Manual { server: Option<String> },
185}
186
187#[derive(Clone)]
189pub struct Tsig {
190 pub key_name: String,
191 pub algorithm: String,
193 pub secret: Vec<u8>,
194}
195
196impl std::fmt::Debug for Tsig {
197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 f.debug_struct("Tsig").field("key_name", &self.key_name).field("algorithm", &self.algorithm).field("secret", &"(hidden)").finish()
199 }
200}
201
202#[doc(hidden)]
204pub fn base64_secret(value: &str) -> Option<Vec<u8>> {
205 crypto::base64_decode_std(value.trim()).filter(|s| !s.is_empty())
206}
207
208impl DnsConfig {
209 pub fn from_env() -> Result<DnsConfig, String> {
211 let provider = match non_empty_env(Config::RWS_CONFIG_ACME_DNS_PROVIDER).map(|p| p.to_ascii_lowercase()).as_deref() {
212 Some("rfc2136") => {
213 let server = non_empty_env(Config::RWS_CONFIG_ACME_DNS_SERVER)
214 .ok_or("DNS-01 with rfc2136 needs RWS_CONFIG_ACME_DNS_SERVER (the primary server, host[:port])")?;
215 let tsig = match (non_empty_env(Config::RWS_CONFIG_ACME_DNS_TSIG_KEY), non_empty_env(Config::RWS_CONFIG_ACME_DNS_TSIG_SECRET)) {
216 (None, None) => None,
217 (Some(key_name), Some(secret)) => {
218 let secret = base64_secret(&secret).ok_or("RWS_CONFIG_ACME_DNS_TSIG_SECRET is not base64")?;
219 let algorithm = non_empty_env(Config::RWS_CONFIG_ACME_DNS_TSIG_ALGORITHM)
220 .unwrap_or_else(|| "hmac-sha256".to_string())
221 .to_ascii_lowercase();
222 if !matches!(algorithm.as_str(), "hmac-sha256" | "hmac-sha512") {
223 return Err(format!("RWS_CONFIG_ACME_DNS_TSIG_ALGORITHM {algorithm:?}: expected hmac-sha256 or hmac-sha512"));
224 }
225 Some(Tsig { key_name, algorithm, secret })
226 }
227 _ => return Err("RWS_CONFIG_ACME_DNS_TSIG_KEY and RWS_CONFIG_ACME_DNS_TSIG_SECRET go together".to_string()),
228 };
229 DnsProvider::Rfc2136 { server, tsig }
230 }
231 Some("exec") => DnsProvider::Exec {
232 program: non_empty_env(Config::RWS_CONFIG_ACME_DNS_EXEC).ok_or("DNS-01 with exec needs RWS_CONFIG_ACME_DNS_EXEC (the program)")?,
233 server: non_empty_env(Config::RWS_CONFIG_ACME_DNS_SERVER),
234 },
235 Some("cloudflare") => DnsProvider::Cloudflare {
236 token: dns_api::Secret(
237 non_empty_env(Config::RWS_CONFIG_ACME_DNS_API_TOKEN).ok_or("DNS-01 with cloudflare needs RWS_CONFIG_ACME_DNS_API_TOKEN (an API token with DNS edit permission)")?,
238 ),
239 api: non_empty_env(Config::RWS_CONFIG_ACME_DNS_API_URL).unwrap_or_else(|| dns_api::CLOUDFLARE_API.to_string()).trim_end_matches('/').to_string(),
240 zone_id: non_empty_env(Config::RWS_CONFIG_ACME_DNS_ZONE_ID),
241 server: non_empty_env(Config::RWS_CONFIG_ACME_DNS_SERVER),
242 },
243 Some("route53") => DnsProvider::Route53 {
244 credentials: dns_api::AwsCredentials::from_env().ok_or("DNS-01 with route53 needs AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY")?,
245 api: non_empty_env(Config::RWS_CONFIG_ACME_DNS_API_URL).unwrap_or_else(|| dns_api::ROUTE53_API.to_string()).trim_end_matches('/').to_string(),
246 zone_id: non_empty_env(Config::RWS_CONFIG_ACME_DNS_ZONE_ID),
247 server: non_empty_env(Config::RWS_CONFIG_ACME_DNS_SERVER),
248 },
249 Some(other) => return Err(format!("RWS_CONFIG_ACME_DNS_PROVIDER {other:?}: expected rfc2136, cloudflare, route53 or exec")),
250 None => return Err("DNS-01 needs RWS_CONFIG_ACME_DNS_PROVIDER (rfc2136, cloudflare, route53 or exec)".to_string()),
251 };
252 let zone = non_empty_env(Config::RWS_CONFIG_ACME_DNS_ZONE);
253 let propagation_secs = non_empty_env(Config::RWS_CONFIG_ACME_DNS_PROPAGATION_SECS).and_then(|v| v.parse().ok()).unwrap_or(120);
254 Ok(DnsConfig { provider, zone, propagation_secs })
255 }
256}
257
258impl AcmeConfig {
259 pub const LETSENCRYPT: &'static str =
261 "https://acme-v02.api.letsencrypt.org/directory";
262 pub const LETSENCRYPT_STAGING: &'static str =
264 "https://acme-staging-v02.api.letsencrypt.org/directory";
265
266 pub fn from_env() -> Option<Self> {
269 let domains_str = std::env::var(Config::RWS_CONFIG_ACME_DOMAINS).ok()?;
270 if domains_str.trim().is_empty() { return None; }
271 install_hooks();
272
273 let domains: Vec<String> = domains_str
274 .split(',')
275 .map(|s| s.trim().to_string())
276 .filter(|s| !s.is_empty())
277 .collect();
278 if domains.is_empty() { return None; }
279
280 let email = non_empty_env(Config::RWS_CONFIG_ACME_EMAIL).unwrap_or_default();
283 let staging = non_empty_env(Config::RWS_CONFIG_ACME_STAGING)
284 .map(|v| v.eq_ignore_ascii_case("true"))
285 .unwrap_or(false);
286 let directory_url = if staging {
287 Self::LETSENCRYPT_STAGING.to_string()
288 } else {
289 non_empty_env(Config::RWS_CONFIG_ACME_DIRECTORY)
290 .unwrap_or_else(|| Self::LETSENCRYPT.to_string())
291 };
292
293 let cert_path = non_empty_env(Config::RWS_CONFIG_ACME_CERT_PATH)
297 .or_else(|| non_empty_env(Config::RWS_CONFIG_TLS_CERT_FILE))
298 .unwrap_or_else(|| DEFAULT_CERT_PATH.to_string());
299 let key_path = non_empty_env(Config::RWS_CONFIG_ACME_KEY_PATH)
300 .or_else(|| non_empty_env(Config::RWS_CONFIG_TLS_KEY_FILE))
301 .unwrap_or_else(|| DEFAULT_KEY_PATH.to_string());
302
303 let challenge_port = non_empty_env(Config::RWS_CONFIG_ACME_CHALLENGE_PORT)
304 .and_then(|v| v.parse().ok())
305 .unwrap_or(80u16);
306
307 let renew_before_days = non_empty_env(Config::RWS_CONFIG_ACME_RENEW_BEFORE_DAYS)
308 .and_then(|v| v.parse().ok());
309 let profile = non_empty_env(Config::RWS_CONFIG_ACME_PROFILE);
310
311 let account_key_path = non_empty_env(Config::RWS_CONFIG_ACME_ACCOUNT_KEY_PATH)
312 .unwrap_or_else(|| DEFAULT_ACCOUNT_KEY_PATH.to_string());
313
314 let challenge = ChallengeType::configured();
315 let dns = match challenge {
316 ChallengeType::Dns01 => match DnsConfig::from_env() {
317 Ok(dns) => Some(dns),
318 Err(e) => {
319 eprintln!("[ACME] {e}; certificates can't be issued until it's fixed.");
320 return None;
321 }
322 },
323 ChallengeType::DnsPersist01 => Some(DnsConfig {
325 provider: DnsProvider::Manual { server: non_empty_env(Config::RWS_CONFIG_ACME_DNS_SERVER) },
326 zone: None,
327 propagation_secs: 0,
328 }),
329 _ => None,
330 };
331
332 Some(AcmeConfig {
333 domains,
334 email,
335 directory_url,
336 cert_path,
337 key_path,
338 challenge_port,
339 renew_before_days,
340 profile,
341 account_key_path,
342 challenge,
343 dns,
344 })
345 }
346
347 pub fn install_as_tls_certificate(&self) {
353 if non_empty_env(Config::RWS_CONFIG_TLS_CERT_FILE).is_none() {
354 std::env::set_var(Config::RWS_CONFIG_TLS_CERT_FILE, &self.cert_path);
355 }
356 if non_empty_env(Config::RWS_CONFIG_TLS_KEY_FILE).is_none() {
357 std::env::set_var(Config::RWS_CONFIG_TLS_KEY_FILE, &self.key_path);
358 }
359 }
360}
361
362#[doc(hidden)]
363pub fn renew_check_interval_secs() -> u64 {
364 non_empty_env(Config::RWS_CONFIG_ACME_RENEW_CHECK_INTERVAL_SECS)
365 .and_then(|v| v.parse().ok())
366 .filter(|secs| *secs > 0)
367 .unwrap_or(12 * 3600)
368}
369
370pub const STATE_DIR: &str = ".rws/acme";
373const DEFAULT_CERT_PATH: &str = ".rws/acme/cert.pem";
374const DEFAULT_KEY_PATH: &str = ".rws/acme/key.pem";
375const DEFAULT_ACCOUNT_KEY_PATH: &str = ".rws/acme/account.key";
376const LEGACY_DEFAULTS: [(&str, &str); 3] = [
378 ("cert.pem", DEFAULT_CERT_PATH),
379 ("key.pem", DEFAULT_KEY_PATH),
380 ("acme_account.key", DEFAULT_ACCOUNT_KEY_PATH),
381];
382
383fn adopt_legacy_files(config: &AcmeConfig) {
387 for (legacy, current) in LEGACY_DEFAULTS {
388 let in_use = [&config.cert_path, &config.key_path, &config.account_key_path].iter().any(|p| p.as_str() == current);
389 if !in_use || std::path::Path::new(current).exists() || !std::path::Path::new(legacy).is_file() {
390 continue;
391 }
392 match move_private(legacy, current) {
393 Ok(()) => println!("[ACME] Moved '{legacy}' (in the web root) to '{current}'."),
394 Err(e) => eprintln!("[ACME] Could not move '{legacy}' to '{current}': {e}"),
395 }
396 }
397}
398
399fn move_private(from: &str, to: &str) -> std::io::Result<()> {
401 ensure_state_dir(to)?;
402 std::fs::rename(from, to).or_else(|_| std::fs::copy(from, to).and_then(|_| std::fs::remove_file(from)))?;
403 restrict_permissions(to);
404 Ok(())
405}
406
407fn ensure_state_dir(path: &str) -> std::io::Result<()> {
409 let Some(dir) = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) else {
410 return Ok(());
411 };
412 if dir.is_dir() {
413 return Ok(());
414 }
415 #[cfg(unix)]
416 {
417 use std::os::unix::fs::DirBuilderExt;
418 std::fs::DirBuilder::new().recursive(true).mode(0o700).create(dir)
419 }
420 #[cfg(not(unix))]
421 {
422 std::fs::create_dir_all(dir)
423 }
424}
425
426fn write_private(path: &str, contents: &[u8]) -> std::io::Result<()> {
429 ensure_state_dir(path)?;
430 #[cfg(unix)]
431 {
432 use std::io::Write;
433 use std::os::unix::fs::OpenOptionsExt;
434 let mut file = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(path)?;
435 file.write_all(contents)?;
436 restrict_permissions(path);
437 Ok(())
438 }
439 #[cfg(not(unix))]
440 {
441 std::fs::write(path, contents)
442 }
443}
444
445fn restrict_permissions(path: &str) {
446 #[cfg(unix)]
447 {
448 use std::os::unix::fs::PermissionsExt;
449 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
450 }
451 #[cfg(not(unix))]
452 let _ = path;
453}
454
455fn non_empty_env(name: &str) -> Option<String> {
456 std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
457}
458
459#[doc(hidden)]
468pub fn challenges() -> &'static std::sync::Mutex<std::collections::HashMap<String, String>> {
469 static CHALLENGES: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, String>>> =
470 std::sync::OnceLock::new();
471 CHALLENGES.get_or_init(|| {
472 install_hooks();
473 Default::default()
474 })
475}
476
477#[doc(hidden)]
482pub fn install_hooks() {
483 static INSTALLED: std::sync::Once = std::sync::Once::new();
484 INSTALLED.call_once(|| {
485 fn ensure(name: &str) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + '_>> {
486 Box::pin(on_demand::ensure(name))
487 }
488 fn http_challenge(uri: &str) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send + '_>> {
489 Box::pin(challenge_http_response_shared(uri))
490 }
491 crate::tls::set_acme_hooks(crate::tls::AcmeHooks {
492 on_demand_enabled: on_demand::enabled,
493 on_demand_ensure: ensure,
494 on_demand_certificate: on_demand::certificate,
495 tls_alpn_enabled: tls_alpn::enabled,
496 is_challenge: tls_alpn::is_challenge,
497 challenge_certificate: tls_alpn::certificate,
498 note_tls_listener: tls_alpn::note_tls_listener,
499 note_redirect_listener,
500 http_challenge,
501 });
502 });
503}
504
505static REDIRECT_LISTENER_PORT: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(0);
507
508#[doc(hidden)]
511pub fn note_redirect_listener(port: u16) {
512 REDIRECT_LISTENER_PORT.store(port, std::sync::atomic::Ordering::SeqCst);
513}
514
515const CHALLENGE_PATH_PREFIX: &str = "/.well-known/acme-challenge/";
516
517#[doc(hidden)]
520pub fn challenge_http_response(uri: &str) -> Option<String> {
521 let token = uri.strip_prefix(CHALLENGE_PATH_PREFIX)?;
522 let key_auth = challenges().lock().unwrap().get(token)?.clone();
523 Some(challenge_response(&key_auth))
524}
525
526#[doc(hidden)]
529pub async fn challenge_http_response_shared(uri: &str) -> Option<String> {
530 if let Some(response) = challenge_http_response(uri) {
531 return Some(response);
532 }
533 let store = shared::shared()?;
534 let token = uri.strip_prefix(CHALLENGE_PATH_PREFIX)?.to_string();
535 if token.is_empty() || token.len() > 256 {
536 return None;
537 }
538 let key_auth = tokio::task::spawn_blocking(move || store.challenge(&token)).await.ok()??;
539 Some(challenge_response(&key_auth))
540}
541
542fn challenge_response(key_auth: &str) -> String {
543 format!(
544 "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
545 key_auth.len(),
546 key_auth
547 )
548}
549
550pub struct AcmeManager {
554 config: AcmeConfig,
555}
556
557impl AcmeManager {
558 pub fn new(config: AcmeConfig) -> Self {
559 install_hooks();
560 AcmeManager { config }
561 }
562
563 pub async fn provision_if_needed(&self) -> Result<(), String> {
566 self.check().await.map(|_| ())
567 }
568
569 #[doc(hidden)]
578 pub async fn check(&self) -> Result<RenewalCheck, String> {
579 if self.adopt_shared().await {
581 return Ok(RenewalCheck::Renewed);
582 }
583 let Some(info) = crypto::read_cert_info(&self.config.cert_path) else {
584 println!("[ACME] No valid certificate found at '{}', provisioning...", self.config.cert_path);
585 self.provision().await?;
586 return Ok(RenewalCheck::Renewed);
587 };
588 let missing = missing_names(&self.config.domains, crypto::read_cert_dns_names(&self.config.cert_path).as_deref());
592 if !missing.is_empty() {
593 println!("[ACME] Certificate at '{}' doesn't cover {:?}; ordering one for {:?}.",
594 self.config.cert_path, missing, self.config.domains);
595 self.provision_coordinated(None).await?;
596 return Ok(RenewalCheck::Renewed);
597 }
598 let (window, retry_after) = match &info.cert_id {
599 Some(cert_id) => match self.renewal_info(cert_id).await {
600 Ok(found) => found,
601 Err(e) => {
602 eprintln!("[ACME] No renewal information ({e}); renewing at two-thirds of the lifetime.");
603 (None, None)
604 }
605 },
606 None => (None, None),
607 };
608 let due = renewal_time(&info, window, self.config.renew_before_days);
609 let now = unix_now();
610 if now >= due {
611 println!("[ACME] Certificate at '{}' is due for renewal (expires {}), renewing...",
612 self.config.cert_path, crate::http::date::format(info.not_after.max(0) as u64));
613 self.provision_coordinated(info.cert_id.as_deref()).await?;
614 return Ok(RenewalCheck::Renewed);
615 }
616 println!("[ACME] Certificate at '{}' is current; renewal due {}.",
617 self.config.cert_path, crate::http::date::format(due.max(0) as u64));
618 let mut recheck = renew_check_interval_secs().min((due - now).max(60) as u64);
619 if info.cert_id.is_some() {
620 recheck = recheck.min(retry_after.unwrap_or(RENEWAL_INFO_RECHECK_SECS).max(60));
623 }
624 Ok(RenewalCheck::Current { recheck_in: recheck })
625 }
626
627 async fn renewal_info(&self, cert_id: &str) -> Result<(Option<(i64, i64)>, Option<u64>), String> {
631 let http = AcmeHttpClient::new()?;
632 let dir = fetch_directory(&http, &self.config.directory_url).await?;
633 let Some(base) = dir.renewal_info_url else { return Ok((None, None)) };
634 let resp = http.get(&format!("{}/{}", base.trim_end_matches('/'), cert_id)).await?;
635 if resp.status != 200 {
636 return Err(format!("renewalInfo: HTTP {}", resp.status));
637 }
638 let retry_after = resp.header("retry-after").and_then(|v| {
639 v.trim().parse::<u64>().ok().or_else(|| {
640 crate::http::date::parse(v).map(|at| at.saturating_sub(unix_now().max(0) as u64))
641 })
642 });
643 Ok((parse_suggested_window(&resp.body), retry_after))
644 }
645
646 pub async fn provision(&self) -> Result<(), String> {
648 self.provision_coordinated(None).await
649 }
650
651 async fn provision_coordinated(&self, replaces: Option<&str>) -> Result<(), String> {
655 let Some(store) = shared::shared() else {
656 return self.provision_replacing(replaces).await;
657 };
658 let domains = self.config.domains.clone();
659 let locked = tokio::task::spawn_blocking(move || store.lock(&domains)).await.unwrap_or(None);
660 if locked == Some(false) {
661 println!("[ACME] Another replica is ordering the certificate for {:?}; waiting for it.", self.config.domains);
662 let deadline = tokio::time::Instant::now() + shared::WAIT_FOR_OTHER;
663 while tokio::time::Instant::now() < deadline {
664 tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
665 if self.adopt_shared().await {
666 return Ok(());
667 }
668 }
669 return Err("another replica held the ordering lock, but no certificate appeared".to_string());
670 }
671 let result = self.provision_replacing(replaces).await;
672 let (cert_path, key_path, domains) = (self.config.cert_path.clone(), self.config.key_path.clone(), self.config.domains.clone());
673 let stored = result.is_ok();
674 let _ = tokio::task::spawn_blocking(move || {
675 if stored {
676 if let (Ok(cert), Ok(key)) = (std::fs::read(&cert_path), std::fs::read(&key_path)) {
677 store.store_certificate(&domains, &cert, &key);
678 }
679 }
680 if locked == Some(true) {
681 store.unlock(&domains);
682 }
683 })
684 .await;
685 result
686 }
687
688 async fn adopt_shared(&self) -> bool {
691 let Some(store) = shared::shared() else { return false };
692 let domains = self.config.domains.clone();
693 let Ok(Some((cert, key))) = tokio::task::spawn_blocking(move || store.certificate(&domains)).await else {
694 return false;
695 };
696 let Some(theirs) = crypto::cert_info_from_pem(&cert) else { return false };
697 if crypto::read_cert_info(&self.config.cert_path).is_some_and(|ours| ours.not_after >= theirs.not_after) {
698 return false;
699 }
700 let written = ensure_state_dir(&self.config.cert_path)
701 .and_then(|_| std::fs::write(&self.config.cert_path, &cert))
702 .and_then(|_| write_private(&self.config.key_path, &key));
703 match written {
704 Ok(()) => {
705 println!("[ACME] Installed the certificate another replica stored (expires {}).", crate::http::date::format(theirs.not_after.max(0) as u64));
706 true
707 }
708 Err(e) => {
709 eprintln!("[ACME] Couldn't install the shared certificate: {e}");
710 false
711 }
712 }
713 }
714
715 async fn provision_replacing(&self, replaces: Option<&str>) -> Result<(), String> {
719 println!("[ACME] Provisioning certificate for: {:?}", self.config.domains);
720
721 let http = AcmeHttpClient::new()?;
722
723 let account_key = self.load_or_create_account_key()?;
725
726 let dir = fetch_directory(&http, &self.config.directory_url).await?;
728 if let Some(profile) = &self.config.profile {
729 if !dir.profiles.is_empty() && !dir.profiles.contains(profile) {
730 return Err(format!(
731 "the CA doesn't offer the certificate profile {profile:?} (RWS_CONFIG_ACME_PROFILE); it offers: {}",
732 dir.profiles.join(", ")
733 ));
734 }
735 }
736
737 let nonce = get_nonce(&http, &dir.new_nonce_url).await?;
739
740 let (account_url, nonce) =
742 create_account(&http, &account_key, &dir.new_account_url, &self.config.email, &nonce).await?;
743 println!("[ACME] Account: {account_url}");
744
745 let profile = self.config.profile.as_deref();
749 let ordered = new_order(&http, &account_key, &account_url, &dir.new_order_url, &self.config.domains, replaces, profile, &nonce).await;
750 let (order, order_url, nonce) = match (ordered, replaces) {
751 (Ok(order), _) => order,
752 (Err(e), Some(_)) => {
753 eprintln!("[ACME] Order replacing the current certificate refused ({e}); ordering without 'replaces'.");
754 let nonce = get_nonce(&http, &dir.new_nonce_url).await?;
755 new_order(&http, &account_key, &account_url, &dir.new_order_url, &self.config.domains, None, profile, &nonce).await?
756 }
757 (Err(e), None) => return Err(e),
758 };
759 println!("[ACME] Order: {order_url}{}", replaces.map(|id| format!(" (replacing {id})")).unwrap_or_default());
760
761 let mut nonce = nonce;
763 for authz_url in &order.authorizations {
764 nonce = self.complete_authorization(&http, &account_key, &account_url, authz_url, &nonce).await?;
765 }
766
767 let (cert_key, csr_der) = generate_csr(&self.config.domains)?;
769 let csr_b64 = crypto::base64url(&csr_der);
770
771 let nonce = finalize_order(&http, &account_key, &account_url, &order.finalize_url, &csr_b64, &nonce).await?;
773
774 let (cert_url, nonce) = poll_order(&http, &account_key, &account_url, &order_url, &nonce).await?;
776 println!("[ACME] Certificate URL: {cert_url}");
777
778 let cert_pem = download_cert(&http, &account_key, &account_url, &cert_url, &nonce).await?;
780
781 ensure_state_dir(&self.config.cert_path)
783 .and_then(|_| std::fs::write(&self.config.cert_path, &cert_pem))
784 .map_err(|e| format!("failed to write cert to '{}': {e}", self.config.cert_path))?;
785 write_private(&self.config.key_path, cert_key.serialize_pem().as_bytes())
786 .map_err(|e| format!("failed to write key to '{}': {e}", self.config.key_path))?;
787
788 println!(
789 "[ACME] Certificate written to '{}' and '{}'.",
790 self.config.cert_path, self.config.key_path
791 );
792 Ok(())
793 }
794
795 pub async fn provision_until_ready(&self) {
801 adopt_legacy_files(&self.config);
802 let mut delay = 30u64;
803 loop {
804 match self.provision_if_needed().await {
805 Ok(()) => return,
806 Err(e) => {
807 eprintln!("[ACME] Provisioning failed: {e}");
808 eprintln!("[ACME] Retrying in {delay}s...");
809 tokio::time::sleep(tokio::time::Duration::from_secs(delay)).await;
810 delay = (delay * 2).min(15 * 60);
811 }
812 }
813 }
814 }
815
816 pub async fn run_renewal_loop(self) {
823 renewal().running.store(true, std::sync::atomic::Ordering::SeqCst);
824 let interval = renew_check_interval_secs();
825 let mut next = crypto::read_cert_info(&self.config.cert_path)
826 .map(|info| ((renewal_time(&info, None, self.config.renew_before_days) - unix_now()).max(60) as u64).min(interval))
827 .unwrap_or(interval);
828 loop {
829 let forced = tokio::select! {
830 _ = tokio::time::sleep(tokio::time::Duration::from_secs(next)) => false,
831 _ = renewal().now.notified() => true,
832 };
833 let result = if forced {
834 println!("[ACME] Renewal of '{}' requested through the admin API, renewing...", self.config.cert_path);
835 let cert_id = crypto::read_cert_info(&self.config.cert_path).and_then(|info| info.cert_id);
836 self.provision_coordinated(cert_id.as_deref()).await.map(|_| RenewalCheck::Renewed)
837 } else {
838 self.check().await
839 };
840 renewal().record(&result);
841 match result {
842 Ok(RenewalCheck::Current { recheck_in }) => next = recheck_in,
843 Ok(RenewalCheck::Renewed) => {
844 next = 60;
846 #[cfg(unix)]
847 {
848 unsafe { libc::kill(libc::getpid(), libc::SIGHUP); }
850 }
851 }
852 Err(e) => {
853 next = interval.min(3600);
854 eprintln!("[ACME] Renewal failed: {e} — retrying in {next}s");
855 }
856 }
857 }
858 }
859
860 fn load_or_create_account_key(&self) -> Result<AccountKey, String> {
863 let path = &self.config.account_key_path;
864 if let Ok(der) = std::fs::read(path) {
865 let key = AccountKey::from_pkcs8(&der)
866 .map_err(|e| format!("failed to load account key from '{path}': {e}"))?;
867 println!("[ACME] Loaded account key from '{path}'.");
868 return Ok(key);
869 }
870 let (key, der) = AccountKey::generate()
871 .map_err(|e| format!("account key generation failed: {e}"))?;
872 write_private(path, &der)
873 .map_err(|e| format!("failed to save account key to '{path}': {e}"))?;
874 println!("[ACME] Generated new account key at '{path}'.");
875 Ok(key)
876 }
877
878 async fn complete_authorization(
879 &self,
880 http: &AcmeHttpClient,
881 key: &AccountKey,
882 account_url: &str,
883 authz_url: &str,
884 nonce: &str,
885 ) -> Result<String, String> {
886 let (authz, nonce) = get_authorization(http, key, account_url, authz_url, nonce).await?;
887
888 if authz.status == "valid" {
889 return Ok(nonce);
890 }
891
892 let kind = self.config.challenge;
893 let challenge = authz.challenges.iter()
894 .find(|c| c.challenge_type == kind.as_str())
895 .ok_or_else(|| format!("no {} challenge in authorization {authz_url} (the CA offers: {})", kind.as_str(),
896 authz.challenges.iter().map(|c| c.challenge_type.as_str()).collect::<Vec<_>>().join(", ")))?;
897
898 let key_auth = format!(
899 "{}.{}",
900 challenge.token,
901 crypto::key_thumbprint(key)?
902 );
903 let domain = authz.identifier_value.clone();
905
906 let presented = if kind == ChallengeType::DnsPersist01 {
907 self.check_persist_record(&domain, &challenge.issuer_domain_names, account_url).await?
908 } else {
909 self.present(kind, &challenge.token, &domain, &key_auth).await?
910 };
911
912 let poll_result = match signal_challenge(http, key, account_url, &challenge.url, &nonce).await {
914 Ok(nonce) => poll_authorization_valid(http, key, account_url, authz_url, &nonce).await,
915 Err(e) => Err(e),
916 };
917
918 presented.withdraw().await;
920
921 let nonce = poll_result?;
922
923 println!("[ACME] Authorization validated for '{}'.", authz.identifier_value);
924 Ok(nonce)
925 }
926}
927
928impl AcmeManager {
929 async fn present(&self, kind: ChallengeType, token: &str, domain: &str, key_auth: &str) -> Result<Presented, String> {
931 match kind {
932 ChallengeType::Http01 => {
933 challenges().lock().unwrap().insert(token.to_string(), key_auth.to_string());
934 if let Some(store) = shared::shared() {
935 let (token, key_auth) = (token.to_string(), key_auth.to_string());
936 let _ = tokio::task::spawn_blocking(move || store.put_challenge(&token, &key_auth)).await;
937 }
938 let port = self.config.challenge_port;
941 let standalone = if REDIRECT_LISTENER_PORT.load(std::sync::atomic::Ordering::SeqCst) == port {
942 println!("[ACME] Answering HTTP-01 challenge via the redirect listener on port {port}.");
943 None
944 } else {
945 println!("[ACME] Starting HTTP-01 challenge server on port {port}...");
946 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
947 let handle = tokio::spawn(async move {
948 if let Err(e) = run_challenge_server(port, shutdown_rx).await {
949 eprintln!("[ACME] Challenge server error: {e}");
950 }
951 });
952 tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
954 Some((shutdown_tx, handle))
955 };
956 Ok(Presented::Http { token: token.to_string(), standalone })
957 }
958 ChallengeType::TlsAlpn01 => {
959 tls_alpn::add(domain, key_auth)?;
960 if let Some(store) = shared::shared() {
961 let (domain, key_auth) = (domain.to_string(), key_auth.to_string());
962 let _ = tokio::task::spawn_blocking(move || store.put_tls_alpn(&domain, &key_auth)).await;
963 }
964 let standalone = if tls_alpn::tls_listener_up() {
965 println!("[ACME] Answering TLS-ALPN-01 challenge for {domain} via the TLS listener.");
966 None
967 } else {
968 let (ip, port, _) = crate::entry_point::get_ip_port_thread_count();
969 let addr = if ip.contains(':') { format!("[{ip}]:{port}") } else { format!("{ip}:{port}") };
970 println!("[ACME] Answering TLS-ALPN-01 challenge for {domain} on {addr}...");
971 let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
972 let handle = tokio::spawn(async move {
973 if let Err(e) = tls_alpn::serve(addr, shutdown_rx).await {
974 eprintln!("[ACME] TLS-ALPN-01 listener error: {e}");
975 }
976 });
977 tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
978 Some((shutdown_tx, handle))
979 };
980 Ok(Presented::TlsAlpn { domain: domain.to_string(), standalone })
981 }
982 ChallengeType::Dns01 => {
983 let dns = self.config.dns.clone().ok_or("DNS-01 isn't configured (RWS_CONFIG_ACME_DNS_PROVIDER)")?;
984 let name = dns01::record_name(domain);
985 let value = dns01::record_value(key_auth);
986 println!("[ACME] Publishing the DNS-01 record {name} TXT {value}...");
987 dns01::present(&dns, &name, &value).await?;
988 if dns01::wait_visible(&dns, &name, &value).await {
989 println!("[ACME] {name} is visible.");
990 } else {
991 eprintln!("[ACME] {name} isn't visible after {}s; asking the CA to check anyway.", dns.propagation_secs);
992 }
993 Ok(Presented::Dns { config: dns, name, value })
994 }
995 ChallengeType::DnsPersist01 => Err("dns-persist-01 records are published by you, not presented".to_string()),
996 }
997 }
998
999 async fn check_persist_record(&self, domain: &str, issuers: &[String], account_url: &str) -> Result<Presented, String> {
1003 let dns = self.config.dns.clone().ok_or("dns-persist-01 isn't configured")?;
1004 let issuer = issuers.first().ok_or("the CA's dns-persist-01 challenge names no issuer-domain-names")?;
1005 let wildcard = self.config.domains.iter().any(|d| d.strip_prefix("*.").is_some_and(|base| base.eq_ignore_ascii_case(domain)));
1007 let name = dns01::persist_record_name(domain);
1008 if dns01::persist_record_published(&dns, &name, issuers, account_url, wildcard).await? {
1009 println!("[ACME] Found the DNS-PERSIST-01 record at {name}.");
1010 return Ok(Presented::Persistent);
1011 }
1012 let value = dns01::persist_record_value(issuer, account_url, wildcard);
1013 Err(format!("dns-persist-01: publish this TXT record once (it stays; rws retries until it's visible): {name}. TXT \"{value}\""))
1014 }
1015}
1016
1017enum Presented {
1019 Http { token: String, standalone: Option<(tokio::sync::oneshot::Sender<()>, tokio::task::JoinHandle<()>)> },
1020 TlsAlpn { domain: String, standalone: Option<(tokio::sync::oneshot::Sender<()>, tokio::task::JoinHandle<()>)> },
1021 Dns { config: DnsConfig, name: String, value: String },
1022 Persistent,
1024}
1025
1026impl Presented {
1027 async fn withdraw(self) {
1028 let standalone = match self {
1029 Presented::Http { token, standalone } => {
1030 challenges().lock().unwrap().remove(&token);
1031 if let Some(store) = shared::shared() {
1032 let _ = tokio::task::spawn_blocking(move || store.remove_challenge(&token)).await;
1033 }
1034 standalone
1035 }
1036 Presented::TlsAlpn { domain, standalone } => {
1037 tls_alpn::remove(&domain);
1038 if let Some(store) = shared::shared() {
1039 let _ = tokio::task::spawn_blocking(move || store.remove_tls_alpn(&domain)).await;
1040 }
1041 standalone
1042 }
1043 Presented::Dns { config, name, value } => {
1044 if let Err(e) = dns01::cleanup(&config, &name, &value).await {
1045 eprintln!("[ACME] Couldn't remove the DNS-01 record {name}: {e}");
1046 }
1047 None
1048 }
1049 Presented::Persistent => None,
1050 };
1051 if let Some((shutdown_tx, handle)) = standalone {
1052 let _ = shutdown_tx.send(());
1053 let _ = handle.await;
1054 }
1055 }
1056}
1057
1058struct Directory {
1061 new_nonce_url: String,
1062 new_account_url: String,
1063 new_order_url: String,
1064 renewal_info_url: Option<String>,
1066 profiles: Vec<String>,
1069}
1070
1071struct Order {
1072 authorizations: Vec<String>,
1073 finalize_url: String,
1074}
1075
1076struct Authorization {
1077 status: String,
1078 identifier_value: String,
1079 challenges: Vec<Challenge>,
1080}
1081
1082struct Challenge {
1083 challenge_type: String,
1084 url: String,
1085 token: String,
1087 issuer_domain_names: Vec<String>,
1089}
1090
1091async fn fetch_directory(http: &AcmeHttpClient, url: &str) -> Result<Directory, String> {
1092 let resp = http.get(url).await?;
1093 if resp.status != 200 {
1094 return Err(format!("directory fetch failed: HTTP {}: {}", resp.status, resp.body));
1095 }
1096 let new_nonce_url = json_str(&resp.body, "newNonce")
1097 .ok_or("directory missing newNonce")?;
1098 let new_account_url = json_str(&resp.body, "newAccount")
1099 .ok_or("directory missing newAccount")?;
1100 let new_order_url = json_str(&resp.body, "newOrder")
1101 .ok_or("directory missing newOrder")?;
1102 let renewal_info_url = json_str(&resp.body, "renewalInfo");
1103 let profiles = json_object_keys(&resp.body, "profiles");
1104 Ok(Directory { new_nonce_url, new_account_url, new_order_url, renewal_info_url, profiles })
1105}
1106
1107async fn get_nonce(http: &AcmeHttpClient, url: &str) -> Result<String, String> {
1108 let resp = http.head(url).await?;
1109 resp.nonce().ok_or_else(|| "Replay-Nonce missing from newNonce response".to_string())
1110}
1111
1112async fn create_account(
1113 http: &AcmeHttpClient,
1114 key: &AccountKey,
1115 url: &str,
1116 email: &str,
1117 nonce: &str,
1118) -> Result<(String, String), String> {
1119 let payload = if email.is_empty() {
1120 r#"{"termsOfServiceAgreed":true}"#.to_string()
1121 } else {
1122 format!(r#"{{"termsOfServiceAgreed":true,"contact":["mailto:{email}"]}}"#)
1123 };
1124 let body = crypto::build_jws(key, nonce, url, None, Some(&payload))?;
1125 let resp = http.post_jws(url, &body).await?;
1126 if resp.status != 200 && resp.status != 201 {
1127 return Err(format!("newAccount failed: HTTP {}: {}", resp.status, resp.body));
1128 }
1129 let account_url = resp.location()
1130 .ok_or("newAccount response missing Location header")?;
1131 let nonce = resp.nonce()
1132 .ok_or("newAccount response missing Replay-Nonce")?;
1133 Ok((account_url, nonce))
1134}
1135
1136#[allow(clippy::too_many_arguments)]
1137async fn new_order(
1138 http: &AcmeHttpClient,
1139 key: &AccountKey,
1140 account_url: &str,
1141 url: &str,
1142 domains: &[String],
1143 replaces: Option<&str>,
1144 profile: Option<&str>,
1145 nonce: &str,
1146) -> Result<(Order, String, String), String> {
1147 let payload = new_order_payload(domains, replaces, profile);
1148 let body = crypto::build_jws(key, nonce, url, Some(account_url), Some(&payload))?;
1149 let resp = http.post_jws(url, &body).await?;
1150 if resp.status != 201 {
1151 return Err(format!("newOrder failed: HTTP {}: {}", resp.status, resp.body));
1152 }
1153 let order_url = resp.location()
1154 .ok_or("newOrder response missing Location header")?;
1155 let nonce = resp.nonce()
1156 .ok_or("newOrder response missing Replay-Nonce")?;
1157 let authorizations = json_array_strings(&resp.body, "authorizations");
1158 let finalize_url = json_str(&resp.body, "finalize")
1159 .ok_or("newOrder response missing finalize URL")?;
1160 Ok((Order { authorizations, finalize_url }, order_url, nonce))
1161}
1162
1163async fn get_authorization(
1164 http: &AcmeHttpClient,
1165 key: &AccountKey,
1166 account_url: &str,
1167 authz_url: &str,
1168 nonce: &str,
1169) -> Result<(Authorization, String), String> {
1170 let body = crypto::build_jws(key, nonce, authz_url, Some(account_url), None)?;
1172 let resp = http.post_jws(authz_url, &body).await?;
1173 if resp.status != 200 {
1174 return Err(format!("authorization GET failed: HTTP {}: {}", resp.status, resp.body));
1175 }
1176 let nonce = resp.nonce().unwrap_or_default();
1177 let status = json_str(&resp.body, "status").unwrap_or_default();
1178 let identifier_value = json_str(&resp.body, "value").unwrap_or_default();
1179 let challenges = parse_challenges(&resp.body);
1180 Ok((Authorization { status, identifier_value, challenges }, nonce))
1181}
1182
1183async fn signal_challenge(
1184 http: &AcmeHttpClient,
1185 key: &AccountKey,
1186 account_url: &str,
1187 challenge_url: &str,
1188 nonce: &str,
1189) -> Result<String, String> {
1190 let body = crypto::build_jws(key, nonce, challenge_url, Some(account_url), Some("{}"))?;
1191 let resp = http.post_jws(challenge_url, &body).await?;
1192 if resp.status != 200 {
1193 return Err(format!("challenge signal failed: HTTP {}: {}", resp.status, resp.body));
1194 }
1195 Ok(resp.nonce().unwrap_or_default())
1196}
1197
1198async fn poll_authorization_valid(
1199 http: &AcmeHttpClient,
1200 key: &AccountKey,
1201 account_url: &str,
1202 authz_url: &str,
1203 start_nonce: &str,
1204) -> Result<String, String> {
1205 let mut nonce = start_nonce.to_string();
1206 for attempt in 0..20 {
1207 tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
1208 let body = crypto::build_jws(key, &nonce, authz_url, Some(account_url), None)?;
1210 let resp = http.post_jws(authz_url, &body).await?;
1211 if let Some(n) = resp.nonce() { nonce = n; }
1212 let status = json_str(&resp.body, "status").unwrap_or_default();
1213 match status.as_str() {
1214 "valid" => return Ok(nonce),
1215 "invalid" => {
1216 let err = json_str(&resp.body, "detail").unwrap_or_else(|| resp.body.clone());
1217 return Err(format!("authorization became invalid: {err}"));
1218 }
1219 _ => {
1220 if attempt == 19 {
1221 return Err(format!("authorization timed out with status '{status}'"));
1222 }
1223 }
1224 }
1225 }
1226 Err("authorization polling timed out".to_string())
1227}
1228
1229async fn finalize_order(
1230 http: &AcmeHttpClient,
1231 key: &AccountKey,
1232 account_url: &str,
1233 finalize_url: &str,
1234 csr_b64: &str,
1235 nonce: &str,
1236) -> Result<String, String> {
1237 let payload = format!(r#"{{"csr":"{csr_b64}"}}"#);
1238 let body = crypto::build_jws(key, nonce, finalize_url, Some(account_url), Some(&payload))?;
1239 let resp = http.post_jws(finalize_url, &body).await?;
1240 if resp.status != 200 {
1241 return Err(format!("finalize failed: HTTP {}: {}", resp.status, resp.body));
1242 }
1243 Ok(resp.nonce().unwrap_or_default())
1244}
1245
1246async fn poll_order(
1247 http: &AcmeHttpClient,
1248 key: &AccountKey,
1249 account_url: &str,
1250 order_url: &str,
1251 start_nonce: &str,
1252) -> Result<(String, String), String> {
1253 let mut nonce = start_nonce.to_string();
1254 for attempt in 0..20 {
1255 tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
1256 let body = crypto::build_jws(key, &nonce, order_url, Some(account_url), None)?;
1257 let resp = http.post_jws(order_url, &body).await?;
1258 if let Some(n) = resp.nonce() { nonce = n; }
1259 let status = json_str(&resp.body, "status").unwrap_or_default();
1260 match status.as_str() {
1261 "valid" => {
1262 let cert_url = json_str(&resp.body, "certificate")
1263 .ok_or("order is valid but missing certificate URL")?;
1264 return Ok((cert_url, nonce));
1265 }
1266 "invalid" => {
1267 return Err(format!("order became invalid: {}", resp.body));
1268 }
1269 _ => {
1270 if attempt == 19 {
1271 return Err(format!("order timed out with status '{status}'"));
1272 }
1273 }
1274 }
1275 }
1276 Err("order polling timed out".to_string())
1277}
1278
1279async fn download_cert(
1280 http: &AcmeHttpClient,
1281 key: &AccountKey,
1282 account_url: &str,
1283 cert_url: &str,
1284 nonce: &str,
1285) -> Result<String, String> {
1286 let body = crypto::build_jws(key, nonce, cert_url, Some(account_url), None)?;
1287 let resp = http.post_jws(cert_url, &body).await?;
1288 if resp.status != 200 {
1289 return Err(format!("cert download failed: HTTP {}: {}", resp.status, resp.body));
1290 }
1291 Ok(resp.body)
1292}
1293
1294async fn run_challenge_server(
1297 port: u16,
1298 shutdown: tokio::sync::oneshot::Receiver<()>,
1299) -> Result<(), String> {
1300 let listeners = crate::server::bind_all_families("0.0.0.0", port)
1301 .await
1302 .map_err(|e| format!("challenge server: bind port {port} failed: {e}"))?;
1303
1304 let tasks: Vec<_> = listeners.into_iter().map(|l| tokio::spawn(serve_challenges(l))).collect();
1305 let _ = shutdown.await;
1306 for task in tasks {
1307 task.abort();
1308 }
1309 Ok(())
1310}
1311
1312async fn serve_challenges(listener: tokio::net::TcpListener) {
1313 loop {
1314 let Ok((mut stream, _)) = listener.accept().await else { break };
1315 tokio::spawn(async move {
1316 let mut buf = [0u8; 1024];
1317 let n = stream.read(&mut buf).await.unwrap_or(0);
1318 let req = String::from_utf8_lossy(&buf[..n]);
1319 let uri = req.lines().next().and_then(|l| l.split_whitespace().nth(1)).unwrap_or("");
1320 let response = challenge_http_response_shared(uri).await.unwrap_or_else(|| {
1321 "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string()
1322 });
1323 let _ = stream.write_all(response.as_bytes()).await;
1324 });
1325 }
1326}
1327
1328fn generate_csr(domains: &[String]) -> Result<(rcgen::KeyPair, Vec<u8>), String> {
1334 let cert_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)
1335 .map_err(|e| format!("cert key generation failed: {e}"))?;
1336 let mut params = rcgen::CertificateParams::new(domains.to_vec())
1337 .map_err(|e| format!("CSR params error: {e}"))?;
1338 params.distinguished_name = rcgen::DistinguishedName::new();
1339 let csr = params.serialize_request(&cert_key)
1340 .map_err(|e| format!("CSR serialize error: {e}"))?;
1341 Ok((cert_key, csr.der().to_vec()))
1342}
1343
1344#[doc(hidden)]
1347pub fn new_order_payload(domains: &[String], replaces: Option<&str>, profile: Option<&str>) -> String {
1348 let ids: Vec<String> = domains.iter()
1349 .map(|d| format!(r#"{{"type":"dns","value":"{}"}}"#, json_escape(d)))
1350 .collect();
1351 let mut payload = format!(r#"{{"identifiers":[{}]"#, ids.join(","));
1352 if let Some(replaces) = replaces {
1353 payload.push_str(&format!(r#","replaces":"{}""#, json_escape(replaces)));
1354 }
1355 if let Some(profile) = profile {
1356 payload.push_str(&format!(r#","profile":"{}""#, json_escape(profile)));
1357 }
1358 payload.push('}');
1359 payload
1360}
1361
1362fn json_escape(s: &str) -> String {
1363 crate::core::json::escape(s)
1364}
1365
1366#[doc(hidden)]
1368pub struct Renewal {
1369 running: std::sync::atomic::AtomicBool,
1370 now: tokio::sync::Notify,
1371 last: std::sync::Mutex<Option<(u64, String)>>,
1373}
1374
1375impl Renewal {
1376 fn record(&self, result: &Result<RenewalCheck, String>) {
1377 let outcome = match result {
1378 Ok(RenewalCheck::Renewed) => "renewed".to_string(),
1379 Ok(RenewalCheck::Current { .. }) => "current".to_string(),
1380 Err(e) => e.clone(),
1381 };
1382 *self.last.lock().unwrap_or_else(|e| e.into_inner()) = Some((unix_now().max(0) as u64, outcome));
1383 }
1384}
1385
1386#[doc(hidden)]
1387pub fn renewal() -> &'static Renewal {
1388 static RENEWAL: std::sync::OnceLock<Renewal> = std::sync::OnceLock::new();
1389 RENEWAL.get_or_init(|| Renewal {
1390 running: std::sync::atomic::AtomicBool::new(false),
1391 now: tokio::sync::Notify::new(),
1392 last: std::sync::Mutex::new(None),
1393 })
1394}
1395
1396pub fn request_renewal() -> bool {
1399 let renewal = renewal();
1400 if !renewal.running.load(std::sync::atomic::Ordering::SeqCst) {
1401 return false;
1402 }
1403 renewal.now.notify_one();
1404 true
1405}
1406
1407pub fn renewal_status() -> (bool, Option<(u64, String)>) {
1409 let renewal = renewal();
1410 (renewal.running.load(std::sync::atomic::Ordering::SeqCst), renewal.last.lock().unwrap_or_else(|e| e.into_inner()).clone())
1411}
1412
1413#[derive(Debug, PartialEq, Eq)]
1415#[doc(hidden)]
1416pub enum RenewalCheck {
1417 Renewed,
1419 Current { recheck_in: u64 },
1421}
1422
1423const RENEWAL_INFO_RECHECK_SECS: u64 = 6 * 3600;
1425
1426fn unix_now() -> i64 {
1427 std::time::SystemTime::now()
1428 .duration_since(std::time::UNIX_EPOCH)
1429 .map_or(0, |d| d.as_secs() as i64)
1430}
1431
1432#[doc(hidden)]
1443pub fn missing_names(domains: &[String], have: Option<&[String]>) -> Vec<String> {
1444 let have = have.unwrap_or(&[]);
1445 domains
1446 .iter()
1447 .filter(|d| !have.iter().any(|h| h.eq_ignore_ascii_case(d.trim_end_matches('.'))))
1448 .cloned()
1449 .collect()
1450}
1451
1452#[doc(hidden)]
1453pub fn renewal_time(info: &crypto::CertInfo, window: Option<(i64, i64)>, renew_before_days: Option<i64>) -> i64 {
1454 let lifetime = (info.not_after - info.not_before).max(0);
1455 let fallback = match renew_before_days {
1456 Some(days) => info.not_after - days.saturating_mul(86_400),
1457 None => info.not_before + lifetime / 3 * 2,
1458 };
1459 match window {
1460 Some((start, end)) => {
1461 let span = (end - start).max(0);
1462 let point = start + (span as f64 * window_fraction(info)) as i64;
1463 point.min(fallback)
1464 }
1465 None => fallback,
1466 }
1467}
1468
1469fn window_fraction(info: &crypto::CertInfo) -> f64 {
1473 let identity = info.cert_id.clone().unwrap_or_else(|| info.not_after.to_string());
1474 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1475 for byte in identity.bytes() {
1476 hash = (hash ^ byte as u64).wrapping_mul(0x0100_0000_01b3);
1477 }
1478 (hash >> 11) as f64 / (1u64 << 53) as f64
1479}
1480
1481#[doc(hidden)]
1484pub fn parse_suggested_window(body: &str) -> Option<(i64, i64)> {
1485 let window = &body[body.find("\"suggestedWindow\"")?..];
1486 let start = parse_rfc3339(&json_str(window, "start")?)?;
1487 let end = parse_rfc3339(&json_str(window, "end")?)?;
1488 (start <= end).then_some((start, end))
1489}
1490
1491#[doc(hidden)]
1494pub fn parse_rfc3339(value: &str) -> Option<i64> {
1495 let b = value.trim().as_bytes();
1496 if b.len() < 20 || b[4] != b'-' || b[7] != b'-' || !matches!(b[10], b'T' | b't') || b[13] != b':' || b[16] != b':' {
1497 return None;
1498 }
1499 let num = |range: std::ops::Range<usize>| -> Option<i64> {
1500 let digits = b.get(range)?;
1501 digits.iter().all(u8::is_ascii_digit).then(|| digits.iter().fold(0i64, |n, d| n * 10 + (d - b'0') as i64))
1502 };
1503 let (year, month, day) = (num(0..4)?, num(5..7)?, num(8..10)?);
1504 let (hour, minute, second) = (num(11..13)?, num(14..16)?, num(17..19)?);
1505 if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || minute > 59 || second > 60 {
1506 return None;
1507 }
1508 let mut rest = &b[19..];
1509 if rest.first() == Some(&b'.') {
1510 let digits = rest[1..].iter().take_while(|c| c.is_ascii_digit()).count();
1511 if digits == 0 {
1512 return None;
1513 }
1514 rest = &rest[1 + digits..];
1515 }
1516 let offset = match rest {
1517 [b'Z' | b'z'] => 0,
1518 [sign @ (b'+' | b'-'), h1, h2, b':', m1, m2] => {
1519 let digits = [*h1, *h2, *m1, *m2];
1520 if !digits.iter().all(u8::is_ascii_digit) {
1521 return None;
1522 }
1523 let minutes = ((h1 - b'0') as i64 * 10 + (h2 - b'0') as i64) * 60 + (m1 - b'0') as i64 * 10 + (m2 - b'0') as i64;
1524 if *sign == b'+' { minutes * 60 } else { -minutes * 60 }
1525 }
1526 _ => return None,
1527 };
1528 let days = crypto::days_since_epoch(year, month, day)?;
1529 Some(days * 86_400 + hour * 3600 + minute * 60 + second - offset)
1530}
1531
1532#[doc(hidden)]
1535pub fn json_object_keys(json: &str, field: &str) -> Vec<String> {
1536 let key = format!("\"{}\"", field);
1537 let Some(at) = json.find(&key) else { return vec![] };
1538 let rest = json[at + key.len()..].trim_start_matches([' ', ':', '\n', '\r', '\t']);
1539 if !rest.starts_with('{') {
1540 return vec![];
1541 }
1542 let mut keys = Vec::new();
1543 let (mut depth, mut in_string, mut escaped) = (0usize, false, false);
1544 let mut current = String::new();
1545 let mut expecting_key = true;
1546 for c in rest.chars() {
1547 if in_string {
1548 if escaped {
1549 escaped = false;
1550 current.push(c);
1551 } else if c == '\\' {
1552 escaped = true;
1553 } else if c == '"' {
1554 in_string = false;
1555 } else {
1556 current.push(c);
1557 }
1558 continue;
1559 }
1560 match c {
1561 '"' => { in_string = true; current.clear(); }
1562 ':' if depth == 1 && expecting_key => { keys.push(std::mem::take(&mut current)); expecting_key = false; }
1563 ',' if depth == 1 => expecting_key = true,
1564 '{' | '[' => depth += 1,
1565 '}' | ']' => {
1566 depth -= 1;
1567 if depth == 0 { break; }
1568 }
1569 _ => {}
1570 }
1571 }
1572 keys
1573}
1574
1575#[doc(hidden)]
1576pub fn json_str(json: &str, field: &str) -> Option<String> {
1577 let key = format!("\"{}\"", field);
1578 let start = json.find(&key)? + key.len();
1579 let rest = json[start..].trim_start_matches([' ', ':'].as_ref()).trim_start();
1580 let inner = rest.strip_prefix('"')?;
1581 let mut out = String::new();
1584 let mut chars = inner.chars();
1585 while let Some(c) = chars.next() {
1586 match c {
1587 '"' => return Some(out),
1588 '\\' => match chars.next()? {
1589 'n' => out.push('\n'),
1590 't' => out.push('\t'),
1591 other => out.push(other),
1592 },
1593 c => out.push(c),
1594 }
1595 }
1596 None
1597}
1598
1599fn json_array_strings(json: &str, field: &str) -> Vec<String> {
1600 let key = format!("\"{}\"", field);
1601 let start = match json.find(&key) {
1602 Some(s) => s + key.len(),
1603 None => return vec![],
1604 };
1605 let rest = json[start..].trim_start_matches([' ', ':'].as_ref()).trim_start();
1606 if !rest.starts_with('[') { return vec![]; }
1607 let arr_end = rest.find(']').unwrap_or(rest.len());
1608 let arr = &rest[1..arr_end];
1609 let mut result = Vec::new();
1610 let mut pos = 0;
1611 while let Some(q) = arr[pos..].find('"') {
1612 let abs = pos + q + 1;
1613 if let Some(end) = arr[abs..].find('"') {
1614 result.push(arr[abs..abs + end].to_string());
1615 pos = abs + end + 1;
1616 } else {
1617 break;
1618 }
1619 }
1620 result
1621}
1622
1623fn parse_challenges(json: &str) -> Vec<Challenge> {
1624 json_objects(json, "challenges")
1625 .into_iter()
1626 .filter_map(|obj| {
1627 Some(Challenge {
1628 challenge_type: json_str(obj, "type")?,
1629 url: json_str(obj, "url")?,
1630 token: json_str(obj, "token").unwrap_or_default(),
1631 issuer_domain_names: json_array_strings(obj, "issuer-domain-names"),
1632 })
1633 })
1634 .collect()
1635}
1636
1637#[doc(hidden)]
1639pub fn json_objects<'a>(json: &'a str, field: &str) -> Vec<&'a str> {
1640 let marker = format!("\"{field}\"");
1641 let start = match json.find(&marker) {
1642 Some(s) => s + marker.len(),
1643 None => return vec![],
1644 };
1645 let rest = json[start..].trim_start_matches([' ', ':'].as_ref()).trim_start();
1646 if !rest.starts_with('[') { return vec![]; }
1647
1648 let mut result = Vec::new();
1649 let mut pos = 1usize; while pos < rest.len() {
1651 let ahead = rest[pos..].trim_start_matches([' ', '\n', '\r', '\t', ',']);
1653 if !ahead.starts_with('{') { break; }
1654 let abs = rest.len() - ahead.len();
1655 let mut depth = 0usize;
1657 let mut obj_end = abs;
1658 for (i, ch) in rest[abs..].char_indices() {
1659 match ch {
1660 '{' => depth += 1,
1661 '}' => {
1662 depth -= 1;
1663 if depth == 0 { obj_end = abs + i + 1; break; }
1664 }
1665 _ => {}
1666 }
1667 }
1668 if obj_end <= abs { break; }
1669 result.push(&rest[abs..obj_end]);
1670 pos = obj_end;
1671 }
1672 result
1673}