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	net::runtime::TokioRuntimeProvider,
13	proto::rr::{RData, RecordType},
14};
15use std::{net::IpAddr, sync::Arc};
16
17use crate::prelude::*;
18use cloudillo_types::address::AddressType;
19
20/// Root nameserver IPs - the 13 official ICANN root servers
21const ROOT_SERVERS: [&str; 13] = [
22	"198.41.0.4",     // A.ROOT-SERVERS.NET
23	"199.9.14.201",   // B.ROOT-SERVERS.NET
24	"192.33.4.12",    // C.ROOT-SERVERS.NET
25	"199.7.91.13",    // D.ROOT-SERVERS.NET
26	"192.203.230.10", // E.ROOT-SERVERS.NET
27	"192.5.5.241",    // F.ROOT-SERVERS.NET
28	"192.112.36.4",   // G.ROOT-SERVERS.NET
29	"198.97.190.53",  // H.ROOT-SERVERS.NET
30	"192.36.148.17",  // I.ROOT-SERVERS.NET
31	"192.58.128.30",  // J.ROOT-SERVERS.NET
32	"193.0.14.129",   // K.ROOT-SERVERS.NET
33	"199.7.83.42",    // L.ROOT-SERVERS.NET
34	"202.12.27.33",   // M.ROOT-SERVERS.NET
35];
36
37/// DNS Resolver wrapper that performs recursive resolution from root servers
38pub struct DnsResolver {}
39
40impl DnsResolver {
41	/// Create a new DNS resolver configured with root servers
42	pub fn new() -> ClResult<Self> {
43		debug!("Created DNS resolver with {} root servers", ROOT_SERVERS.len());
44		Ok(Self {})
45	}
46
47	/// Create a resolver configured to query specific nameservers
48	#[expect(clippy::unused_self, reason = "method for consistency")]
49	fn create_resolver_for_ns(&self, ns_ips: &[IpAddr]) -> ClResult<TokioResolver> {
50		let name_servers = ns_ips
51			.iter()
52			.map(|ip| NameServerConfig::new(*ip, true, vec![ConnectionConfig::udp()]))
53			.collect();
54		let config = ResolverConfig::from_parts(None, vec![], name_servers);
55		TokioResolver::builder_with_config(config, TokioRuntimeProvider::default())
56			.build()
57			.map_err(|e| Error::ValidationError(format!("dns resolver build: {e}")))
58	}
59
60	/// Resolve NS record hostnames to IP addresses using the given resolver
61	async fn resolve_ns_to_ips(
62		&self,
63		ns_names: &[String],
64		resolver: &TokioResolver,
65	) -> Vec<IpAddr> {
66		let mut ips = Vec::new();
67		for ns_name in ns_names {
68			if let Ok(lookup) = resolver.lookup_ip(ns_name.as_str()).await {
69				for ip in lookup.iter() {
70					ips.push(ip);
71				}
72			}
73		}
74		ips
75	}
76
77	/// Find authoritative nameservers for a domain by walking down from root
78	async fn find_authoritative_ns(&self, domain: &str) -> ClResult<Vec<IpAddr>> {
79		let labels: Vec<&str> = domain.trim_end_matches('.').split('.').collect();
80
81		// Start with root servers
82		let mut current_ns_ips: Vec<IpAddr> =
83			ROOT_SERVERS.iter().filter_map(|ip| ip.parse().ok()).collect();
84
85		let mut current_resolver = self.create_resolver_for_ns(&current_ns_ips)?;
86
87		// Walk down the domain tree
88		for i in (0..labels.len()).rev() {
89			let subdomain = labels[i..].join(".") + ".";
90
91			debug!(subdomain = %subdomain, "Looking up NS for zone");
92
93			// Query NS records for this level
94			match current_resolver.lookup(subdomain.as_str(), RecordType::NS).await {
95				Ok(ns_lookup) => {
96					let mut ns_names: Vec<String> = Vec::new();
97					let mut glue_ips: Vec<IpAddr> = Vec::new();
98
99					// Collect NS names — referrals put NS records in the AUTHORITY section
100					for record in ns_lookup.answers().iter().chain(ns_lookup.authorities()) {
101						if let RData::NS(ns) = &record.data {
102							let ns_name = ns.0.to_string();
103							debug!(subdomain = %subdomain, ns = %ns_name, "Found NS record");
104							ns_names.push(ns_name);
105						}
106					}
107
108					// Glue records — typically ADDITIONAL, but some servers also place
109					// A/AAAA in authorities; accept both, exactly as record_iter() used to.
110					for record in ns_lookup.additionals().iter().chain(ns_lookup.authorities()) {
111						match &record.data {
112							RData::A(a) => glue_ips.push(IpAddr::V4(a.0)),
113							RData::AAAA(aaaa) => glue_ips.push(IpAddr::V6(aaaa.0)),
114							_ => {}
115						}
116					}
117
118					if !ns_names.is_empty() {
119						// Resolve NS names to IPs if no glue records
120						let ns_ips = if glue_ips.is_empty() {
121							self.resolve_ns_to_ips(&ns_names, &current_resolver).await
122						} else {
123							glue_ips
124						};
125
126						if !ns_ips.is_empty() {
127							debug!(
128								subdomain = %subdomain,
129								ns_count = ns_ips.len(),
130								"Updated authoritative NS"
131							);
132							current_ns_ips = ns_ips;
133							current_resolver = self.create_resolver_for_ns(&current_ns_ips)?;
134						}
135					}
136				}
137				Err(e) => {
138					// NS lookup failed - this is normal for non-delegated subdomains
139					debug!(
140						subdomain = %subdomain,
141						error = %e,
142						"No NS delegation at this level"
143					);
144				}
145			}
146		}
147
148		debug!(
149			domain = %domain,
150			ns_count = current_ns_ips.len(),
151			"Found authoritative nameservers"
152		);
153
154		Ok(current_ns_ips)
155	}
156
157	/// Resolve a domain to A record
158	pub async fn resolve_a(&self, domain: &str) -> ClResult<Option<String>> {
159		debug!(domain = %domain, "Starting A record resolution from root");
160
161		let auth_ns = self.find_authoritative_ns(domain).await?;
162		if auth_ns.is_empty() {
163			warn!(domain = %domain, "Could not find authoritative nameservers");
164			return Ok(None);
165		}
166
167		let auth_resolver = self.create_resolver_for_ns(&auth_ns)?;
168
169		debug!(domain = %domain, "Querying A records from authoritative NS");
170		match auth_resolver.lookup(domain, RecordType::A).await {
171			Ok(lookup) => {
172				for record in lookup.answers() {
173					if let RData::A(a) = &record.data {
174						let ip = a.0.to_string();
175						debug!(domain = %domain, ip = %ip, "Found A record");
176						return Ok(Some(ip));
177					}
178				}
179			}
180			Err(e) => {
181				debug!(domain = %domain, error = %e, "A lookup failed");
182			}
183		}
184
185		Ok(None)
186	}
187
188	/// Resolve a domain to CNAME record
189	pub async fn resolve_cname(&self, domain: &str) -> ClResult<Option<String>> {
190		debug!(domain = %domain, "Starting CNAME record resolution from root");
191
192		let auth_ns = self.find_authoritative_ns(domain).await?;
193		if auth_ns.is_empty() {
194			warn!(domain = %domain, "Could not find authoritative nameservers");
195			return Ok(None);
196		}
197
198		let auth_resolver = self.create_resolver_for_ns(&auth_ns)?;
199
200		debug!(domain = %domain, "Querying CNAME records from authoritative NS");
201		match auth_resolver.lookup(domain, RecordType::CNAME).await {
202			Ok(lookup) => {
203				for record in lookup.answers() {
204					if let RData::CNAME(cname) = &record.data {
205						let target = cname.0.to_string().trim_end_matches('.').to_string();
206						debug!(domain = %domain, cname = %target, "Found CNAME record");
207						return Ok(Some(target));
208					}
209				}
210			}
211			Err(e) => {
212				debug!(domain = %domain, error = %e, "CNAME lookup failed");
213			}
214		}
215
216		Ok(None)
217	}
218}
219
220/// Create a recursive DNS resolver that starts from root nameservers
221pub fn create_recursive_resolver() -> ClResult<Arc<DnsResolver>> {
222	Ok(Arc::new(DnsResolver::new()?))
223}
224
225/// Resolve domain addresses from DNS (without validation)
226/// Uses CNAME lookup (returns hostname target)
227pub async fn resolve_domain_addresses(
228	domain: &str,
229	resolver: &DnsResolver,
230) -> ClResult<Option<String>> {
231	debug!(domain = %domain, "Resolving domain addresses");
232
233	// Try CNAME first, then A
234	if let Some(cname) = resolver.resolve_cname(domain).await? {
235		return Ok(Some(cname));
236	}
237	if let Some(ip) = resolver.resolve_a(domain).await? {
238		return Ok(Some(ip));
239	}
240
241	debug!(domain = %domain, "No DNS records found");
242	Ok(None)
243}
244
245/// Validate a domain against local address using DNS
246/// Checks both CNAME and A records regardless of local address type
247pub async fn validate_domain_address(
248	domain: &str,
249	local_address: &[Box<str>],
250	resolver: &DnsResolver,
251) -> ClResult<(String, AddressType)> {
252	if local_address.is_empty() {
253		return Err(Error::ValidationError("no local address configured".to_string()));
254	}
255
256	debug!(
257		domain = %domain,
258		local_addresses = ?local_address,
259		"Starting DNS validation with recursive resolver"
260	);
261
262	// Try CNAME first
263	if let Some(resolved_cname) = resolver.resolve_cname(domain).await? {
264		for local_addr in local_address {
265			if resolved_cname.eq_ignore_ascii_case(local_addr.as_ref()) {
266				info!(
267					domain = %domain,
268					resolved_cname = %resolved_cname,
269					matched_local_address = %local_addr,
270					"Domain validated via CNAME record"
271				);
272				return Ok((resolved_cname, AddressType::Hostname));
273			}
274		}
275		warn!(
276			domain = %domain,
277			resolved_cname = %resolved_cname,
278			local_addresses = ?local_address,
279			"DNS CNAME record doesn't match local address"
280		);
281		return Err(Error::ValidationError("address".to_string()));
282	}
283
284	// Try A record
285	if let Some(resolved_ip) = resolver.resolve_a(domain).await? {
286		for local_addr in local_address {
287			if resolved_ip == local_addr.as_ref() {
288				info!(
289					domain = %domain,
290					resolved_ip = %resolved_ip,
291					matched_local_address = %local_addr,
292					"Domain validated via A record"
293				);
294				return Ok((resolved_ip, AddressType::Ipv4));
295			}
296		}
297		warn!(
298			domain = %domain,
299			resolved_ip = %resolved_ip,
300			local_addresses = ?local_address,
301			"DNS A record doesn't match local address"
302		);
303		return Err(Error::ValidationError("address".to_string()));
304	}
305
306	// Neither CNAME nor A record found
307	warn!(domain = %domain, "DNS validation failed: no CNAME or A record found");
308	Err(Error::ValidationError("nodns".to_string()))
309}
310
311// vim: ts=4