use hickory_resolver::{
TokioResolver,
config::{ConnectionConfig, NameServerConfig, ResolverConfig},
lookup::Lookup,
net::{NetError, runtime::TokioRuntimeProvider},
proto::rr::{RData, RecordType},
};
use lru::LruCache;
use std::{
net::IpAddr,
num::NonZeroUsize,
sync::{Arc, LazyLock},
};
use crate::prelude::*;
use cloudillo_types::address::AddressType;
const ROOT_SERVERS: [&str; 13] = [
"198.41.0.4", "199.9.14.201", "192.33.4.12", "199.7.91.13", "192.203.230.10", "192.5.5.241", "192.112.36.4", "198.97.190.53", "192.36.148.17", "192.58.128.30", "193.0.14.129", "199.7.83.42", "202.12.27.33", ];
static ROOT_SERVER_IPS: LazyLock<Vec<IpAddr>> =
LazyLock::new(|| ROOT_SERVERS.iter().filter_map(|ip| ip.parse().ok()).collect());
const NS_CACHE_CAPACITY: NonZeroUsize = match NonZeroUsize::new(256) {
Some(n) => n,
None => NonZeroUsize::MIN,
};
const NS_CACHE_TTL_SECS: i64 = 300;
#[derive(Clone)]
struct CachedNs {
ips: Vec<IpAddr>,
valid_until: Timestamp,
}
#[derive(Debug)]
enum LookupOutcome {
Found(String),
NoRecord,
LookupError,
}
pub struct DnsResolver {
ns_cache: Arc<parking_lot::Mutex<LruCache<Box<str>, CachedNs>>>,
}
impl DnsResolver {
pub fn new() -> ClResult<Self> {
debug!("Created DNS resolver with {} root servers", ROOT_SERVERS.len());
Ok(Self { ns_cache: Arc::new(parking_lot::Mutex::new(LruCache::new(NS_CACHE_CAPACITY))) })
}
#[expect(clippy::unused_self, reason = "method for consistency")]
fn create_resolver_for_ns(&self, ns_ips: &[IpAddr]) -> ClResult<TokioResolver> {
let name_servers = ns_ips
.iter()
.map(|ip| {
NameServerConfig::new(
*ip,
true,
vec![ConnectionConfig::udp(), ConnectionConfig::tcp()],
)
})
.collect();
let config = ResolverConfig::from_parts(None, vec![], name_servers);
TokioResolver::builder_with_config(config, TokioRuntimeProvider::default())
.build()
.map_err(|e| Error::ValidationError(format!("dns resolver build: {e}")))
}
async fn lookup_with_retry(
resolver: &TokioResolver,
name: &str,
rtype: RecordType,
) -> Result<Lookup, NetError> {
const ATTEMPTS: u32 = 3;
const BACKOFF_MS: [u64; 2] = [300, 900];
let mut last_err: Option<NetError> = None;
for attempt in 0..ATTEMPTS {
match resolver.lookup(name, rtype).await {
Ok(r) => return Ok(r),
Err(e) => {
if e.is_no_records_found() {
debug!(
query = %name,
rtype = ?rtype,
error = %e,
"DNS lookup returned no records (authoritative negative answer)"
);
return Err(e);
}
let is_final = attempt + 1 >= ATTEMPTS;
if is_final {
warn!(
query = %name,
rtype = ?rtype,
attempt = attempt + 1,
total_attempts = ATTEMPTS,
error = %e,
"DNS lookup failed (final)"
);
} else {
debug!(
query = %name,
rtype = ?rtype,
attempt = attempt + 1,
total_attempts = ATTEMPTS,
error = %e,
"DNS lookup failed, will retry"
);
let idx = attempt as usize;
if idx < BACKOFF_MS.len() {
tokio::time::sleep(std::time::Duration::from_millis(BACKOFF_MS[idx]))
.await;
}
}
last_err = Some(e);
}
}
}
match last_err {
Some(e) => Err(e),
None => Err(NetError::Message("dns lookup retry loop produced no error")),
}
}
async fn collect_addr_records(
resolver: &TokioResolver,
name: &str,
rtype: RecordType,
ips: &mut Vec<IpAddr>,
) {
let Ok(lookup) = Self::lookup_with_retry(resolver, name, rtype).await else {
return;
};
for record in lookup.answers() {
match &record.data {
RData::A(a) => ips.push(IpAddr::V4(a.0)),
RData::AAAA(aaaa) => ips.push(IpAddr::V6(aaaa.0)),
_ => {}
}
}
}
async fn resolve_ns_to_ips(&self, ns_names: &[String], depth: u8) -> Vec<IpAddr> {
let mut ips = Vec::new();
for ns_name in ns_names {
let Ok(auth_ns) = Box::pin(self.find_authoritative_ns_depth(ns_name, depth + 1)).await
else {
continue;
};
if auth_ns.is_empty() {
continue;
}
let Ok(auth_resolver) = self.create_resolver_for_ns(&auth_ns) else {
continue;
};
Self::collect_addr_records(&auth_resolver, ns_name, RecordType::A, &mut ips).await;
Self::collect_addr_records(&auth_resolver, ns_name, RecordType::AAAA, &mut ips).await;
}
if ips.is_empty() && !ns_names.is_empty() {
warn!(
ns_names = ?ns_names,
"Failed to resolve any NS names to IPs"
);
}
ips
}
async fn find_authoritative_ns(&self, domain: &str) -> ClResult<Vec<IpAddr>> {
self.find_authoritative_ns_depth(domain, 0).await
}
async fn find_authoritative_ns_depth(&self, domain: &str, depth: u8) -> ClResult<Vec<IpAddr>> {
const MAX_DEPTH: u8 = 4;
let labels: Vec<&str> = domain.trim_end_matches('.').split('.').collect();
if depth >= MAX_DEPTH {
warn!(
domain = %domain,
depth = depth,
max_depth = MAX_DEPTH,
"find_authoritative_ns_depth: max recursion depth reached, returning empty, caller will skip"
);
return Ok(Vec::new());
}
let key: Box<str> = domain.trim_end_matches('.').into();
{
let mut cache = self.ns_cache.lock();
if let Some(entry) = cache.get(&key)
&& entry.valid_until.0 > Timestamp::now().0
{
return Ok(entry.ips.clone());
}
}
let mut current_ns_ips: Vec<IpAddr> = ROOT_SERVER_IPS.clone();
let mut current_resolver = self.create_resolver_for_ns(¤t_ns_ips)?;
for i in (0..labels.len()).rev() {
let subdomain = labels[i..].join(".") + ".";
debug!(subdomain = %subdomain, "Looking up NS for zone");
match Self::lookup_with_retry(¤t_resolver, subdomain.as_str(), RecordType::NS)
.await
{
Ok(ns_lookup) => {
let mut ns_names: Vec<String> = Vec::new();
let mut glue_ips: Vec<IpAddr> = Vec::new();
for record in ns_lookup.answers().iter().chain(ns_lookup.authorities()) {
if let RData::NS(ns) = &record.data {
let ns_name = ns.0.to_string();
debug!(subdomain = %subdomain, ns = %ns_name, "Found NS record");
ns_names.push(ns_name);
}
}
for record in ns_lookup.additionals().iter().chain(ns_lookup.authorities()) {
match &record.data {
RData::A(a) => glue_ips.push(IpAddr::V4(a.0)),
RData::AAAA(aaaa) => glue_ips.push(IpAddr::V6(aaaa.0)),
_ => {}
}
}
if !ns_names.is_empty() {
let ns_ips = if glue_ips.is_empty() {
self.resolve_ns_to_ips(&ns_names, depth).await
} else {
glue_ips
};
if ns_ips.is_empty() {
debug!(
subdomain = %subdomain,
ns_names = ?ns_names,
"Got NS names but failed to resolve any to IPs — keeping parent NS"
);
} else {
debug!(
subdomain = %subdomain,
ns_count = ns_ips.len(),
"Updated authoritative NS"
);
current_ns_ips = ns_ips;
current_resolver = self.create_resolver_for_ns(¤t_ns_ips)?;
}
}
}
Err(e) => {
if e.is_no_records_found() {
debug!(
subdomain = %subdomain,
ns_count_in = current_ns_ips.len(),
"No NS delegation at this level — stopping walk-down, using parent NS"
);
break;
}
warn!(
subdomain = %subdomain,
ns_count_in = current_ns_ips.len(),
error = %e,
"NS lookup failed at this level (all retries exhausted)"
);
}
}
}
debug!(
domain = %domain,
ns_count = current_ns_ips.len(),
"Found authoritative nameservers"
);
if depth == 0 {
let valid_until = Timestamp(Timestamp::now().0 + NS_CACHE_TTL_SECS);
self.ns_cache
.lock()
.put(key, CachedNs { ips: current_ns_ips.clone(), valid_until });
}
Ok(current_ns_ips)
}
pub async fn resolve_a(&self, domain: &str) -> ClResult<Option<String>> {
match self.resolve_a_outcome(domain).await? {
LookupOutcome::Found(ip) => Ok(Some(ip)),
LookupOutcome::NoRecord | LookupOutcome::LookupError => Ok(None),
}
}
async fn resolve_a_outcome(&self, domain: &str) -> ClResult<LookupOutcome> {
debug!(domain = %domain, "Starting A record resolution from root");
let auth_ns = self.find_authoritative_ns(domain).await?;
if auth_ns.is_empty() {
warn!(domain = %domain, "Could not find authoritative nameservers");
return Ok(LookupOutcome::LookupError);
}
let auth_resolver = self.create_resolver_for_ns(&auth_ns)?;
debug!(domain = %domain, "Querying A records from authoritative NS");
match Self::lookup_with_retry(&auth_resolver, domain, RecordType::A).await {
Ok(lookup) => {
for record in lookup.answers() {
if let RData::A(a) = &record.data {
let ip = a.0.to_string();
debug!(domain = %domain, ip = %ip, "Found A record");
return Ok(LookupOutcome::Found(ip));
}
}
Ok(LookupOutcome::NoRecord)
}
Err(e) => {
if e.is_no_records_found() {
debug!(
domain = %domain,
rtype = ?RecordType::A,
error = %e,
"Authoritative NS returned no record (no answer)"
);
Ok(LookupOutcome::NoRecord)
} else {
Ok(LookupOutcome::LookupError)
}
}
}
}
pub async fn resolve_cname(&self, domain: &str) -> ClResult<Option<String>> {
match self.resolve_cname_outcome(domain).await? {
LookupOutcome::Found(name) => Ok(Some(name)),
LookupOutcome::NoRecord | LookupOutcome::LookupError => Ok(None),
}
}
async fn resolve_cname_outcome(&self, domain: &str) -> ClResult<LookupOutcome> {
debug!(domain = %domain, "Starting CNAME record resolution from root");
let auth_ns = self.find_authoritative_ns(domain).await?;
if auth_ns.is_empty() {
warn!(domain = %domain, "Could not find authoritative nameservers");
return Ok(LookupOutcome::LookupError);
}
let auth_resolver = self.create_resolver_for_ns(&auth_ns)?;
debug!(domain = %domain, "Querying CNAME records from authoritative NS");
match Self::lookup_with_retry(&auth_resolver, domain, RecordType::CNAME).await {
Ok(lookup) => {
for record in lookup.answers() {
if let RData::CNAME(cname) = &record.data {
let target = cname.0.to_string().trim_end_matches('.').to_string();
debug!(domain = %domain, cname = %target, "Found CNAME record");
return Ok(LookupOutcome::Found(target));
}
}
Ok(LookupOutcome::NoRecord)
}
Err(e) => {
if e.is_no_records_found() {
debug!(
domain = %domain,
rtype = ?RecordType::CNAME,
error = %e,
"Authoritative NS returned no record (no answer)"
);
Ok(LookupOutcome::NoRecord)
} else {
Ok(LookupOutcome::LookupError)
}
}
}
}
}
struct Signature {
tokens: Vec<String>,
had_record: bool,
transient: bool,
}
fn normalize_name(name: &str) -> String {
name.trim_end_matches('.').to_ascii_lowercase()
}
fn first_shared_token<'a>(a: &'a [String], b: &[String]) -> Option<&'a String> {
a.iter().find(|t| b.iter().any(|u| u == *t))
}
impl DnsResolver {
async fn resolve_signature(&self, name: &str) -> ClResult<Signature> {
const MAX_CNAME_HOPS: u8 = 8;
if name.parse::<IpAddr>().is_ok() {
return Ok(Signature {
tokens: vec![name.to_string()],
had_record: true,
transient: false,
});
}
let mut tokens = vec![normalize_name(name)];
let mut had_record = false;
let mut current = name.to_string();
for _ in 0..MAX_CNAME_HOPS {
match self.resolve_cname_outcome(¤t).await? {
LookupOutcome::Found(target) => {
had_record = true;
tokens.push(normalize_name(&target));
current = target;
}
LookupOutcome::NoRecord => {
match self.resolve_a_outcome(¤t).await? {
LookupOutcome::Found(ip) => {
had_record = true;
tokens.push(ip);
}
LookupOutcome::NoRecord => {}
LookupOutcome::LookupError => {
return Ok(Signature { tokens, had_record, transient: true });
}
}
return Ok(Signature { tokens, had_record, transient: false });
}
LookupOutcome::LookupError => {
return Ok(Signature { tokens, had_record, transient: true });
}
}
}
warn!(name = %name, "CNAME chain exceeded max hops; treating as transient");
Ok(Signature { tokens, had_record, transient: true })
}
}
pub fn create_recursive_resolver() -> ClResult<Arc<DnsResolver>> {
Ok(Arc::new(DnsResolver::new()?))
}
pub async fn resolve_domain_addresses(
domain: &str,
resolver: &DnsResolver,
) -> ClResult<Option<String>> {
debug!(domain = %domain, "Resolving domain addresses");
if let Some(cname) = resolver.resolve_cname(domain).await? {
return Ok(Some(cname));
}
if let Some(ip) = resolver.resolve_a(domain).await? {
return Ok(Some(ip));
}
debug!(domain = %domain, "No DNS records found");
Ok(None)
}
pub async fn validate_domain_address(
domain: &str,
local_address: &[Box<str>],
resolver: &DnsResolver,
) -> ClResult<(String, AddressType)> {
if local_address.is_empty() {
return Err(Error::ValidationError("no local address configured".to_string()));
}
let dsig = resolver.resolve_signature(domain).await?;
let mut any_transient = dsig.transient;
if !dsig.had_record && !dsig.transient {
warn!(domain = %domain, "DNS validation failed: domain has no CNAME or A record");
return Err(Error::ValidationError("nodns".to_string()));
}
for local_addr in local_address {
let lsig = resolver.resolve_signature(local_addr.as_ref()).await?;
any_transient |= lsig.transient;
if let Some(common) = first_shared_token(&dsig.tokens, &lsig.tokens) {
let addr_type = if common.parse::<IpAddr>().is_ok() {
AddressType::Ipv4
} else {
AddressType::Hostname
};
info!(
domain = %domain,
matched_local_address = %local_addr,
matched_token = %common,
"Domain validated (shared CNAME name or IP)"
);
return Ok((common.clone(), addr_type));
}
}
if any_transient {
return Err(Error::ServiceUnavailable(
"transient DNS failure during domain validation".to_string(),
));
}
warn!(
domain = %domain,
resolved = ?dsig.tokens,
local_addresses = ?local_address,
"Domain resolves but does not match any configured local address"
);
Err(Error::ValidationError("address".to_string()))
}
#[cfg(test)]
mod tests {
use super::first_shared_token;
fn s(items: &[&str]) -> Vec<String> {
items.iter().map(std::string::ToString::to_string).collect()
}
#[test]
fn case1_shared_ip() {
let d = s(&["cl-o.example.com", "1.2.3.4"]);
let l = s(&["1.2.3.4"]);
assert_eq!(first_shared_token(&d, &l), Some(&"1.2.3.4".to_string()));
}
#[test]
fn case2_different_ip_no_match() {
let d = s(&["cl-o.example.com", "1.2.3.4"]);
let l = s(&["5.6.7.8"]);
assert_eq!(first_shared_token(&d, &l), None);
}
#[test]
fn case3_shared_name_literal() {
let d = s(&["cl-o.example.com", "srv.host.net", "1.2.3.4"]);
let l = s(&["srv.host.net"]);
assert_eq!(first_shared_token(&d, &l), Some(&"srv.host.net".to_string()));
}
#[test]
fn case4_shared_cname_alias() {
let d = s(&["cl-o.home.w9.hu", "szilard-home.cloudillo.net", "84.0.234.154"]);
let l = s(&["zsuzska.symbion.hu", "szilard-home.cloudillo.net", "84.0.234.154"]);
assert_eq!(first_shared_token(&d, &l), Some(&"szilard-home.cloudillo.net".to_string()));
}
#[test]
fn case5_direct_a_same_ip_different_names() {
let d = s(&["cl-o.example.com", "1.2.3.4"]);
let l = s(&["other.host.net", "1.2.3.4"]);
assert_eq!(first_shared_token(&d, &l), Some(&"1.2.3.4".to_string()));
}
#[test]
fn case6_cname_to_ip_literal_local() {
let d = s(&["cl-o.example.com", "host.net", "1.2.3.4"]);
let l = s(&["1.2.3.4"]);
assert_eq!(first_shared_token(&d, &l), Some(&"1.2.3.4".to_string()));
}
#[test]
fn case10_first_match_wins() {
let d = s(&["a.example.com", "shared.net", "1.2.3.4"]);
let l = s(&["shared.net", "1.2.3.4"]);
assert_eq!(first_shared_token(&d, &l), Some(&"shared.net".to_string()));
}
#[test]
fn case11_round_robin_shared_cname() {
let d = s(&["cl-o.example.com", "rr.host.net", "1.2.3.4"]);
let l = s(&["alias.example.org", "rr.host.net", "9.8.7.6"]);
assert_eq!(first_shared_token(&d, &l), Some(&"rr.host.net".to_string()));
}
#[test]
fn no_shared_token() {
let d = s(&["a.example.com", "host-a.net", "1.2.3.4"]);
let l = s(&["b.example.org", "host-b.net", "5.6.7.8"]);
assert_eq!(first_shared_token(&d, &l), None);
}
}