1use hickory_resolver::{
10 TokioResolver,
11 config::{ConnectionConfig, NameServerConfig, ResolverConfig},
12 lookup::Lookup,
13 net::{NetError, runtime::TokioRuntimeProvider},
14 proto::rr::{RData, RecordType},
15};
16use lru::LruCache;
17use std::{
18 net::IpAddr,
19 num::NonZeroUsize,
20 sync::{Arc, LazyLock},
21};
22
23use crate::prelude::*;
24use cloudillo_types::address::AddressType;
25
26const ROOT_SERVERS: [&str; 13] = [
28 "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", ];
42
43static ROOT_SERVER_IPS: LazyLock<Vec<IpAddr>> =
48 LazyLock::new(|| ROOT_SERVERS.iter().filter_map(|ip| ip.parse().ok()).collect());
49
50const NS_CACHE_CAPACITY: NonZeroUsize = match NonZeroUsize::new(256) {
51 Some(n) => n,
52 None => NonZeroUsize::MIN,
53};
54const NS_CACHE_TTL_SECS: i64 = 300;
58
59#[derive(Clone)]
60struct CachedNs {
61 ips: Vec<IpAddr>,
62 valid_until: Timestamp,
63}
64
65#[derive(Debug)]
68enum LookupOutcome {
69 Found(String),
71 NoRecord,
73 LookupError,
77}
78
79pub struct DnsResolver {
81 ns_cache: Arc<parking_lot::Mutex<LruCache<Box<str>, CachedNs>>>,
82}
83
84impl DnsResolver {
85 pub fn new() -> ClResult<Self> {
87 debug!("Created DNS resolver with {} root servers", ROOT_SERVERS.len());
88 Ok(Self { ns_cache: Arc::new(parking_lot::Mutex::new(LruCache::new(NS_CACHE_CAPACITY))) })
89 }
90
91 #[expect(clippy::unused_self, reason = "method for consistency")]
93 fn create_resolver_for_ns(&self, ns_ips: &[IpAddr]) -> ClResult<TokioResolver> {
94 let name_servers = ns_ips
95 .iter()
96 .map(|ip| {
97 NameServerConfig::new(
98 *ip,
99 true,
100 vec![ConnectionConfig::udp(), ConnectionConfig::tcp()],
101 )
102 })
103 .collect();
104 let config = ResolverConfig::from_parts(None, vec![], name_servers);
105 TokioResolver::builder_with_config(config, TokioRuntimeProvider::default())
106 .build()
107 .map_err(|e| Error::ValidationError(format!("dns resolver build: {e}")))
108 }
109
110 async fn lookup_with_retry(
114 resolver: &TokioResolver,
115 name: &str,
116 rtype: RecordType,
117 ) -> Result<Lookup, NetError> {
118 const ATTEMPTS: u32 = 3;
119 const BACKOFF_MS: [u64; 2] = [300, 900];
121 let mut last_err: Option<NetError> = None;
122 for attempt in 0..ATTEMPTS {
123 match resolver.lookup(name, rtype).await {
124 Ok(r) => return Ok(r),
125 Err(e) => {
126 if e.is_no_records_found() {
129 debug!(
130 query = %name,
131 rtype = ?rtype,
132 error = %e,
133 "DNS lookup returned no records (authoritative negative answer)"
134 );
135 return Err(e);
136 }
137 let is_final = attempt + 1 >= ATTEMPTS;
138 if is_final {
139 warn!(
140 query = %name,
141 rtype = ?rtype,
142 attempt = attempt + 1,
143 total_attempts = ATTEMPTS,
144 error = %e,
145 "DNS lookup failed (final)"
146 );
147 } else {
148 debug!(
149 query = %name,
150 rtype = ?rtype,
151 attempt = attempt + 1,
152 total_attempts = ATTEMPTS,
153 error = %e,
154 "DNS lookup failed, will retry"
155 );
156 let idx = attempt as usize;
157 if idx < BACKOFF_MS.len() {
158 tokio::time::sleep(std::time::Duration::from_millis(BACKOFF_MS[idx]))
159 .await;
160 }
161 }
162 last_err = Some(e);
163 }
164 }
165 }
166 match last_err {
167 Some(e) => Err(e),
168 None => Err(NetError::Message("dns lookup retry loop produced no error")),
170 }
171 }
172
173 async fn collect_addr_records(
176 resolver: &TokioResolver,
177 name: &str,
178 rtype: RecordType,
179 ips: &mut Vec<IpAddr>,
180 ) {
181 let Ok(lookup) = Self::lookup_with_retry(resolver, name, rtype).await else {
182 return;
183 };
184 for record in lookup.answers() {
185 match &record.data {
186 RData::A(a) => ips.push(IpAddr::V4(a.0)),
187 RData::AAAA(aaaa) => ips.push(IpAddr::V6(aaaa.0)),
188 _ => {}
189 }
190 }
191 }
192
193 async fn resolve_ns_to_ips(&self, ns_names: &[String], depth: u8) -> Vec<IpAddr> {
203 let mut ips = Vec::new();
204 for ns_name in ns_names {
205 let Ok(auth_ns) = Box::pin(self.find_authoritative_ns_depth(ns_name, depth + 1)).await
210 else {
211 continue;
212 };
213 if auth_ns.is_empty() {
214 continue;
215 }
216 let Ok(auth_resolver) = self.create_resolver_for_ns(&auth_ns) else {
217 continue;
218 };
219 Self::collect_addr_records(&auth_resolver, ns_name, RecordType::A, &mut ips).await;
220 Self::collect_addr_records(&auth_resolver, ns_name, RecordType::AAAA, &mut ips).await;
221 }
222 if ips.is_empty() && !ns_names.is_empty() {
223 warn!(
224 ns_names = ?ns_names,
225 "Failed to resolve any NS names to IPs"
226 );
227 }
228 ips
229 }
230
231 async fn find_authoritative_ns(&self, domain: &str) -> ClResult<Vec<IpAddr>> {
235 self.find_authoritative_ns_depth(domain, 0).await
236 }
237
238 async fn find_authoritative_ns_depth(&self, domain: &str, depth: u8) -> ClResult<Vec<IpAddr>> {
246 const MAX_DEPTH: u8 = 4;
247 let labels: Vec<&str> = domain.trim_end_matches('.').split('.').collect();
248
249 if depth >= MAX_DEPTH {
255 warn!(
256 domain = %domain,
257 depth = depth,
258 max_depth = MAX_DEPTH,
259 "find_authoritative_ns_depth: max recursion depth reached, returning empty, caller will skip"
260 );
261 return Ok(Vec::new());
262 }
263
264 let key: Box<str> = domain.trim_end_matches('.').into();
265 {
266 let mut cache = self.ns_cache.lock();
267 if let Some(entry) = cache.get(&key)
268 && entry.valid_until.0 > Timestamp::now().0
269 {
270 return Ok(entry.ips.clone());
271 }
272 }
273
274 let mut current_ns_ips: Vec<IpAddr> = ROOT_SERVER_IPS.clone();
276
277 let mut current_resolver = self.create_resolver_for_ns(¤t_ns_ips)?;
278
279 for i in (0..labels.len()).rev() {
281 let subdomain = labels[i..].join(".") + ".";
282
283 debug!(subdomain = %subdomain, "Looking up NS for zone");
284
285 match Self::lookup_with_retry(¤t_resolver, subdomain.as_str(), RecordType::NS)
287 .await
288 {
289 Ok(ns_lookup) => {
290 let mut ns_names: Vec<String> = Vec::new();
291 let mut glue_ips: Vec<IpAddr> = Vec::new();
292
293 for record in ns_lookup.answers().iter().chain(ns_lookup.authorities()) {
295 if let RData::NS(ns) = &record.data {
296 let ns_name = ns.0.to_string();
297 debug!(subdomain = %subdomain, ns = %ns_name, "Found NS record");
298 ns_names.push(ns_name);
299 }
300 }
301
302 for record in ns_lookup.additionals().iter().chain(ns_lookup.authorities()) {
305 match &record.data {
306 RData::A(a) => glue_ips.push(IpAddr::V4(a.0)),
307 RData::AAAA(aaaa) => glue_ips.push(IpAddr::V6(aaaa.0)),
308 _ => {}
309 }
310 }
311
312 if !ns_names.is_empty() {
313 let ns_ips = if glue_ips.is_empty() {
315 self.resolve_ns_to_ips(&ns_names, depth).await
316 } else {
317 glue_ips
318 };
319
320 if ns_ips.is_empty() {
321 debug!(
322 subdomain = %subdomain,
323 ns_names = ?ns_names,
324 "Got NS names but failed to resolve any to IPs — keeping parent NS"
325 );
326 } else {
327 debug!(
328 subdomain = %subdomain,
329 ns_count = ns_ips.len(),
330 "Updated authoritative NS"
331 );
332 current_ns_ips = ns_ips;
333 current_resolver = self.create_resolver_for_ns(¤t_ns_ips)?;
334 }
335 }
336 }
337 Err(e) => {
338 if e.is_no_records_found() {
339 debug!(
343 subdomain = %subdomain,
344 ns_count_in = current_ns_ips.len(),
345 "No NS delegation at this level — stopping walk-down, using parent NS"
346 );
347 break;
348 }
349 warn!(
353 subdomain = %subdomain,
354 ns_count_in = current_ns_ips.len(),
355 error = %e,
356 "NS lookup failed at this level (all retries exhausted)"
357 );
358 }
359 }
360 }
361
362 debug!(
363 domain = %domain,
364 ns_count = current_ns_ips.len(),
365 "Found authoritative nameservers"
366 );
367
368 if depth == 0 {
369 let valid_until = Timestamp(Timestamp::now().0 + NS_CACHE_TTL_SECS);
373 self.ns_cache
374 .lock()
375 .put(key, CachedNs { ips: current_ns_ips.clone(), valid_until });
376 }
377 Ok(current_ns_ips)
378 }
379
380 pub async fn resolve_a(&self, domain: &str) -> ClResult<Option<String>> {
382 match self.resolve_a_outcome(domain).await? {
383 LookupOutcome::Found(ip) => Ok(Some(ip)),
384 LookupOutcome::NoRecord | LookupOutcome::LookupError => Ok(None),
385 }
386 }
387
388 async fn resolve_a_outcome(&self, domain: &str) -> ClResult<LookupOutcome> {
390 debug!(domain = %domain, "Starting A record resolution from root");
391
392 let auth_ns = self.find_authoritative_ns(domain).await?;
393 if auth_ns.is_empty() {
394 warn!(domain = %domain, "Could not find authoritative nameservers");
395 return Ok(LookupOutcome::LookupError);
396 }
397
398 let auth_resolver = self.create_resolver_for_ns(&auth_ns)?;
399
400 debug!(domain = %domain, "Querying A records from authoritative NS");
401 match Self::lookup_with_retry(&auth_resolver, domain, RecordType::A).await {
402 Ok(lookup) => {
403 for record in lookup.answers() {
404 if let RData::A(a) = &record.data {
405 let ip = a.0.to_string();
406 debug!(domain = %domain, ip = %ip, "Found A record");
407 return Ok(LookupOutcome::Found(ip));
408 }
409 }
410 Ok(LookupOutcome::NoRecord)
411 }
412 Err(e) => {
413 if e.is_no_records_found() {
414 debug!(
415 domain = %domain,
416 rtype = ?RecordType::A,
417 error = %e,
418 "Authoritative NS returned no record (no answer)"
419 );
420 Ok(LookupOutcome::NoRecord)
421 } else {
422 Ok(LookupOutcome::LookupError)
423 }
424 }
425 }
426 }
427
428 pub async fn resolve_cname(&self, domain: &str) -> ClResult<Option<String>> {
430 match self.resolve_cname_outcome(domain).await? {
431 LookupOutcome::Found(name) => Ok(Some(name)),
432 LookupOutcome::NoRecord | LookupOutcome::LookupError => Ok(None),
433 }
434 }
435
436 async fn resolve_cname_outcome(&self, domain: &str) -> ClResult<LookupOutcome> {
438 debug!(domain = %domain, "Starting CNAME record resolution from root");
439
440 let auth_ns = self.find_authoritative_ns(domain).await?;
441 if auth_ns.is_empty() {
442 warn!(domain = %domain, "Could not find authoritative nameservers");
443 return Ok(LookupOutcome::LookupError);
444 }
445
446 let auth_resolver = self.create_resolver_for_ns(&auth_ns)?;
447
448 debug!(domain = %domain, "Querying CNAME records from authoritative NS");
449 match Self::lookup_with_retry(&auth_resolver, domain, RecordType::CNAME).await {
450 Ok(lookup) => {
451 for record in lookup.answers() {
452 if let RData::CNAME(cname) = &record.data {
453 let target = cname.0.to_string().trim_end_matches('.').to_string();
454 debug!(domain = %domain, cname = %target, "Found CNAME record");
455 return Ok(LookupOutcome::Found(target));
456 }
457 }
458 Ok(LookupOutcome::NoRecord)
459 }
460 Err(e) => {
461 if e.is_no_records_found() {
462 debug!(
463 domain = %domain,
464 rtype = ?RecordType::CNAME,
465 error = %e,
466 "Authoritative NS returned no record (no answer)"
467 );
468 Ok(LookupOutcome::NoRecord)
469 } else {
470 Ok(LookupOutcome::LookupError)
471 }
472 }
473 }
474 }
475}
476
477struct Signature {
484 tokens: Vec<String>,
485 had_record: bool,
486 transient: bool,
487}
488
489fn normalize_name(name: &str) -> String {
490 name.trim_end_matches('.').to_ascii_lowercase()
491}
492
493fn first_shared_token<'a>(a: &'a [String], b: &[String]) -> Option<&'a String> {
496 a.iter().find(|t| b.iter().any(|u| u == *t))
497}
498
499impl DnsResolver {
500 async fn resolve_signature(&self, name: &str) -> ClResult<Signature> {
503 const MAX_CNAME_HOPS: u8 = 8;
504 if name.parse::<IpAddr>().is_ok() {
505 return Ok(Signature {
506 tokens: vec![name.to_string()],
507 had_record: true,
508 transient: false,
509 });
510 }
511 let mut tokens = vec![normalize_name(name)];
512 let mut had_record = false;
513 let mut current = name.to_string();
514 for _ in 0..MAX_CNAME_HOPS {
515 match self.resolve_cname_outcome(¤t).await? {
516 LookupOutcome::Found(target) => {
517 had_record = true;
518 tokens.push(normalize_name(&target));
519 current = target;
520 }
521 LookupOutcome::NoRecord => {
522 match self.resolve_a_outcome(¤t).await? {
524 LookupOutcome::Found(ip) => {
525 had_record = true;
526 tokens.push(ip);
527 }
528 LookupOutcome::NoRecord => {}
529 LookupOutcome::LookupError => {
530 return Ok(Signature { tokens, had_record, transient: true });
531 }
532 }
533 return Ok(Signature { tokens, had_record, transient: false });
534 }
535 LookupOutcome::LookupError => {
536 return Ok(Signature { tokens, had_record, transient: true });
537 }
538 }
539 }
540 warn!(name = %name, "CNAME chain exceeded max hops; treating as transient");
542 Ok(Signature { tokens, had_record, transient: true })
543 }
544}
545
546pub fn create_recursive_resolver() -> ClResult<Arc<DnsResolver>> {
548 Ok(Arc::new(DnsResolver::new()?))
549}
550
551pub async fn resolve_domain_addresses(
554 domain: &str,
555 resolver: &DnsResolver,
556) -> ClResult<Option<String>> {
557 debug!(domain = %domain, "Resolving domain addresses");
558
559 if let Some(cname) = resolver.resolve_cname(domain).await? {
561 return Ok(Some(cname));
562 }
563 if let Some(ip) = resolver.resolve_a(domain).await? {
564 return Ok(Some(ip));
565 }
566
567 debug!(domain = %domain, "No DNS records found");
568 Ok(None)
569}
570
571pub async fn validate_domain_address(
597 domain: &str,
598 local_address: &[Box<str>],
599 resolver: &DnsResolver,
600) -> ClResult<(String, AddressType)> {
601 if local_address.is_empty() {
602 return Err(Error::ValidationError("no local address configured".to_string()));
603 }
604
605 let dsig = resolver.resolve_signature(domain).await?;
606 let mut any_transient = dsig.transient;
607
608 if !dsig.had_record && !dsig.transient {
611 warn!(domain = %domain, "DNS validation failed: domain has no CNAME or A record");
612 return Err(Error::ValidationError("nodns".to_string()));
613 }
614
615 for local_addr in local_address {
616 let lsig = resolver.resolve_signature(local_addr.as_ref()).await?;
617 any_transient |= lsig.transient;
618 if let Some(common) = first_shared_token(&dsig.tokens, &lsig.tokens) {
621 let addr_type = if common.parse::<IpAddr>().is_ok() {
622 AddressType::Ipv4
623 } else {
624 AddressType::Hostname
625 };
626 info!(
627 domain = %domain,
628 matched_local_address = %local_addr,
629 matched_token = %common,
630 "Domain validated (shared CNAME name or IP)"
631 );
632 return Ok((common.clone(), addr_type));
633 }
634 }
635
636 if any_transient {
639 return Err(Error::ServiceUnavailable(
640 "transient DNS failure during domain validation".to_string(),
641 ));
642 }
643 warn!(
644 domain = %domain,
645 resolved = ?dsig.tokens,
646 local_addresses = ?local_address,
647 "Domain resolves but does not match any configured local address"
648 );
649 Err(Error::ValidationError("address".to_string()))
650}
651
652#[cfg(test)]
653mod tests {
654 use super::first_shared_token;
655
656 fn s(items: &[&str]) -> Vec<String> {
657 items.iter().map(std::string::ToString::to_string).collect()
658 }
659
660 #[test]
661 fn case1_shared_ip() {
662 let d = s(&["cl-o.example.com", "1.2.3.4"]);
664 let l = s(&["1.2.3.4"]);
665 assert_eq!(first_shared_token(&d, &l), Some(&"1.2.3.4".to_string()));
666 }
667
668 #[test]
669 fn case2_different_ip_no_match() {
670 let d = s(&["cl-o.example.com", "1.2.3.4"]);
672 let l = s(&["5.6.7.8"]);
673 assert_eq!(first_shared_token(&d, &l), None);
674 }
675
676 #[test]
677 fn case3_shared_name_literal() {
678 let d = s(&["cl-o.example.com", "srv.host.net", "1.2.3.4"]);
680 let l = s(&["srv.host.net"]);
681 assert_eq!(first_shared_token(&d, &l), Some(&"srv.host.net".to_string()));
682 }
683
684 #[test]
685 fn case4_shared_cname_alias() {
686 let d = s(&["cl-o.home.w9.hu", "szilard-home.cloudillo.net", "84.0.234.154"]);
688 let l = s(&["zsuzska.symbion.hu", "szilard-home.cloudillo.net", "84.0.234.154"]);
689 assert_eq!(first_shared_token(&d, &l), Some(&"szilard-home.cloudillo.net".to_string()));
690 }
691
692 #[test]
693 fn case5_direct_a_same_ip_different_names() {
694 let d = s(&["cl-o.example.com", "1.2.3.4"]);
696 let l = s(&["other.host.net", "1.2.3.4"]);
697 assert_eq!(first_shared_token(&d, &l), Some(&"1.2.3.4".to_string()));
698 }
699
700 #[test]
701 fn case6_cname_to_ip_literal_local() {
702 let d = s(&["cl-o.example.com", "host.net", "1.2.3.4"]);
704 let l = s(&["1.2.3.4"]);
705 assert_eq!(first_shared_token(&d, &l), Some(&"1.2.3.4".to_string()));
706 }
707
708 #[test]
709 fn case10_first_match_wins() {
710 let d = s(&["a.example.com", "shared.net", "1.2.3.4"]);
712 let l = s(&["shared.net", "1.2.3.4"]);
713 assert_eq!(first_shared_token(&d, &l), Some(&"shared.net".to_string()));
714 }
715
716 #[test]
717 fn case11_round_robin_shared_cname() {
718 let d = s(&["cl-o.example.com", "rr.host.net", "1.2.3.4"]);
721 let l = s(&["alias.example.org", "rr.host.net", "9.8.7.6"]);
722 assert_eq!(first_shared_token(&d, &l), Some(&"rr.host.net".to_string()));
723 }
724
725 #[test]
726 fn no_shared_token() {
727 let d = s(&["a.example.com", "host-a.net", "1.2.3.4"]);
728 let l = s(&["b.example.org", "host-b.net", "5.6.7.8"]);
729 assert_eq!(first_shared_token(&d, &l), None);
730 }
731}
732
733