use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use async_trait::async_trait;
#[cfg(feature = "aws-lc-rs")]
use aws_lc_rs as crypto_provider;
use crypto_provider::rand::SystemRandom;
use crypto_provider::signature::{ECDSA_P256_SHA256_ASN1_SIGNING, EcdsaKeyPair};
use rcgen::{CertificateParams, CustomExtension, KeyPair as RcgenKeyPair, PKCS_ECDSA_P256_SHA256};
#[cfg(all(feature = "ring", not(feature = "aws-lc-rs")))]
use ring as crypto_provider;
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use sha2::{Digest, Sha256};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::{Mutex, RwLock};
use tokio::task::JoinHandle;
use tracing::{debug, error, warn};
use crate::dns_util::{
DEFAULT_PROPAGATION_INTERVAL, DEFAULT_PROPAGATION_TIMEOUT, challenge_record_name,
challenge_record_value, check_dns_propagation, find_zone_by_fqdn, from_fqdn,
};
use crate::error::{Error, Result};
use crate::storage::{Storage, safe_key};
type ChallengeMap =
Arc<RwLock<HashMap<String, (Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)>>>;
static ACTIVE_CHALLENGES: OnceLock<RwLock<HashMap<String, String>>> = OnceLock::new();
fn active_challenges() -> &'static RwLock<HashMap<String, String>> {
ACTIVE_CHALLENGES.get_or_init(|| RwLock::new(HashMap::new()))
}
pub fn get_active_challenge(identifier: &str) -> Option<String> {
let rt = tokio::runtime::Handle::try_current();
match rt {
Ok(_handle) => {
let map = active_challenges().try_read().ok()?;
map.get(identifier).cloned()
}
Err(_) => None,
}
}
async fn register_active_challenge(identifier: &str, key_auth: &str) {
let mut map = active_challenges().write().await;
map.insert(identifier.to_string(), key_auth.to_string());
}
async fn unregister_active_challenge(identifier: &str) {
let mut map = active_challenges().write().await;
map.remove(identifier);
}
#[async_trait]
pub trait Solver: Send + Sync {
async fn present(&self, domain: &str, token: &str, key_auth: &str) -> Result<()>;
async fn wait(&self, _domain: &str, _token: &str, _key_auth: &str) -> Result<()> {
Ok(())
}
async fn cleanup(&self, domain: &str, token: &str, key_auth: &str) -> Result<()>;
}
const CHALLENGE_TOKENS_PREFIX: &str = "challenge_tokens";
fn challenge_tokens_key(issuer_prefix: &str, domain: &str) -> String {
let safe_domain = safe_key(domain);
if issuer_prefix.is_empty() {
format!("{CHALLENGE_TOKENS_PREFIX}/{safe_domain}.json")
} else {
format!("{issuer_prefix}/{CHALLENGE_TOKENS_PREFIX}/{safe_domain}.json")
}
}
pub struct Http01Solver {
challenges: Arc<RwLock<HashMap<String, String>>>,
pub port: u16,
server_handle: Mutex<Option<JoinHandle<()>>>,
}
impl Http01Solver {
pub fn new(port: u16) -> Self {
Self {
challenges: Arc::new(RwLock::new(HashMap::new())),
port,
server_handle: Mutex::new(None),
}
}
async fn ensure_server_running(&self) -> Result<()> {
let mut handle = self.server_handle.lock().await;
if handle.is_some() {
return Ok(());
}
let addr = format!("0.0.0.0:{}", self.port);
let listener = match TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {
debug!(addr = %addr, "address in use, retrying after 100ms");
tokio::time::sleep(Duration::from_millis(100)).await;
match TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e2) => {
warn!(
addr = %addr,
error = %e2,
"failed to bind HTTP-01 challenge server after retry; \
assuming an existing listener can handle it"
);
return Ok(());
}
}
}
Err(e) => {
return Err(Error::Other(format!(
"failed to bind HTTP-01 challenge server on {addr}: {e}"
)));
}
};
debug!(addr = %addr, "HTTP-01 challenge server started");
let challenges = Arc::clone(&self.challenges);
let jh = tokio::spawn(async move {
loop {
let accept_result = listener.accept().await;
let (mut stream, peer) = match accept_result {
Ok(v) => v,
Err(e) => {
warn!(error = %e, "HTTP-01 server accept error");
continue;
}
};
let challenges = Arc::clone(&challenges);
tokio::spawn(async move {
let handler = async {
let mut buf = Vec::with_capacity(8192);
let mut tmp = [0u8; 1024];
let headers_end;
loop {
let n = match stream.read(&mut tmp).await {
Ok(0) => return,
Ok(n) => n,
Err(_) => return,
};
buf.extend_from_slice(&tmp[..n]);
if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
headers_end = pos;
break;
}
if buf.len() > 65536 {
return;
}
}
let request = String::from_utf8_lossy(&buf[..headers_end]);
let request_line = match request.lines().next() {
Some(line) => line,
None => return,
};
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("");
let path = parts.next().unwrap_or("");
if !method.eq_ignore_ascii_case("GET") {
let response = "HTTP/1.1 405 Method Not Allowed\r\n\
Content-Length: 0\r\n\
Content-Type: text/plain\r\n\
Connection: close\r\n\
\r\n";
let _ = stream.write_all(response.as_bytes()).await;
return;
}
const CHALLENGE_PATH_PREFIX: &str = "/.well-known/acme-challenge/";
if let Some(token) = path.strip_prefix(CHALLENGE_PATH_PREFIX) {
let challenges = challenges.read().await;
if let Some(key_auth) = challenges.get(token) {
let body = key_auth.as_bytes();
let response = format!(
"HTTP/1.1 200 OK\r\n\
Content-Type: text/plain\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\
\r\n",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.write_all(body).await;
debug!(token = token, peer = %peer, "served HTTP-01 challenge");
} else {
let response = "HTTP/1.1 404 Not Found\r\n\
Content-Length: 0\r\n\
Content-Type: text/plain\r\n\
Connection: close\r\n\
\r\n";
let _ = stream.write_all(response.as_bytes()).await;
}
} else {
let response = "HTTP/1.1 404 Not Found\r\n\
Content-Length: 0\r\n\
Content-Type: text/plain\r\n\
Connection: close\r\n\
\r\n";
let _ = stream.write_all(response.as_bytes()).await;
}
};
let _ = tokio::time::timeout(Duration::from_secs(10), handler).await;
});
}
});
*handle = Some(jh);
Ok(())
}
}
impl Default for Http01Solver {
fn default() -> Self {
Self::new(80)
}
}
impl std::fmt::Debug for Http01Solver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Http01Solver")
.field("port", &self.port)
.finish()
}
}
#[async_trait]
impl Solver for Http01Solver {
async fn present(&self, _domain: &str, token: &str, key_auth: &str) -> Result<()> {
{
let mut challenges = self.challenges.write().await;
challenges.insert(token.to_string(), key_auth.to_string());
}
register_active_challenge(token, key_auth).await;
self.ensure_server_running().await?;
debug!(token = token, "HTTP-01 challenge presented");
Ok(())
}
async fn cleanup(&self, _domain: &str, token: &str, _key_auth: &str) -> Result<()> {
let should_stop = {
let mut challenges = self.challenges.write().await;
challenges.remove(token);
challenges.is_empty()
};
unregister_active_challenge(token).await;
if should_stop {
let mut handle = self.server_handle.lock().await;
if let Some(jh) = handle.take() {
jh.abort();
debug!("HTTP-01 challenge server stopped (no more challenges)");
}
}
Ok(())
}
}
const ACME_IDENTIFIER_OID: &[u64] = &[1, 3, 6, 1, 5, 5, 7, 1, 31];
const ACME_TLS_ALPN_PROTOCOL: &[u8] = b"acme-tls/1";
pub struct TlsAlpn01Solver {
challenges: ChallengeMap,
pub port: u16,
server_handle: Mutex<Option<JoinHandle<()>>>,
}
impl TlsAlpn01Solver {
pub fn new(port: u16) -> Self {
Self {
challenges: Arc::new(RwLock::new(HashMap::new())),
port,
server_handle: Mutex::new(None),
}
}
fn generate_challenge_cert(
domain: &str,
key_auth: &str,
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
let rng = SystemRandom::new();
let pkcs8_doc = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng)
.map_err(|e| {
Error::Other(format!(
"failed to generate ephemeral key for TLS-ALPN-01: {e}"
))
})?;
let pkcs8_bytes = pkcs8_doc.as_ref().to_vec();
let key_pair = RcgenKeyPair::from_pkcs8_der_and_sign_algo(
&PrivatePkcs8KeyDer::from(pkcs8_bytes.clone()),
&PKCS_ECDSA_P256_SHA256,
)
.map_err(|e| Error::Other(format!("failed to create rcgen key pair: {e}")))?;
let mut params = CertificateParams::new(vec![domain.to_string()])
.map_err(|e| Error::Other(format!("failed to create certificate params: {e}")))?;
let digest = Sha256::digest(key_auth.as_bytes());
let mut ext_value = Vec::with_capacity(2 + 32);
ext_value.push(0x04); ext_value.push(0x20); ext_value.extend_from_slice(&digest);
let oid_vec: Vec<u64> = ACME_IDENTIFIER_OID.to_vec();
let mut ext = CustomExtension::from_oid_content(&oid_vec, ext_value);
ext.set_criticality(true);
params.custom_extensions.push(ext);
let cert = params.self_signed(&key_pair).map_err(|e| {
Error::Other(format!(
"failed to self-sign TLS-ALPN-01 challenge certificate: {e}"
))
})?;
let cert_der = CertificateDer::from(cert.der().to_vec());
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(pkcs8_bytes));
Ok((vec![cert_der], key_der))
}
async fn ensure_server_running(&self) -> Result<()> {
let mut handle = self.server_handle.lock().await;
if handle.is_some() {
return Ok(());
}
let addr = format!("0.0.0.0:{}", self.port);
let listener = TcpListener::bind(&addr).await.map_err(|e| {
Error::Other(format!(
"failed to bind TLS-ALPN-01 challenge server on {addr}: {e}"
))
})?;
debug!(addr = %addr, "TLS-ALPN-01 challenge server started");
let challenges = Arc::clone(&self.challenges);
let jh = tokio::spawn(async move {
loop {
let (stream, peer) = match listener.accept().await {
Ok(v) => v,
Err(e) => {
warn!(error = %e, "TLS-ALPN-01 server accept error");
continue;
}
};
let challenges = Arc::clone(&challenges);
tokio::spawn(async move {
let challenges_snapshot = challenges.read().await;
if challenges_snapshot.is_empty() {
return;
}
let resolver = ChallengeCertResolver {
challenges: challenges_snapshot
.iter()
.map(|(domain, (certs, key))| {
(domain.clone(), (certs.clone(), key.clone_key()))
})
.collect(),
};
drop(challenges_snapshot);
let mut config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(Arc::new(resolver));
config.alpn_protocols = vec![ACME_TLS_ALPN_PROTOCOL.to_vec()];
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config));
match acceptor.accept(stream).await {
Ok(_tls_stream) => {
debug!(peer = %peer, "TLS-ALPN-01 handshake completed");
}
Err(e) => {
debug!(peer = %peer, error = %e, "TLS-ALPN-01 handshake failed");
}
}
});
}
});
*handle = Some(jh);
Ok(())
}
}
impl Default for TlsAlpn01Solver {
fn default() -> Self {
Self::new(443)
}
}
impl std::fmt::Debug for TlsAlpn01Solver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TlsAlpn01Solver")
.field("port", &self.port)
.finish()
}
}
#[async_trait]
impl Solver for TlsAlpn01Solver {
async fn present(&self, domain: &str, _token: &str, key_auth: &str) -> Result<()> {
let (cert_chain, key_der) = Self::generate_challenge_cert(domain, key_auth)?;
{
let mut challenges = self.challenges.write().await;
challenges.insert(domain.to_string(), (cert_chain, key_der));
}
register_active_challenge(domain, key_auth).await;
self.ensure_server_running().await?;
debug!(domain = domain, "TLS-ALPN-01 challenge presented");
Ok(())
}
async fn cleanup(&self, domain: &str, _token: &str, _key_auth: &str) -> Result<()> {
let should_stop = {
let mut challenges = self.challenges.write().await;
challenges.remove(domain);
challenges.is_empty()
};
unregister_active_challenge(domain).await;
if should_stop {
let mut handle = self.server_handle.lock().await;
if let Some(jh) = handle.take() {
jh.abort();
debug!("TLS-ALPN-01 challenge server stopped (no more challenges)");
}
}
Ok(())
}
}
#[derive(Debug)]
struct ChallengeCertResolver {
challenges: HashMap<String, (Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)>,
}
impl rustls::server::ResolvesServerCert for ChallengeCertResolver {
fn resolve(
&self,
client_hello: rustls::server::ClientHello<'_>,
) -> Option<Arc<rustls::sign::CertifiedKey>> {
let sni = client_hello.server_name()?;
let (certs, key_der) = self.challenges.get(sni)?;
let signing_key = crate::handshake::signing_key_from_der(key_der).ok()?;
Some(Arc::new(rustls::sign::CertifiedKey::new(
certs.clone(),
signing_key,
)))
}
}
#[async_trait]
pub trait DnsProvider: Send + Sync {
async fn set_record(&self, zone: &str, name: &str, value: &str, ttl: u32) -> Result<()>;
async fn delete_record(&self, zone: &str, name: &str, value: &str) -> Result<()>;
}
pub struct Dns01Solver {
pub provider: Box<dyn DnsProvider>,
pub propagation_timeout: Duration,
pub propagation_check_interval: Duration,
pub propagation_delay: Option<Duration>,
pub ttl: u32,
pub override_domain: Option<String>,
pub resolvers: Option<Vec<String>>,
records: RwLock<HashMap<(String, String), DnsRecordMemory>>,
}
struct DnsRecordMemory {
zone: String,
relative_name: String,
value: String,
}
impl Dns01Solver {
pub fn new(provider: Box<dyn DnsProvider>) -> Self {
Self {
provider,
propagation_timeout: DEFAULT_PROPAGATION_TIMEOUT,
propagation_check_interval: DEFAULT_PROPAGATION_INTERVAL,
propagation_delay: None,
ttl: 120,
override_domain: None,
resolvers: None,
records: RwLock::new(HashMap::new()),
}
}
pub fn with_timeouts(
provider: Box<dyn DnsProvider>,
propagation_timeout: Duration,
propagation_check_interval: Duration,
) -> Self {
Self {
provider,
propagation_timeout,
propagation_check_interval,
propagation_delay: None,
ttl: 120,
override_domain: None,
resolvers: None,
records: RwLock::new(HashMap::new()),
}
}
fn dns_name(&self, domain: &str) -> String {
if let Some(ref override_domain) = self.override_domain {
override_domain.clone()
} else {
challenge_record_name(domain)
}
}
}
impl std::fmt::Debug for Dns01Solver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Dns01Solver")
.field("propagation_timeout", &self.propagation_timeout)
.field(
"propagation_check_interval",
&self.propagation_check_interval,
)
.field("propagation_delay", &self.propagation_delay)
.field("ttl", &self.ttl)
.field("override_domain", &self.override_domain)
.field("resolvers", &self.resolvers)
.finish()
}
}
#[async_trait]
impl Solver for Dns01Solver {
async fn present(&self, domain: &str, _token: &str, key_auth: &str) -> Result<()> {
let dns_name = self.dns_name(domain);
let txt_value = challenge_record_value(key_auth);
let zone = find_zone_by_fqdn(&dns_name)
.ok_or_else(|| Error::Other(format!("could not determine DNS zone for {dns_name}")))?;
let dns_name_fqdn = if dns_name.ends_with('.') {
dns_name.clone()
} else {
format!("{dns_name}.")
};
let relative_name = dns_name_fqdn
.strip_suffix(&zone)
.unwrap_or(&dns_name)
.trim_end_matches('.')
.to_string();
debug!(
dns_name = %dns_name,
zone = %zone,
relative_name = %relative_name,
"creating DNS TXT record for ACME challenge"
);
self.provider
.set_record(&zone, &relative_name, &txt_value, self.ttl)
.await?;
let memory = DnsRecordMemory {
zone,
relative_name,
value: txt_value.clone(),
};
{
let mut records = self.records.write().await;
records.insert((dns_name, txt_value), memory);
}
Ok(())
}
async fn wait(&self, domain: &str, _token: &str, key_auth: &str) -> Result<()> {
let dns_name = self.dns_name(domain);
let txt_value = challenge_record_value(key_auth);
let fqdn = from_fqdn(&dns_name);
if let Some(delay) = self.propagation_delay {
debug!(
fqdn = %fqdn,
delay = ?delay,
"sleeping for configured propagation delay before checking DNS"
);
tokio::time::sleep(delay).await;
}
debug!(
fqdn = %fqdn,
"waiting for DNS propagation of challenge TXT record"
);
check_dns_propagation(
&fqdn,
&txt_value,
self.propagation_timeout,
self.propagation_check_interval,
)
.await?;
Ok(())
}
async fn cleanup(&self, domain: &str, _token: &str, key_auth: &str) -> Result<()> {
let dns_name = self.dns_name(domain);
let txt_value = challenge_record_value(key_auth);
let memory = {
let mut records = self.records.write().await;
records.remove(&(dns_name.clone(), txt_value))
};
if let Some(mem) = memory {
debug!(
zone = %mem.zone,
name = %mem.relative_name,
"deleting DNS TXT record for ACME challenge"
);
self.provider
.delete_record(&mem.zone, &mem.relative_name, &mem.value)
.await?;
} else {
warn!(
dns_name = %dns_name,
"no memory of presenting DNS record (cleanup may be incomplete)"
);
}
Ok(())
}
}
pub struct DistributedSolver {
inner: Box<dyn Solver>,
storage: Arc<dyn Storage>,
storage_key_issuer_prefix: String,
}
impl DistributedSolver {
pub fn new(inner: Box<dyn Solver>, storage: Arc<dyn Storage>) -> Self {
Self {
inner,
storage,
storage_key_issuer_prefix: String::new(),
}
}
pub fn with_prefix(inner: Box<dyn Solver>, storage: Arc<dyn Storage>, prefix: String) -> Self {
Self {
inner,
storage,
storage_key_issuer_prefix: prefix,
}
}
fn challenge_key(&self, domain: &str) -> String {
challenge_tokens_key(&self.storage_key_issuer_prefix, domain)
}
}
impl std::fmt::Debug for DistributedSolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DistributedSolver")
.field("storage_key_issuer_prefix", &self.storage_key_issuer_prefix)
.finish()
}
}
#[async_trait]
impl Solver for DistributedSolver {
async fn present(&self, domain: &str, token: &str, key_auth: &str) -> Result<()> {
let storage_key = self.challenge_key(domain);
let mut token_map: HashMap<String, String> = match self.storage.load(&storage_key).await {
Ok(data) => serde_json::from_slice(&data).unwrap_or_default(),
Err(_) => HashMap::new(),
};
token_map.insert(token.to_string(), key_auth.to_string());
let json_bytes = serde_json::to_vec(&token_map)
.map_err(|e| Error::Other(format!("serializing challenge tokens: {e}")))?;
self.storage.store(&storage_key, &json_bytes).await?;
self.inner
.present(domain, token, key_auth)
.await
.map_err(|e| Error::Other(format!("presenting with inner solver: {e}")))?;
Ok(())
}
async fn wait(&self, domain: &str, token: &str, key_auth: &str) -> Result<()> {
self.inner.wait(domain, token, key_auth).await
}
async fn cleanup(&self, domain: &str, token: &str, key_auth: &str) -> Result<()> {
let storage_key = self.challenge_key(domain);
match self.storage.load(&storage_key).await {
Ok(data) => {
let mut token_map: HashMap<String, String> =
serde_json::from_slice(&data).unwrap_or_default();
token_map.remove(token);
if token_map.is_empty() {
if let Err(e) = self.storage.delete(&storage_key).await {
error!(
key = %storage_key,
error = %e,
"failed to delete challenge token from storage during cleanup"
);
}
} else {
if let Ok(json_bytes) = serde_json::to_vec(&token_map)
&& let Err(e) = self.storage.store(&storage_key, &json_bytes).await
{
error!(
key = %storage_key,
error = %e,
"failed to update challenge tokens in storage during cleanup"
);
}
}
}
Err(e) => {
error!(
key = %storage_key,
error = %e,
"failed to load challenge tokens from storage during cleanup"
);
}
}
self.inner
.cleanup(domain, token, key_auth)
.await
.map_err(|e| Error::Other(format!("cleaning up inner solver: {e}")))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
#[test]
fn challenge_tokens_key_no_prefix() {
let key = challenge_tokens_key("", "example.com");
assert_eq!(key, "challenge_tokens/example.com.json");
}
#[test]
fn challenge_tokens_key_with_prefix() {
let key = challenge_tokens_key("acme/ca", "example.com");
assert_eq!(key, "acme/ca/challenge_tokens/example.com.json");
}
#[test]
fn challenge_tokens_key_sanitizes() {
let key = challenge_tokens_key("", "*.Example.COM");
assert!(key.contains("wildcard_"));
}
#[test]
fn http01_solver_default_port() {
let solver = Http01Solver::default();
assert_eq!(solver.port, 80);
}
#[test]
fn http01_solver_custom_port() {
let solver = Http01Solver::new(8080);
assert_eq!(solver.port, 8080);
}
#[test]
fn tls_alpn01_solver_default_port() {
let solver = TlsAlpn01Solver::default();
assert_eq!(solver.port, 443);
}
#[test]
fn tls_alpn01_generate_challenge_cert() {
let (certs, key) =
TlsAlpn01Solver::generate_challenge_cert("example.com", "token.thumbprint").unwrap();
assert_eq!(certs.len(), 1);
assert!(!certs[0].as_ref().is_empty());
match &key {
PrivateKeyDer::Pkcs8(der) => {
assert!(!der.secret_pkcs8_der().is_empty());
}
_ => panic!("expected PKCS#8 key"),
}
}
struct MockDnsProvider {
set_count: AtomicUsize,
delete_count: AtomicUsize,
}
impl MockDnsProvider {
fn new() -> Self {
Self {
set_count: AtomicUsize::new(0),
delete_count: AtomicUsize::new(0),
}
}
}
#[async_trait]
impl DnsProvider for MockDnsProvider {
async fn set_record(
&self,
_zone: &str,
_name: &str,
_value: &str,
_ttl: u32,
) -> Result<()> {
self.set_count.fetch_add(1, Ordering::SeqCst);
Ok(())
}
async fn delete_record(&self, _zone: &str, _name: &str, _value: &str) -> Result<()> {
self.delete_count.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
#[test]
fn dns01_solver_default_timeouts() {
let provider = MockDnsProvider::new();
let solver = Dns01Solver::new(Box::new(provider));
assert_eq!(solver.propagation_timeout, DEFAULT_PROPAGATION_TIMEOUT);
assert_eq!(
solver.propagation_check_interval,
DEFAULT_PROPAGATION_INTERVAL
);
assert_eq!(solver.ttl, 120);
}
#[test]
fn dns01_solver_dns_name_default() {
let provider = MockDnsProvider::new();
let solver = Dns01Solver::new(Box::new(provider));
let name = solver.dns_name("example.com");
assert_eq!(name, "_acme-challenge.example.com.");
}
#[test]
fn dns01_solver_dns_name_override() {
let provider = MockDnsProvider::new();
let mut solver = Dns01Solver::new(Box::new(provider));
solver.override_domain = Some("delegated.example.net.".to_string());
let name = solver.dns_name("example.com");
assert_eq!(name, "delegated.example.net.");
}
#[tokio::test]
async fn dns01_present_and_cleanup() {
let provider = Arc::new(MockDnsProvider::new());
let provider_ref = Arc::clone(&provider);
let solver = Dns01Solver::new(Box::new(MockDnsProviderWrapper(provider_ref)));
solver
.present("example.com", "token", "key_auth")
.await
.unwrap();
{
let records = solver.records.read().await;
assert_eq!(records.len(), 1);
}
solver
.cleanup("example.com", "token", "key_auth")
.await
.unwrap();
{
let records = solver.records.read().await;
assert!(records.is_empty());
}
assert_eq!(provider.set_count.load(Ordering::SeqCst), 1);
assert_eq!(provider.delete_count.load(Ordering::SeqCst), 1);
}
struct MockDnsProviderWrapper(Arc<MockDnsProvider>);
#[async_trait]
impl DnsProvider for MockDnsProviderWrapper {
async fn set_record(&self, zone: &str, name: &str, value: &str, ttl: u32) -> Result<()> {
self.0.set_record(zone, name, value, ttl).await
}
async fn delete_record(&self, zone: &str, name: &str, value: &str) -> Result<()> {
self.0.delete_record(zone, name, value).await
}
}
}