use std::time::Duration;
use super::{crypto, dns_api, DnsConfig, DnsProvider, Tsig};
use crate::dns;
const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(10);
const RECORD_TTL: u32 = 60;
const TSIG_FUDGE: u16 = 300;
pub(crate) fn record_name(domain: &str) -> String {
format!("_acme-challenge.{}", domain.trim_end_matches('.'))
}
pub(crate) fn record_value(key_authorization: &str) -> String {
let digest = aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, key_authorization.as_bytes());
crypto::base64url(digest.as_ref())
}
pub(crate) async fn present(config: &DnsConfig, name: &str, value: &str) -> Result<(), String> {
match &config.provider {
DnsProvider::Rfc2136 { server, tsig } => {
let zone = zone_of(config, server, name).await?;
let rdata = dns::txt_rdata(value.as_bytes());
update(server, tsig.as_ref(), &zone, name, &[Change::DeleteAll, Change::Add(&rdata)]).await
}
DnsProvider::Exec { program, .. } => run_hook(program, "present", name, value).await,
DnsProvider::Cloudflare { token, api, zone_id, .. } => dns_api::cloudflare_present(&token.0, api, zone_id.as_deref(), name, value).await,
DnsProvider::Route53 { credentials, api, zone_id, .. } => {
let wait = Duration::from_secs(config.propagation_secs);
dns_api::route53_change(credentials, api, zone_id.as_deref(), "UPSERT", name, value, wait).await
}
DnsProvider::Manual { .. } => Err("no dns_provider to publish DNS-01 records with".to_string()),
}
}
pub(crate) async fn cleanup(config: &DnsConfig, name: &str, value: &str) -> Result<(), String> {
match &config.provider {
DnsProvider::Rfc2136 { server, tsig } => {
let zone = zone_of(config, server, name).await?;
let rdata = dns::txt_rdata(value.as_bytes());
update(server, tsig.as_ref(), &zone, name, &[Change::Delete(&rdata)]).await
}
DnsProvider::Exec { program, .. } => run_hook(program, "cleanup", name, value).await,
DnsProvider::Cloudflare { token, api, zone_id, .. } => dns_api::cloudflare_cleanup(&token.0, api, zone_id.as_deref(), name, value).await,
DnsProvider::Route53 { credentials, api, zone_id, .. } => {
dns_api::route53_change(credentials, api, zone_id.as_deref(), "DELETE", name, value, Duration::ZERO).await
}
DnsProvider::Manual { .. } => Ok(()),
}
}
pub(crate) async fn wait_visible(config: &DnsConfig, name: &str, value: &str) -> bool {
let Some((server, recursive)) = lookup_server(config) else { return false };
let deadline = tokio::time::Instant::now() + Duration::from_secs(config.propagation_secs);
loop {
if let Ok(values) = lookup_txt(&server, name, recursive).await {
if values.iter().any(|v| v == value.as_bytes()) {
return true;
}
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
fn lookup_server(config: &DnsConfig) -> Option<(String, bool)> {
let configured = match &config.provider {
DnsProvider::Rfc2136 { server, .. } => Some(server),
DnsProvider::Exec { server, .. }
| DnsProvider::Cloudflare { server, .. }
| DnsProvider::Route53 { server, .. }
| DnsProvider::Manual { server } => server.as_ref(),
};
match configured {
Some(server) => Some((server_addr(server), false)),
None => dns::system_resolver().map(|a| (a.to_string(), true)),
}
}
pub(crate) fn persist_record_name(domain: &str) -> String {
format!("_validation-persist.{}", domain.trim_end_matches('.'))
}
pub(crate) fn persist_record_value(issuer: &str, account_url: &str, wildcard: bool) -> String {
let mut value = format!("{issuer}; accounturi={account_url}");
if wildcard {
value.push_str("; policy=wildcard");
}
value
}
pub(crate) fn persist_record_matches(record: &str, issuers: &[String], account_url: &str, wildcard: bool, now: i64) -> bool {
let mut parts = record.split(';');
let issuer = parts.next().unwrap_or("").trim_matches([' ', '\t']);
if !issuers.iter().any(|i| i == issuer) {
return false;
}
let (mut account, mut policy, mut until) = (None, None, None);
for param in parts {
let Some((tag, value)) = param.split_once('=') else { return false };
let value = value.trim_matches([' ', '\t']);
match tag.trim_matches([' ', '\t']).to_ascii_lowercase().as_str() {
"accounturi" => account = Some(value),
"policy" => policy = Some(value.to_ascii_lowercase()),
"persistuntil" => until = Some(value.parse::<i64>().unwrap_or(i64::MIN)),
_ => {}
}
}
account == Some(account_url)
&& (!wildcard || policy.as_deref() == Some("wildcard"))
&& until.is_none_or(|until| now <= until)
}
pub(crate) async fn persist_record_published(config: &DnsConfig, name: &str, issuers: &[String], account_url: &str, wildcard: bool) -> Result<bool, String> {
let (server, recursive) = lookup_server(config).ok_or("no DNS server to look the record up (set dns_server)")?;
let now = unix_now() as i64;
Ok(lookup_txt(&server, name, recursive)
.await?
.iter()
.any(|value| persist_record_matches(&String::from_utf8_lossy(value), issuers, account_url, wildcard, now)))
}
fn server_addr(server: &str) -> String {
let server = server.trim();
if server.starts_with('[') {
return if server.contains("]:") { server.to_string() } else { format!("{server}:53") };
}
match server.matches(':').count() {
0 => format!("{server}:53"),
1 => server.to_string(),
_ => format!("[{server}]:53"), }
}
async fn exchange(server: &str, message: &[u8]) -> Result<Vec<u8>, String> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let work = async {
let mut stream = tokio::net::TcpStream::connect(server).await.map_err(|e| format!("DNS server {server}: {e}"))?;
let mut framed = (message.len() as u16).to_be_bytes().to_vec();
framed.extend_from_slice(message);
stream.write_all(&framed).await.map_err(|e| format!("DNS server {server}: {e}"))?;
let mut len = [0u8; 2];
stream.read_exact(&mut len).await.map_err(|e| format!("DNS server {server}: {e}"))?;
let mut reply = vec![0u8; u16::from_be_bytes(len) as usize];
stream.read_exact(&mut reply).await.map_err(|e| format!("DNS server {server}: {e}"))?;
Ok::<_, String>(reply)
};
tokio::time::timeout(EXCHANGE_TIMEOUT, work).await.map_err(|_| format!("DNS server {server}: no answer in {}s", EXCHANGE_TIMEOUT.as_secs()))?
}
async fn lookup_txt(server: &str, name: &str, recursive: bool) -> Result<Vec<Vec<u8>>, String> {
let id = dns::query_id();
let reply = exchange(server, &dns::build_query(id, name, dns::TYPE_TXT, recursive)?).await?;
let message = dns::parse(&reply)?;
if message.id != id {
return Err("DNS reply to another query".to_string());
}
Ok(message
.answers
.iter()
.filter(|r| r.rtype == dns::TYPE_TXT && r.name.eq_ignore_ascii_case(name.trim_end_matches('.')))
.map(|r| dns::txt_value(&reply[r.rdata.clone()]))
.collect())
}
async fn zone_of(config: &DnsConfig, server: &str, name: &str) -> Result<String, String> {
if let Some(zone) = &config.zone {
return Ok(zone.trim_end_matches('.').to_string());
}
let server = server_addr(server);
let id = dns::query_id();
let reply = exchange(&server, &dns::build_query(id, name, dns::TYPE_SOA, false)?).await?;
let message = dns::parse(&reply)?;
message
.answers
.iter()
.chain(&message.authority)
.find(|r| r.rtype == dns::TYPE_SOA)
.map(|r| r.name.clone())
.filter(|zone| !zone.is_empty())
.ok_or_else(|| format!("couldn't find the zone of {name} at {server} (set dns_zone)"))
}
pub(crate) enum Change<'a> {
DeleteAll,
Add(&'a [u8]),
Delete(&'a [u8]),
}
async fn update(server: &str, tsig: Option<&Tsig>, zone: &str, name: &str, changes: &[Change<'_>]) -> Result<(), String> {
let id = dns::query_id();
let mut message = update_message(id, zone, name, changes)?;
if let Some(tsig) = tsig {
sign(&mut message, tsig, unix_now())?;
}
let reply = exchange(&server_addr(server), &message).await?;
check_update_reply(&reply, id).map_err(|e| format!("DNS UPDATE of {name} in {zone} at {server}: {e}"))
}
pub(crate) fn update_message(id: u16, zone: &str, name: &str, changes: &[Change<'_>]) -> Result<Vec<u8>, String> {
let mut message = Vec::with_capacity(128);
message.extend_from_slice(&id.to_be_bytes());
message.extend_from_slice(&(5u16 << 11).to_be_bytes()); message.extend_from_slice(&1u16.to_be_bytes()); message.extend_from_slice(&0u16.to_be_bytes()); message.extend_from_slice(&(changes.len() as u16).to_be_bytes()); message.extend_from_slice(&0u16.to_be_bytes()); dns::encode_name(zone, &mut message)?;
message.extend_from_slice(&dns::TYPE_SOA.to_be_bytes());
message.extend_from_slice(&dns::CLASS_IN.to_be_bytes());
for change in changes {
dns::encode_name(name, &mut message)?;
message.extend_from_slice(&dns::TYPE_TXT.to_be_bytes());
let (class, ttl, rdata): (u16, u32, &[u8]) = match change {
Change::DeleteAll => (dns::CLASS_ANY, 0, &[]),
Change::Add(rdata) => (dns::CLASS_IN, RECORD_TTL, rdata),
Change::Delete(rdata) => (dns::CLASS_NONE, 0, rdata),
};
message.extend_from_slice(&class.to_be_bytes());
message.extend_from_slice(&ttl.to_be_bytes());
message.extend_from_slice(&(rdata.len() as u16).to_be_bytes());
message.extend_from_slice(rdata);
}
Ok(message)
}
pub(crate) fn sign(message: &mut Vec<u8>, tsig: &Tsig, time_signed: u64) -> Result<(), String> {
if message.len() < 12 {
return Err("DNS message shorter than its header".to_string());
}
let mut key_name = Vec::new();
dns::encode_name(&tsig.key_name, &mut key_name)?;
let mut algorithm = Vec::new();
dns::encode_name(&tsig.algorithm, &mut algorithm)?;
let time = &time_signed.to_be_bytes()[2..];
let mut signed = message.clone();
signed.extend_from_slice(&key_name);
signed.extend_from_slice(&dns::CLASS_ANY.to_be_bytes());
signed.extend_from_slice(&0u32.to_be_bytes());
signed.extend_from_slice(&algorithm);
signed.extend_from_slice(time);
signed.extend_from_slice(&TSIG_FUDGE.to_be_bytes());
signed.extend_from_slice(&[0, 0, 0, 0]); let hmac_algorithm = match tsig.algorithm.trim_end_matches('.').to_ascii_lowercase().as_str() {
"hmac-sha256" => aws_lc_rs::hmac::HMAC_SHA256,
"hmac-sha512" => aws_lc_rs::hmac::HMAC_SHA512,
other => return Err(format!("unsupported TSIG algorithm {other}")),
};
let mac = aws_lc_rs::hmac::sign(&aws_lc_rs::hmac::Key::new(hmac_algorithm, &tsig.secret), &signed);
let mac = mac.as_ref();
let original_id = [message[0], message[1]];
let mut rdata = algorithm;
rdata.extend_from_slice(time);
rdata.extend_from_slice(&TSIG_FUDGE.to_be_bytes());
rdata.extend_from_slice(&(mac.len() as u16).to_be_bytes());
rdata.extend_from_slice(mac);
rdata.extend_from_slice(&original_id);
rdata.extend_from_slice(&[0, 0, 0, 0]);
message.extend_from_slice(&key_name);
message.extend_from_slice(&dns::TYPE_TSIG.to_be_bytes());
message.extend_from_slice(&dns::CLASS_ANY.to_be_bytes());
message.extend_from_slice(&0u32.to_be_bytes());
message.extend_from_slice(&(rdata.len() as u16).to_be_bytes());
message.extend_from_slice(&rdata);
let arcount = u16::from_be_bytes([message[10], message[11]]) + 1;
message[10..12].copy_from_slice(&arcount.to_be_bytes());
Ok(())
}
pub(crate) fn check_update_reply(reply: &[u8], id: u16) -> Result<(), String> {
let message = dns::parse(reply)?;
if message.id != id {
return Err("reply to another message".to_string());
}
let rcode = message.rcode();
if rcode == 0 {
return Ok(());
}
let tsig_error = message.additional.iter().find(|r| r.rtype == dns::TYPE_TSIG).and_then(|r| tsig_error(&reply[r.rdata.clone()]));
let rcode_name = match rcode {
1 => "FORMERR",
2 => "SERVFAIL",
3 => "NXDOMAIN",
4 => "NOTIMP",
5 => "REFUSED (does the key's update policy cover this name?)",
8 => "NXRRSET",
9 => "NOTAUTH (the server isn't the primary for the zone, or the TSIG key was rejected)",
10 => "NOTZONE (the name isn't in the zone)",
_ => "an error",
};
Err(match tsig_error {
Some(16) => format!("rcode {rcode} {rcode_name}; TSIG BADSIG: wrong secret"),
Some(17) => format!("rcode {rcode} {rcode_name}; TSIG BADKEY: the server doesn't know the key or algorithm"),
Some(18) => format!("rcode {rcode} {rcode_name}; TSIG BADTIME: the clocks differ by more than {TSIG_FUDGE}s"),
Some(code) if code != 0 => format!("rcode {rcode} {rcode_name}; TSIG error {code}"),
_ => format!("rcode {rcode} {rcode_name}"),
})
}
fn tsig_error(rdata: &[u8]) -> Option<u16> {
let (_, after_algorithm) = dns::read_name(rdata, 0).ok()?;
let mac_size = u16::from_be_bytes([*rdata.get(after_algorithm + 8)?, *rdata.get(after_algorithm + 9)?]) as usize;
let error_at = after_algorithm + 10 + mac_size + 2;
Some(u16::from_be_bytes([*rdata.get(error_at)?, *rdata.get(error_at + 1)?]))
}
async fn run_hook(program: &str, action: &str, name: &str, value: &str) -> Result<(), String> {
let (program, action, fqdn, value) = (program.to_string(), action.to_string(), format!("{}.", name.trim_end_matches('.')), value.to_string());
let run = tokio::task::spawn_blocking(move || std::process::Command::new(&program).args([&action, &fqdn, &value]).output().map(|o| (program, action, o)));
let (program, action, output) = run.await.map_err(|e| e.to_string())?.map_err(|e| format!("DNS-01 hook: {e}"))?;
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.trim().is_empty() {
eprintln!("[ACME] {program} {action}: {}", stderr.trim());
}
if output.status.success() {
Ok(())
} else {
Err(format!("DNS-01 hook {program} {action} exited with {}", output.status))
}
}
fn unix_now() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
#[cfg(test)]
mod tests;