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) <noreply@anthropic.com>
This commit is contained in:
Roman Kivalin
2026-04-13 19:34:50 +02:00
co-authored by Claude Opus 4.6
parent da8c937782
commit f990494242
8 changed files with 340 additions and 230 deletions
Generated
+1
View File
@@ -347,6 +347,7 @@ name = "certforge"
version = "0.4.0"
dependencies = [
"anyhow",
"async-trait",
"base64",
"chrono",
"clap",
+3
View File
@@ -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"
+66 -36
View File
@@ -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.<domain>
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<Self> {
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<DnsChallenge>)> {
/// Create a new ACME order for the given domains/IPs.
pub async fn create_order(&self, domains: &[String]) -> Result<Order> {
let identifiers: Vec<Identifier> = domains
.iter()
.map(|d| Identifier::Dns(d.clone()))
.map(|d| {
if let Ok(ip) = d.parse::<IpAddr>() {
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<Vec<DnsChallenge>> {
/// 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<Vec<ChallengeInfo>> {
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<String> {
let mut retries = 20;
loop {
+1
View File
@@ -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 {
+1
View File
@@ -9,6 +9,7 @@ mod error;
mod hooks;
mod keys;
mod renew;
mod solver;
mod state;
use clap::Parser;
+184 -194
View File
@@ -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<HashMap<String, Box<dyn Solver>>> {
let mut solvers: HashMap<String, Box<dyn Solver>> = HashMap::new();
for &name in solver_names {
if solvers.contains_key(name) {
continue;
}
let solver_config = config.solver_config(name)?;
let solver: Box<dyn Solver> = 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<ChallengeType> {
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<bool> {
// 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::<Result<Vec<_>>>()?;
// 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::<Result<Vec<_>>>()?;
// 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<Option<CertKeyPair>> {
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,
+65
View File
@@ -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
}
}
+19
View File
@@ -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
}
}