From f990494242051c5bc343bba000f89d975ed55a4b Mon Sep 17 00:00:00 2001 From: Roman Kivalin Date: Mon, 13 Apr 2026 19:34:50 +0200 Subject: [PATCH] Add solver trait and refactor renewal to use it - Solver trait with present/cleanup/needs_propagation_delay methods - DNS-01 solver extracted from inline renew.rs code - ACME module now supports mixed Identifier::Dns + Identifier::Ip - Generic ChallengeInfo replaces DnsChallenge - Per-domain challenge type selection based on solver config - Propagation delay only applied when DNS-01 solvers are in use - Solver instances deduplicated by name across domains Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + Cargo.toml | 3 + src/acme.rs | 102 +++++++----- src/config.rs | 1 + src/main.rs | 1 + src/renew.rs | 378 +++++++++++++++++++++----------------------- src/solver/dns01.rs | 65 ++++++++ src/solver/mod.rs | 19 +++ 8 files changed, 340 insertions(+), 230 deletions(-) create mode 100644 src/solver/dns01.rs create mode 100644 src/solver/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 476d5db..3c25bad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,6 +347,7 @@ name = "certforge" version = "0.4.0" dependencies = [ "anyhow", + "async-trait", "base64", "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 0fcc092..c8f92ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,5 +51,8 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "serd # State persistence serde_json = "1" +# Trait async support +async-trait = "0.1" + # systemd D-Bus zbus = "5" diff --git a/src/acme.rs b/src/acme.rs index 0cc4e56..94dbedb 100644 --- a/src/acme.rs +++ b/src/acme.rs @@ -1,3 +1,4 @@ +use std::net::IpAddr; use std::time::Duration; use instant_acme::{ @@ -9,23 +10,26 @@ use crate::config::AcmeConfig; use crate::credentials; use crate::error::{Error, Result}; -/// ACME client wrapping instant-acme with credential management. pub struct AcmeClient { account: Account, } -/// Information about a pending DNS-01 challenge. -pub struct DnsChallenge { - /// The domain being validated - pub domain: String, - /// The full DNS name for the TXT record: _acme-challenge. - pub txt_name: String, - /// The TXT record value (base64url-encoded SHA-256 of key authorization) - pub txt_value: String, +/// Generic challenge information for any challenge type. +#[allow(dead_code)] +pub struct ChallengeInfo { + /// The identifier being validated (domain name or IP address). + pub identifier: String, + /// The challenge type that was selected. + pub challenge_type: ChallengeType, + /// The challenge token. + pub token: String, + /// The full key authorization string. + pub key_authorization: String, + /// The DNS TXT record value (base64url(sha256(key_auth))). Only meaningful for DNS-01. + pub dns_value: String, } impl AcmeClient { - /// Load or create an ACME account. pub async fn new(config: &AcmeConfig) -> Result { let cred_data = credentials::load_secret( config.account_key_credential.as_deref(), @@ -94,16 +98,17 @@ impl AcmeClient { } } - /// Create a new certificate order, extract DNS-01 challenge info, and set challenges ready. - /// - /// The caller should publish DNS TXT records for the returned challenges BEFORE calling - /// this method. This method collects challenge info, publishes DNS, then sets challenges ready. - /// - /// Returns the order and challenge info needed for DNS TXT records. - pub async fn create_order(&self, domains: &[String]) -> Result<(Order, Vec)> { + /// Create a new ACME order for the given domains/IPs. + pub async fn create_order(&self, domains: &[String]) -> Result { let identifiers: Vec = domains .iter() - .map(|d| Identifier::Dns(d.clone())) + .map(|d| { + if let Ok(ip) = d.parse::() { + Identifier::Ip(ip) + } else { + Identifier::Dns(d.clone()) + } + }) .collect(); let order = self @@ -111,51 +116,77 @@ impl AcmeClient { .new_order(&NewOrder::new(&identifiers)) .await?; - Ok((order, Vec::new())) + Ok(order) } - /// Collect DNS-01 challenge info from an order's authorizations. - pub async fn collect_challenges(order: &mut Order) -> Result> { + /// Collect challenge info from an order, selecting the desired challenge type per identifier. + /// + /// `desired_types` maps each identifier to the challenge type to use. + /// The order of identifiers matches the order from `create_order`. + pub async fn collect_challenges( + order: &mut Order, + desired_types: &[(String, ChallengeType)], + ) -> Result> { let mut challenges = Vec::new(); let mut auths = order.authorizations(); while let Some(auth_result) = auths.next().await { let mut auth = auth_result?; + let identifier = auth.identifier().to_string(); - let domain = auth.identifier().to_string(); + // Find which challenge type was requested for this identifier + let desired = desired_types + .iter() + .find(|(id, _)| *id == identifier) + .map(|(_, ct)| ct.clone()) + .unwrap_or(ChallengeType::Dns01); - let dns01 = auth.challenge(ChallengeType::Dns01).ok_or_else(|| { + let challenge = auth.challenge(desired.clone()).ok_or_else(|| { Error::Acme(instant_acme::Error::Other( - format!("no DNS-01 challenge for {domain}").into(), + format!("no {desired:?} challenge for {identifier}").into(), )) })?; - let key_auth = dns01.key_authorization(); - let txt_value = key_auth.dns_value(); + let key_auth = challenge.key_authorization(); + let token = challenge.token.clone(); + let dns_value = key_auth.dns_value(); + let key_authorization = key_auth.as_str().to_string(); - challenges.push(DnsChallenge { - txt_name: format!("_acme-challenge.{domain}"), - domain, - txt_value, + challenges.push(ChallengeInfo { + identifier, + challenge_type: desired, + token, + key_authorization, + dns_value, }); } Ok(challenges) } - /// Set all DNS-01 challenges as ready (call after publishing TXT records). - pub async fn set_challenges_ready(order: &mut Order) -> Result<()> { + /// Set all challenges as ready. + pub async fn set_challenges_ready( + order: &mut Order, + desired_types: &[(String, ChallengeType)], + ) -> Result<()> { let mut auths = order.authorizations(); while let Some(auth_result) = auths.next().await { let mut auth = auth_result?; - if let Some(mut dns01) = auth.challenge(ChallengeType::Dns01) { - dns01.set_ready().await?; + let identifier = auth.identifier().to_string(); + + let desired = desired_types + .iter() + .find(|(id, _)| *id == identifier) + .map(|(_, ct)| ct.clone()) + .unwrap_or(ChallengeType::Dns01); + + if let Some(mut ch) = auth.challenge(desired) { + ch.set_ready().await?; } } Ok(()) } - /// Wait for the order to become ready, then finalize with a CSR. pub async fn finalize_order(order: &mut Order, csr_der: &[u8]) -> Result<()> { let mut retries = 20; loop { @@ -184,7 +215,6 @@ impl AcmeClient { Ok(()) } - /// Wait for the certificate to be available and download it. pub async fn download_certificate(order: &mut Order) -> Result { let mut retries = 20; loop { diff --git a/src/config.rs b/src/config.rs index 9ef1a84..767a1a3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -87,6 +87,7 @@ fn default_dns_protocol() -> DnsProtocol { /// Challenge solver configuration, tagged by type. #[derive(Debug, Deserialize)] #[serde(tag = "type")] +#[allow(dead_code)] pub enum SolverConfig { #[serde(rename = "dns-01")] Dns01 { diff --git a/src/main.rs b/src/main.rs index abd6896..325748c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod error; mod hooks; mod keys; mod renew; +mod solver; mod state; use clap::Parser; diff --git a/src/renew.rs b/src/renew.rs index 7cdcb5d..1f2a46f 100644 --- a/src/renew.rs +++ b/src/renew.rs @@ -1,10 +1,11 @@ +use std::collections::HashMap; use std::path::Path; -use hickory_proto::rr::Name; +use instant_acme::ChallengeType; -use crate::acme::{AcmeClient, DnsChallenge}; +use crate::acme::AcmeClient; use crate::certs::CertInfo; -use crate::config::{CertificateConfig, Config, DaneSelector, DnsClientConfig, SolverConfig}; +use crate::config::{CertificateConfig, Config, DaneSelector, SolverConfig}; use crate::credentials; use crate::dane; use crate::dns::tsig; @@ -12,9 +13,50 @@ use crate::dns::update::DnsUpdater; use crate::error::{Error, Result}; use crate::hooks; use crate::keys::CertKeyPair; +use crate::solver::dns01::Dns01Solver; +use crate::solver::Solver; use crate::state::{PendingRotation, State}; -/// Run the renewal process for all certificates (or a specific one). +/// Build solver instances from config, deduplicating by solver name. +fn build_solvers(config: &Config, solver_names: &[&str]) -> Result>> { + let mut solvers: HashMap> = HashMap::new(); + + for &name in solver_names { + if solvers.contains_key(name) { + continue; + } + let solver_config = config.solver_config(name)?; + let solver: Box = match solver_config { + SolverConfig::Dns01 { dns } => { + let dns_config = config.dns_client(dns)?; + Box::new(Dns01Solver::new(dns_config.clone())) + } + SolverConfig::Http01 { .. } => { + return Err(Error::Config(format!( + "solver '{name}': HTTP-01 not yet implemented" + ))); + } + SolverConfig::TlsAlpn01 { .. } => { + return Err(Error::Config(format!( + "solver '{name}': TLS-ALPN-01 not yet implemented" + ))); + } + }; + solvers.insert(name.to_string(), solver); + } + + Ok(solvers) +} + +/// Map a solver config to its ACME challenge type. +fn solver_challenge_type(config: &Config, solver_name: &str) -> Result { + match config.solver_config(solver_name)? { + SolverConfig::Dns01 { .. } => Ok(ChallengeType::Dns01), + SolverConfig::Http01 { .. } => Ok(ChallengeType::Http01), + SolverConfig::TlsAlpn01 { .. } => Ok(ChallengeType::TlsAlpn01), + } +} + pub async fn run( config: &Config, state_dir: &Path, @@ -54,31 +96,6 @@ pub async fn run( Ok(()) } -/// Resolve the DNS client config for a solver assigned to a domain. -fn resolve_dns_config_for_domain<'a>( - config: &'a Config, - cert_config: &CertificateConfig, - domain_index: usize, -) -> Result<&'a DnsClientConfig> { - let solver_name = cert_config - .solver_for_domain(domain_index, config.default_solver.as_deref()) - .ok_or_else(|| { - Error::Config(format!( - "certificate {}: no solver configured for domain '{}'", - cert_config.name, cert_config.domains[domain_index] - )) - })?; - - let solver = config.solver_config(solver_name)?; - match solver { - SolverConfig::Dns01 { dns } => config.dns_client(dns), - _ => Err(Error::Config(format!( - "certificate {}: solver '{solver_name}' is not DNS-01 (HTTP-01 and TLS-ALPN-01 not yet implemented)", - cert_config.name - ))), - } -} - async fn renew_certificate( config: &Config, acme: &AcmeClient, @@ -88,18 +105,12 @@ async fn renew_certificate( force: bool, dry_run: bool, ) -> Result { - // Check if there's a pending key rotation to complete + // Check pending key rotation if let Some(pending) = state.pending_rotations.get(&cert_config.name) { if pending.ttl_expired() { - tracing::info!( - name = %cert_config.name, - "pending key rotation TTL expired, completing renewal" - ); + tracing::info!(name = %cert_config.name, "pending key rotation TTL expired, completing renewal"); } else { - tracing::info!( - name = %cert_config.name, - "pending key rotation, waiting for TTL expiry" - ); + tracing::info!(name = %cert_config.name, "pending key rotation, waiting for TTL expiry"); return Ok(false); } } @@ -109,86 +120,37 @@ async fn renew_certificate( && let Ok(cert) = CertInfo::load(&cert_config.cert_path) { let days = cert.days_until_expiry(); if !cert.expires_within_days(cert_config.renew_before_days) { - tracing::info!( - name = %cert_config.name, - days_remaining = days, - "certificate does not need renewal" - ); + tracing::info!(name = %cert_config.name, days_remaining = days, "certificate does not need renewal"); return Ok(false); } - tracing::info!( - name = %cert_config.name, - days_remaining = days, - "certificate expiring soon, renewing" - ); + tracing::info!(name = %cert_config.name, days_remaining = days, "certificate expiring soon, renewing"); } + // Resolve solver names for each domain + let solver_names: Vec<&str> = cert_config + .domains + .iter() + .enumerate() + .map(|(i, domain)| { + cert_config + .solver_for_domain(i, config.default_solver.as_deref()) + .ok_or_else(|| { + Error::Config(format!( + "certificate {}: no solver configured for domain '{domain}'", + cert_config.name + )) + }) + }) + .collect::>>()?; + + // Build solver instances + let solvers = build_solvers(config, &solver_names)?; + // Determine key pair - let key = if let Some(pending) = state.pending_rotations.remove(&cert_config.name) { - let key_data = std::fs::read(&pending.pending_key_path) - .map_err(|e| Error::Key(format!("failed to read pending key: {e}")))?; - CertKeyPair::from_pem_or_der(&key_data, &cert_config.key_type)? - } else if cert_config.rotate_key { - let needs_pre_publish = cert_config - .dane - .iter() - .any(|d| d.pre_publish && d.selector == DaneSelector::Spki); - - let new_key = CertKeyPair::generate(&cert_config.key_type)?; - - if needs_pre_publish { - tracing::info!(name = %cert_config.name, "pre-publishing TLSA for key rotation"); - - if !dry_run { - let pending_dir = state_dir.join("pending"); - std::fs::create_dir_all(&pending_dir)?; - let pending_key_path = pending_dir.join(format!("{}.key", cert_config.name)); - std::fs::write(&pending_key_path, new_key.to_pem())?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions( - &pending_key_path, - std::fs::Permissions::from_mode(0o600), - )?; - } - - let max_ttl = pre_publish_dane(config, cert_config, &new_key, dry_run).await?; - - state.pending_rotations.insert( - cert_config.name.clone(), - PendingRotation { - pending_key_path, - published_at: chrono::Utc::now(), - old_ttl: max_ttl, - }, - ); - state.save(state_dir)?; - } - - tracing::info!( - name = %cert_config.name, - "TLSA pre-published, renewal deferred to next run" - ); - return Ok(false); - } - - new_key - } else { - match CertKeyPair::load( - cert_config.key_credential.as_deref(), - cert_config.key_path.as_deref(), - &cert_config.key_type, - ) - .await - { - Ok(key) => key, - Err(_) => { - tracing::info!(name = %cert_config.name, "generating new key pair"); - CertKeyPair::generate(&cert_config.key_type)? - } - } + let key = determine_key(config, cert_config, state, state_dir, dry_run).await?; + let key = match key { + Some(k) => k, + None => return Ok(false), // Pre-publication deferred }; if dry_run { @@ -197,12 +159,7 @@ async fn renew_certificate( } // Generate CSR - tracing::debug!( - name = %cert_config.name, - domains = ?cert_config.domains, - key_type = ?cert_config.key_type, - "generating CSR" - ); + tracing::debug!(name = %cert_config.name, domains = ?cert_config.domains, key_type = ?cert_config.key_type, "generating CSR"); let pkcs8_der = rustls_pki_types::PrivatePkcs8KeyDer::from(key.pkcs8_der.as_slice()); let rcgen_alg = match cert_config.key_type { crate::config::KeyType::EcdsaP256 => &rcgen::PKCS_ECDSA_P256_SHA256, @@ -211,70 +168,61 @@ async fn renew_certificate( }; let rcgen_kp = rcgen::KeyPair::from_pkcs8_der_and_sign_algo(&pkcs8_der, rcgen_alg) .map_err(|e| Error::Key(format!("failed to load key for CSR: {e}")))?; - - let mut params = - rcgen::CertificateParams::new(cert_config.domains.clone()) - .map_err(|e| Error::Certificate(format!("invalid CSR params: {e}")))?; + let mut params = rcgen::CertificateParams::new(cert_config.domains.clone()) + .map_err(|e| Error::Certificate(format!("invalid CSR params: {e}")))?; params.distinguished_name = rcgen::DistinguishedName::new(); - let csr = params .serialize_request(&rcgen_kp) .map_err(|e| Error::Certificate(format!("failed to generate CSR: {e}")))?; - // ACME order + // Build desired challenge types per identifier + let desired_types: Vec<(String, ChallengeType)> = cert_config + .domains + .iter() + .zip(solver_names.iter()) + .map(|(domain, &solver_name)| { + Ok((domain.clone(), solver_challenge_type(config, solver_name)?)) + }) + .collect::>>()?; + + // Create ACME order tracing::debug!(name = %cert_config.name, domains = ?cert_config.domains, "creating ACME order"); - let (mut order, _) = acme.create_order(&cert_config.domains).await?; + let mut order = acme.create_order(&cert_config.domains).await?; - // Collect DNS-01 challenge info - let challenges = AcmeClient::collect_challenges(&mut order).await?; + // Collect challenges + let challenges = AcmeClient::collect_challenges(&mut order, &desired_types).await?; - // Publish DNS-01 challenge TXT records - for challenge in &challenges { - tracing::debug!( - domain = %challenge.domain, - txt_name = %challenge.txt_name, - txt_value = %challenge.txt_value, - "publishing DNS-01 challenge TXT record" - ); - // Find which domain index this challenge corresponds to - let domain_index = cert_config.domains.iter() - .position(|d| d == &challenge.domain) - .ok_or_else(|| Error::Config(format!( - "challenge domain '{}' not found in certificate domains", challenge.domain - )))?; - let dns_config = resolve_dns_config_for_domain(config, cert_config, domain_index)?; - publish_challenge_txt(dns_config, challenge).await?; + // Present all challenges + for (challenge, &solver_name) in challenges.iter().zip(solver_names.iter()) { + let solver = &solvers[solver_name]; + solver.present(challenge).await?; } - // Run the ACME order flow, ensuring TXT cleanup happens even on failure + // Run ACME flow, ensuring cleanup happens even on failure let order_result = async { - // Wait for DNS propagation - tracing::debug!("waiting 5s for DNS propagation"); - tokio::time::sleep(std::time::Duration::from_secs(5)).await; + // Wait for DNS propagation if any solver needs it + if solvers.values().any(|s| s.needs_propagation_delay()) { + tracing::debug!("waiting 5s for DNS propagation"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } tracing::debug!(name = %cert_config.name, "setting challenges ready"); - AcmeClient::set_challenges_ready(&mut order).await?; + AcmeClient::set_challenges_ready(&mut order, &desired_types).await?; tracing::debug!(name = %cert_config.name, "finalizing order"); AcmeClient::finalize_order(&mut order, csr.der()).await?; + tracing::debug!(name = %cert_config.name, "downloading certificate"); AcmeClient::download_certificate(&mut order).await } .await; - // Always clean up challenge TXT records - for challenge in &challenges { - let domain_index = cert_config.domains.iter() - .position(|d| d == &challenge.domain); - if let Some(idx) = domain_index - && let Ok(dns_config) = resolve_dns_config_for_domain(config, cert_config, idx) - && let Err(e) = cleanup_challenge_txt(dns_config, challenge).await { - tracing::warn!( - domain = %challenge.domain, - error = %e, - "failed to clean up challenge TXT record" - ); - } + // Always clean up challenges + for (challenge, &solver_name) in challenges.iter().zip(solver_names.iter()) { + let solver = &solvers[solver_name]; + if let Err(e) = solver.cleanup(challenge).await { + tracing::warn!(identifier = %challenge.identifier, error = %e, "failed to clean up challenge"); + } } let cert_pem = order_result?; @@ -286,7 +234,6 @@ async fn renew_certificate( std::fs::write(&cert_config.cert_path, &cert_pem)?; tracing::info!(path = %cert_config.cert_path.display(), "saved certificate"); - // Save key save_key(&key, cert_config).await?; // Publish DANE TLSA records @@ -312,6 +259,80 @@ async fn renew_certificate( Ok(true) } +/// Determine the key pair for a certificate, handling key rotation and pre-publication. +/// Returns None if renewal is deferred due to DANE pre-publication. +async fn determine_key( + config: &Config, + cert_config: &CertificateConfig, + state: &mut State, + state_dir: &Path, + dry_run: bool, +) -> Result> { + if let Some(pending) = state.pending_rotations.remove(&cert_config.name) { + let key_data = std::fs::read(&pending.pending_key_path) + .map_err(|e| Error::Key(format!("failed to read pending key: {e}")))?; + return Ok(Some(CertKeyPair::from_pem_or_der(&key_data, &cert_config.key_type)?)); + } + + if cert_config.rotate_key { + let needs_pre_publish = cert_config + .dane + .iter() + .any(|d| d.pre_publish && d.selector == DaneSelector::Spki); + + let new_key = CertKeyPair::generate(&cert_config.key_type)?; + + if needs_pre_publish { + tracing::info!(name = %cert_config.name, "pre-publishing TLSA for key rotation"); + + if !dry_run { + let pending_dir = state_dir.join("pending"); + std::fs::create_dir_all(&pending_dir)?; + let pending_key_path = pending_dir.join(format!("{}.key", cert_config.name)); + std::fs::write(&pending_key_path, new_key.to_pem())?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&pending_key_path, std::fs::Permissions::from_mode(0o600))?; + } + + let max_ttl = pre_publish_dane(config, cert_config, &new_key, dry_run).await?; + + state.pending_rotations.insert( + cert_config.name.clone(), + PendingRotation { + pending_key_path, + published_at: chrono::Utc::now(), + old_ttl: max_ttl, + }, + ); + state.save(state_dir)?; + } + + tracing::info!(name = %cert_config.name, "TLSA pre-published, renewal deferred to next run"); + return Ok(None); + } + + return Ok(Some(new_key)); + } + + // Load existing key or generate if first time + match CertKeyPair::load( + cert_config.key_credential.as_deref(), + cert_config.key_path.as_deref(), + &cert_config.key_type, + ) + .await + { + Ok(key) => Ok(Some(key)), + Err(_) => { + tracing::info!(name = %cert_config.name, "generating new key pair"); + Ok(Some(CertKeyPair::generate(&cert_config.key_type)?)) + } + } +} + async fn save_key(key: &CertKeyPair, cert_config: &CertificateConfig) -> Result<()> { let pem = key.to_pem(); @@ -335,37 +356,6 @@ async fn save_key(key: &CertKeyPair, cert_config: &CertificateConfig) -> Result< Ok(()) } -async fn publish_challenge_txt(dns_config: &DnsClientConfig, challenge: &DnsChallenge) -> Result<()> { - tracing::debug!( - domain = %challenge.domain, - zone = %dns_config.zone, - server = %dns_config.server, - txt_name = %challenge.txt_name, - "publishing challenge TXT via RFC 2136" - ); - let signer = tsig::load_tsig_signer(dns_config).await?; - let mut updater = DnsUpdater::connect(dns_config, signer).await?; - - let name = Name::from_ascii(&challenge.txt_name) - .map_err(|e| Error::DnsUpdate(format!("invalid TXT name: {e}")))?; - let zone = Name::from_ascii(&dns_config.zone) - .map_err(|e| Error::DnsUpdate(format!("invalid zone: {e}")))?; - - updater.add_txt_record(&zone, &name, &challenge.txt_value, 60).await -} - -async fn cleanup_challenge_txt(dns_config: &DnsClientConfig, challenge: &DnsChallenge) -> Result<()> { - let signer = tsig::load_tsig_signer(dns_config).await?; - let mut updater = DnsUpdater::connect(dns_config, signer).await?; - - let name = Name::from_ascii(&challenge.txt_name) - .map_err(|e| Error::DnsUpdate(format!("invalid TXT name: {e}")))?; - let zone = Name::from_ascii(&dns_config.zone) - .map_err(|e| Error::DnsUpdate(format!("invalid zone: {e}")))?; - - updater.delete_txt_record(&zone, &name, &challenge.txt_value).await -} - async fn pre_publish_dane( config: &Config, cert_config: &CertificateConfig, diff --git a/src/solver/dns01.rs b/src/solver/dns01.rs new file mode 100644 index 0000000..72b2601 --- /dev/null +++ b/src/solver/dns01.rs @@ -0,0 +1,65 @@ +use hickory_proto::rr::Name; + +use crate::acme::ChallengeInfo; +use crate::config::DnsClientConfig; +use crate::dns::tsig; +use crate::dns::update::DnsUpdater; +use crate::error::{Error, Result}; + +/// DNS-01 challenge solver using RFC 2136 dynamic updates. +pub struct Dns01Solver { + dns_config: DnsClientConfig, +} + +impl Dns01Solver { + pub fn new(dns_config: DnsClientConfig) -> Self { + Self { dns_config } + } +} + +#[async_trait::async_trait] +impl super::Solver for Dns01Solver { + async fn present(&self, challenge: &ChallengeInfo) -> Result<()> { + let txt_name = format!("_acme-challenge.{}", challenge.identifier); + tracing::debug!( + identifier = %challenge.identifier, + zone = %self.dns_config.zone, + server = %self.dns_config.server, + %txt_name, + "publishing DNS-01 challenge TXT record" + ); + + let signer = tsig::load_tsig_signer(&self.dns_config).await?; + let mut updater = DnsUpdater::connect(&self.dns_config, signer).await?; + + let name = Name::from_ascii(&txt_name) + .map_err(|e| Error::DnsUpdate(format!("invalid TXT name: {e}")))?; + let zone = Name::from_ascii(&self.dns_config.zone) + .map_err(|e| Error::DnsUpdate(format!("invalid zone: {e}")))?; + + updater.add_txt_record(&zone, &name, &challenge.dns_value, 60).await + } + + async fn cleanup(&self, challenge: &ChallengeInfo) -> Result<()> { + let txt_name = format!("_acme-challenge.{}", challenge.identifier); + tracing::debug!( + identifier = %challenge.identifier, + %txt_name, + "cleaning up DNS-01 challenge TXT record" + ); + + let signer = tsig::load_tsig_signer(&self.dns_config).await?; + let mut updater = DnsUpdater::connect(&self.dns_config, signer).await?; + + let name = Name::from_ascii(&txt_name) + .map_err(|e| Error::DnsUpdate(format!("invalid TXT name: {e}")))?; + let zone = Name::from_ascii(&self.dns_config.zone) + .map_err(|e| Error::DnsUpdate(format!("invalid zone: {e}")))?; + + updater.delete_txt_record(&zone, &name, &challenge.dns_value).await + } + + fn needs_propagation_delay(&self) -> bool { + true + } +} diff --git a/src/solver/mod.rs b/src/solver/mod.rs new file mode 100644 index 0000000..61befd8 --- /dev/null +++ b/src/solver/mod.rs @@ -0,0 +1,19 @@ +pub mod dns01; + +use crate::acme::ChallengeInfo; +use crate::error::Result; + +/// A challenge solver that can present and clean up ACME challenges. +#[async_trait::async_trait] +pub trait Solver: Send + Sync { + /// Present a challenge to make it available for ACME validation. + async fn present(&self, challenge: &ChallengeInfo) -> Result<()>; + + /// Clean up a challenge after validation (success or failure). + async fn cleanup(&self, challenge: &ChallengeInfo) -> Result<()>; + + /// Whether this solver needs a DNS propagation delay after presenting. + fn needs_propagation_delay(&self) -> bool { + false + } +}