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