Skip to main content

cloudillo_core/
dns.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! DNS resolver module with true recursive resolution from root nameservers
5//!
6//! This module provides DNS resolution capabilities using manual recursive
7//! resolution starting from root nameservers.
8
9use 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
26/// Root nameserver IPs - the 13 official ICANN root servers
27const ROOT_SERVERS: [&str; 13] = [
28	"198.41.0.4",     // A.ROOT-SERVERS.NET
29	"199.9.14.201",   // B.ROOT-SERVERS.NET
30	"192.33.4.12",    // C.ROOT-SERVERS.NET
31	"199.7.91.13",    // D.ROOT-SERVERS.NET
32	"192.203.230.10", // E.ROOT-SERVERS.NET
33	"192.5.5.241",    // F.ROOT-SERVERS.NET
34	"192.112.36.4",   // G.ROOT-SERVERS.NET
35	"198.97.190.53",  // H.ROOT-SERVERS.NET
36	"192.36.148.17",  // I.ROOT-SERVERS.NET
37	"192.58.128.30",  // J.ROOT-SERVERS.NET
38	"193.0.14.129",   // K.ROOT-SERVERS.NET
39	"199.7.83.42",    // L.ROOT-SERVERS.NET
40	"202.12.27.33",   // M.ROOT-SERVERS.NET
41];
42
43/// Parsed root server IPs, computed once at first use. The previous
44/// implementation re-parsed `ROOT_SERVERS` on every recursive entry into
45/// `find_authoritative_ns_depth`; with out-of-bailiwick walks each NS name
46/// added another set of parses.
47static 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};
54/// Time-to-live for cached NS resolutions. Conservative: we don't read DNS
55/// TTLs from hickory's `Lookup`, so 5 minutes covers typical re-validation
56/// bursts without holding stale entries through routine NS rotations.
57const NS_CACHE_TTL_SECS: i64 = 300;
58
59#[derive(Clone)]
60struct CachedNs {
61	ips: Vec<IpAddr>,
62	valid_until: Timestamp,
63}
64
65/// Outcome of a single DNS record lookup, distinguishing legitimate "no record"
66/// from actual lookup failure so callers can log the difference.
67#[derive(Debug)]
68enum LookupOutcome {
69	/// Query succeeded and a matching record was found.
70	Found(String),
71	/// Query succeeded but no matching records of the requested type.
72	NoRecord,
73	/// All retry attempts failed. The underlying error is already logged in
74	/// detail by `lookup_with_retry`; this variant only signals "transport
75	/// failure" vs `NoRecord` to the operator-facing summary warn.
76	LookupError,
77}
78
79/// DNS Resolver wrapper that performs recursive resolution from root servers
80pub struct DnsResolver {
81	ns_cache: Arc<parking_lot::Mutex<LruCache<Box<str>, CachedNs>>>,
82}
83
84impl DnsResolver {
85	/// Create a new DNS resolver configured with root servers
86	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	/// Create a resolver configured to query specific nameservers
92	#[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	/// Retry a DNS lookup a few times with short backoff. Transient UDP loss
111	/// against root / TLD / authoritative NS is the main reason we get spurious
112	/// `nodns` results; one or two retries usually fixes it.
113	async fn lookup_with_retry(
114		resolver: &TokioResolver,
115		name: &str,
116		rtype: RecordType,
117	) -> Result<Lookup, NetError> {
118		const ATTEMPTS: u32 = 3;
119		// invariant: BACKOFF_MS.len() == ATTEMPTS - 1
120		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					// Authoritative negative answer (NXDomain or NoError with no
127					// records). Don't retry, don't WARN — it's a legitimate result.
128					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			// Unreachable: ATTEMPTS >= 1 guarantees the loop ran and set last_err on any error.
169			None => Err(NetError::Message("dns lookup retry loop produced no error")),
170		}
171	}
172
173	/// Collect A/AAAA answer records for `name` into `ips`, swallowing transport
174	/// errors. `lookup_with_retry` already handles its own backoff and logging.
175	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	/// Resolve NS record hostnames to IP addresses by recursively walking from
194	/// root for each NS name.
195	///
196	/// We cannot reuse the parent zone's authoritative servers to resolve the
197	/// NS hostnames, because those NS may live out-of-bailiwick (under a
198	/// different TLD than the zone being delegated). In that case the parent's
199	/// NS have no authority over the NS hostname and answer REFUSED/empty.
200	/// Walking from root for each NS hostname correctly re-roots the lookup
201	/// under the NS's own TLD.
202	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			// Recursively find the authoritative NS for this NS hostname.
206			// Handles out-of-bailiwick delegations by walking from root
207			// again under the NS's own TLD. Box::pin breaks the recursive
208			// async fn into a heap-allocated future (required by rustc).
209			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	/// Find authoritative nameservers for a domain by walking down from root.
232	///
233	/// Public wrapper around `find_authoritative_ns_depth` that starts at depth 0.
234	async fn find_authoritative_ns(&self, domain: &str) -> ClResult<Vec<IpAddr>> {
235		self.find_authoritative_ns_depth(domain, 0).await
236	}
237
238	/// Find authoritative nameservers for a domain by walking down from root,
239	/// with a recursion depth bound.
240	///
241	/// `depth` tracks indirect recursion through `resolve_ns_to_ips` (which
242	/// re-roots out-of-bailiwick NS hostname resolution). Real-world delegation
243	/// chains are very shallow (usually 1–2 hops); we cap at 4 to defend
244	/// against pathological loops without spinning forever.
245	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		// Safety bound: cap NS-resolution recursion to defend against
250		// pathological delegation loops. Return empty so the caller
251		// (resolve_ns_to_ips) cleanly skips this name via its
252		// `if auth_ns.is_empty() { continue; }` short-circuit, rather than
253		// asking root for an A record it cannot answer.
254		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		// Start with root servers
275		let mut current_ns_ips: Vec<IpAddr> = ROOT_SERVER_IPS.clone();
276
277		let mut current_resolver = self.create_resolver_for_ns(&current_ns_ips)?;
278
279		// Walk down the domain tree
280		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			// Query NS records for this level
286			match Self::lookup_with_retry(&current_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					// Collect NS names — referrals put NS records in the AUTHORITY section
294					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					// Glue records — typically ADDITIONAL, but some servers also place
303					// A/AAAA in authorities; accept both, exactly as record_iter() used to.
304					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						// Resolve NS names to IPs if no glue records
314						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(&current_ns_ips)?;
334						}
335					}
336				}
337				Err(e) => {
338					if e.is_no_records_found() {
339						// Authoritative "no NS at this level" — every deeper label
340						// under the same parent will get the same answer from the
341						// same nameserver. Stop walking; keep the parent's NS.
342						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					// True transport failure after exhausting retries. A single
350					// flaky TLD server shouldn't abort the walk — keep the
351					// previous level's NS and try the next label.
352					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			// Only cache top-level calls — inner recursive calls still benefit
370			// when they target a name already cached by a previous top-level
371			// call, but we avoid caching the partial state of an in-flight walk.
372			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	/// Resolve a domain to A record (legacy `Option` interface preserved for callers).
381	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	/// Resolve a domain to A record, distinguishing legitimate `NoRecord` from lookup failure.
389	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	/// Resolve a domain to CNAME record (legacy `Option` interface preserved for callers).
429	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	/// Resolve a domain to CNAME record, distinguishing legitimate `NoRecord` from lookup failure.
437	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
477/// Identifiers a name maps to, used for equivalence comparison: every CNAME
478/// name in its chain (incl. the starting name, normalized) plus the first
479/// terminal A-record IP. Two names refer to the same server iff their token sets
480/// intersect. `had_record` distinguishes "no DNS at all" (→ nodns) from
481/// "resolved, but elsewhere" (→ address). `transient` means a lookup failed
482/// transiently and the signature may be incomplete (→ never escalate).
483struct 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
493/// Find the first token present in both `a` and `b`. Pure helper factored out
494/// for unit testing the intersection logic without hitting the network.
495fn 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	/// Build the signature of `name` by following its CNAME chain, then reading
501	/// the terminal A record. IP literals short-circuit to a single-IP set.
502	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(&current).await? {
516				LookupOutcome::Found(target) => {
517					had_record = true;
518					tokens.push(normalize_name(&target));
519					current = target;
520				}
521				LookupOutcome::NoRecord => {
522					// Chain end — read the A record here.
523					match self.resolve_a_outcome(&current).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		// Pathological/looping chain — treat as transient (do not escalate).
541		warn!(name = %name, "CNAME chain exceeded max hops; treating as transient");
542		Ok(Signature { tokens, had_record, transient: true })
543	}
544}
545
546/// Create a recursive DNS resolver that starts from root nameservers
547pub fn create_recursive_resolver() -> ClResult<Arc<DnsResolver>> {
548	Ok(Arc::new(DnsResolver::new()?))
549}
550
551/// Resolve domain addresses from DNS (without validation)
552/// Uses CNAME lookup (returns hostname target)
553pub async fn resolve_domain_addresses(
554	domain: &str,
555	resolver: &DnsResolver,
556) -> ClResult<Option<String>> {
557	debug!(domain = %domain, "Resolving domain addresses");
558
559	// Try CNAME first, then A
560	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
571/// Validate that `domain` refers to the same server as one of `local_address`.
572///
573/// Each name is resolved to a *signature*: every CNAME name in its chain (incl.
574/// the name itself, normalized) plus its first terminal A-record IP. An IP literal
575/// has signature `{ip}`. The domain matches when its signature shares any token
576/// with a local address's signature. Names ∪ IPs is needed because name-overlap
577/// alone misses IP-literal / direct-A configs, and IP-overlap alone is fragile
578/// under round-robin A records (shared CNAME *name* is rotation-proof).
579///
580/// Cases (D = domain, L = local_address; ✓ = validates):
581///   1. D=A,      L=IP same           ✓ shared IP
582///   2. D=A,      L=IP different      → "address"
583///   3. D=CNAME H, L=H (literal)      ✓ shared name H
584///   4. D=CNAME H, L=CNAME H          ✓ shared name H   (the alias bug this fixes)
585///   5. D=A,      L=host→same IP      ✓ shared IP
586///   6. D=CNAME→A ip, L=IP ip         ✓ shared IP
587///   7. D lookup transient            → Transient (skip; never suspend)
588///   8. D has no record               → "nodns"
589///   9. L hostname lookup transient   → Transient
590///  10. D&L share >1 token            ✓ first shared token wins
591///  11. D&L CNAME→round-robin host    ✓ shared CNAME name
592///
593/// `"address"`/`"nodns"` are definitive (escalate toward suspension);
594/// `ServiceUnavailable` maps to a transient skip. Any transient lookup anywhere
595/// suppresses a definitive verdict.
596pub 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	// Domain produced no DNS record at all → it cannot point at this server.
609	// (A transient lookup is inconclusive; fall through so any_transient handles it.)
610	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		// First shared token wins. Names are normalized on both sides; IP tokens
619		// compare as plain strings.
620		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	// No intersection. A transient lookup anywhere means the signature may be
637	// incomplete — never escalate; skip the run instead.
638	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		// D direct-A, L IP-literal, same IP.
663		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		// D direct-A, L IP-literal, different IP.
671		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		// D CNAME->srv, L literally srv.
679		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		// The reported bug: D and L both CNAME to the same host H.
687		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		// D direct-A, L hostname direct-A, same IP, different names.
695		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		// D CNAME->host(A=1.2.3.4), L IP-literal 1.2.3.4.
703		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		// The first shared token in D's order is returned.
711		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		// D & L both CNAME to the same round-robin host but terminal A records
719		// rotated to different first-IPs; the shared CNAME name still matches.
720		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// vim: ts=4