Skip to main content

seer_core/dns/
resolver.rs

1//! DNS resolution over hickory-resolver.
2//!
3//! Retry boundary (deliberate): unlike the WHOIS/RDAP clients, this module
4//! does NOT wrap queries in [`crate::retry::RetryPolicy`]. hickory-resolver
5//! already performs its own retransmission (`opts.attempts` below) against
6//! the configured nameserver within the per-query timeout; stacking an outer
7//! retry loop on top would multiply worst-case latency without improving
8//! resolution odds. If a retry knob is ever needed here, tune
9//! `ResolverOpts::attempts` rather than adding a wrapper.
10
11use std::net::IpAddr;
12use std::str::FromStr;
13use std::time::Duration;
14
15use hickory_resolver::config::{NameServerConfig, ResolveHosts, ResolverConfig, GOOGLE};
16use hickory_resolver::net::runtime::TokioRuntimeProvider;
17use hickory_resolver::net::NetError;
18use hickory_resolver::proto::dnssec::PublicKey;
19use hickory_resolver::proto::rr::rdata::CAA;
20use hickory_resolver::proto::rr::{RData as HickoryRData, RecordType as HickoryRecordType};
21use hickory_resolver::TokioResolver;
22use tracing::{debug, instrument};
23
24use super::records::{DnsRecord, RecordData, RecordType};
25use crate::error::{Result, SeerError};
26use crate::validation::normalize_domain;
27
28/// Convert a DNS lookup result, treating "no records found" as an empty vec
29/// rather than an error. This is correct DNS behavior — the absence of a
30/// record type for a domain is a valid response (NODATA), not a failure.
31fn dns_lookup_or_empty<T>(
32    result: std::result::Result<T, NetError>,
33    record_type: &str,
34) -> Result<Option<T>> {
35    match result {
36        Ok(response) => Ok(Some(response)),
37        Err(e) if e.is_no_records_found() => Ok(None),
38        Err(e) => Err(SeerError::DnsError(format!(
39            "{} lookup failed: {}",
40            record_type, e
41        ))),
42    }
43}
44
45/// Default timeout for DNS queries (5 seconds).
46/// DNS is typically fast; longer timeouts indicate network issues or unreachable servers.
47const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
48
49/// Build a TokioResolver pre-configured with the given upstream config and
50/// our standard options (timeout, retries, no hosts-file consultation).
51///
52/// Build only fails when TLS configuration construction fails; we don't
53/// enable TLS features in seer-core so `expect` is safe here and is the
54/// cleanest expression of that invariant.
55fn build_resolver(config: ResolverConfig, timeout: Duration) -> TokioResolver {
56    let mut builder = TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
57    {
58        let opts = builder.options_mut();
59        opts.timeout = timeout;
60        opts.attempts = 2;
61        opts.use_hosts_file = ResolveHosts::Never;
62    }
63    builder
64        .build()
65        .expect("hickory resolver build is infallible without TLS features")
66}
67
68/// DNS resolver for querying various record types.
69///
70/// Uses Google DNS (8.8.8.8) by default, but supports custom nameservers.
71/// The default resolver is cached and reused across queries to avoid
72/// repeated initialization overhead.
73#[derive(Clone)]
74pub struct DnsResolver {
75    timeout: Duration,
76    /// Cached default resolver (Google DNS). Reused across all queries
77    /// that don't specify a custom nameserver.
78    default_resolver: TokioResolver,
79}
80
81impl std::fmt::Debug for DnsResolver {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("DnsResolver")
84            .field("timeout", &self.timeout)
85            .finish()
86    }
87}
88
89impl Default for DnsResolver {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95impl DnsResolver {
96    /// Creates a new DNS resolver with default settings.
97    pub fn new() -> Self {
98        Self {
99            timeout: DEFAULT_TIMEOUT,
100            default_resolver: build_resolver(ResolverConfig::udp_and_tcp(&GOOGLE), DEFAULT_TIMEOUT),
101        }
102    }
103
104    /// Sets the timeout for DNS queries.
105    ///
106    /// The default is 5 seconds, which is sufficient for most DNS queries.
107    pub fn with_timeout(mut self, timeout: Duration) -> Self {
108        self.timeout = timeout;
109        self.default_resolver = build_resolver(ResolverConfig::udp_and_tcp(&GOOGLE), timeout);
110        self
111    }
112
113    async fn create_custom_resolver(&self, nameserver: &str) -> Result<TokioResolver> {
114        // Accept either a literal IP or a hostname. For hostnames, resolve
115        // via the default (Google DNS) hickory resolver so we do not depend
116        // on the OS resolver — that is the same fallback principle as the
117        // SSL probe fix: when the local system resolver is broken (split
118        // DNS, broken router, container netns), hickory still reaches the
119        // public name servers and the user-supplied authoritative server
120        // is still usable.
121        let ips: Vec<IpAddr> = if let Ok(ip) = nameserver.parse::<IpAddr>() {
122            vec![ip]
123        } else {
124            let response = self
125                .default_resolver
126                .lookup_ip(nameserver)
127                .await
128                .map_err(|e| {
129                    SeerError::DnsError(format!(
130                        "failed to resolve nameserver hostname {}: {}",
131                        nameserver, e
132                    ))
133                })?;
134            let resolved: Vec<IpAddr> = response.iter().collect();
135            if resolved.is_empty() {
136                return Err(SeerError::DnsError(format!(
137                    "nameserver {} did not resolve to any addresses",
138                    nameserver
139                )));
140            }
141            resolved
142        };
143
144        // SSRF protection: reject private/reserved IPs — whether supplied
145        // literally or returned by name resolution. Without this, a
146        // hostname under attacker control could point at internal infra.
147        for ip in &ips {
148            if let Some(reason) = crate::validation::describe_reserved_ip(ip) {
149                return Err(SeerError::DnsError(format!(
150                    "nameserver {} blocked: {}",
151                    nameserver, reason
152                )));
153            }
154        }
155
156        // Build a config with all resolved IPs as upstream nameservers.
157        // In hickory 0.26, NameServerConfig::udp(IpAddr) builds a
158        // ConnectionConfig with the default DNS port (53) for us, so we
159        // no longer need to construct a SocketAddr explicitly.
160        let mut config = ResolverConfig::from_parts(None, vec![], vec![]);
161        for ip in ips {
162            config.add_name_server(NameServerConfig::udp(ip));
163        }
164
165        Ok(build_resolver(config, self.timeout))
166    }
167
168    /// Resolves DNS records for a domain.
169    ///
170    /// # Arguments
171    /// * `domain` - The domain name to query
172    /// * `record_type` - The type of DNS record to look up (A, AAAA, MX, etc.)
173    /// * `nameserver` - Optional custom nameserver IP; uses Google DNS if None
174    #[instrument(skip(self), fields(domain = %domain, record_type = %record_type))]
175    pub async fn resolve(
176        &self,
177        domain: &str,
178        record_type: RecordType,
179        nameserver: Option<&str>,
180    ) -> Result<Vec<DnsRecord>> {
181        // Reuse the cached default resolver when no custom nameserver is specified
182        let custom_resolver;
183        let resolver = if let Some(ns) = nameserver {
184            custom_resolver = self.create_custom_resolver(ns).await?;
185            &custom_resolver
186        } else {
187            &self.default_resolver
188        };
189        let domain = normalize_domain(domain)?;
190
191        debug!(nameserver = nameserver.unwrap_or("system"), "Resolving DNS");
192
193        match record_type {
194            RecordType::A => self.resolve_a(resolver, &domain).await,
195            RecordType::AAAA => self.resolve_aaaa(resolver, &domain).await,
196            RecordType::CNAME => self.resolve_cname(resolver, &domain).await,
197            RecordType::MX => self.resolve_mx(resolver, &domain).await,
198            RecordType::NS => self.resolve_ns(resolver, &domain).await,
199            RecordType::TXT => self.resolve_txt(resolver, &domain).await,
200            RecordType::SOA => self.resolve_soa(resolver, &domain).await,
201            RecordType::PTR => self.resolve_ptr(resolver, &domain).await,
202            RecordType::SRV => Err(SeerError::DnsError(
203                "SRV records require service name format: _service._proto.name".to_string(),
204            )),
205            RecordType::CAA => self.resolve_caa(resolver, &domain).await,
206            RecordType::DNSKEY => self.resolve_dnskey(resolver, &domain).await,
207            RecordType::DS => self.resolve_ds(resolver, &domain).await,
208            RecordType::TLSA => self.resolve_tlsa(resolver, &domain).await,
209            RecordType::SSHFP => self.resolve_sshfp(resolver, &domain).await,
210            RecordType::ANY => self.resolve_any(resolver, &domain).await,
211            _ => Err(SeerError::DnsError(format!(
212                "Record type {} not implemented",
213                record_type
214            ))),
215        }
216    }
217
218    /// Resolves SRV records for a service.
219    ///
220    /// # Arguments
221    /// * `service` - The service name (e.g., "http", "ldap")
222    /// * `protocol` - The protocol (e.g., "tcp", "udp")
223    /// * `domain` - The domain name
224    /// * `nameserver` - Optional custom nameserver IP
225    #[instrument(skip(self), fields(domain = %domain, service = %service, protocol = %protocol))]
226    pub async fn resolve_srv(
227        &self,
228        service: &str,
229        protocol: &str,
230        domain: &str,
231        nameserver: Option<&str>,
232    ) -> Result<Vec<DnsRecord>> {
233        // Validate service and protocol to prevent DNS query injection
234        if !is_valid_srv_label(service) {
235            return Err(SeerError::DnsError(format!(
236                "invalid SRV service name: {}",
237                service
238            )));
239        }
240        if !is_valid_srv_label(protocol) {
241            return Err(SeerError::DnsError(format!(
242                "invalid SRV protocol name: {}",
243                protocol
244            )));
245        }
246
247        let custom_resolver;
248        let resolver = if let Some(ns) = nameserver {
249            custom_resolver = self.create_custom_resolver(ns).await?;
250            &custom_resolver
251        } else {
252            &self.default_resolver
253        };
254        let query_name = format!("_{}._{}.{}", service, protocol, domain);
255
256        let Some(response) = dns_lookup_or_empty(
257            resolver.lookup(&query_name, HickoryRecordType::SRV).await,
258            "SRV",
259        )?
260        else {
261            return Ok(vec![]);
262        };
263
264        let records = response
265            .answers()
266            .iter()
267            .filter_map(|record| {
268                if let HickoryRData::SRV(srv) = &record.data {
269                    Some(DnsRecord {
270                        name: query_name.clone(),
271                        record_type: RecordType::SRV,
272                        ttl: record.ttl,
273                        data: RecordData::SRV {
274                            priority: srv.priority,
275                            weight: srv.weight,
276                            port: srv.port,
277                            target: srv.target.to_string(),
278                        },
279                    })
280                } else {
281                    None
282                }
283            })
284            .collect();
285
286        Ok(records)
287    }
288
289    async fn resolve_a(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
290        let Some(response) =
291            dns_lookup_or_empty(resolver.lookup(domain, HickoryRecordType::A).await, "A")?
292        else {
293            return Ok(vec![]);
294        };
295
296        let records = response
297            .answers()
298            .iter()
299            .filter_map(|record| {
300                if let HickoryRData::A(addr) = &record.data {
301                    Some(DnsRecord {
302                        name: domain.to_string(),
303                        record_type: RecordType::A,
304                        ttl: record.ttl,
305                        data: RecordData::A {
306                            address: addr.0.to_string(),
307                        },
308                    })
309                } else {
310                    None
311                }
312            })
313            .collect();
314
315        Ok(records)
316    }
317
318    async fn resolve_aaaa(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
319        let Some(response) = dns_lookup_or_empty(
320            resolver.lookup(domain, HickoryRecordType::AAAA).await,
321            "AAAA",
322        )?
323        else {
324            return Ok(vec![]);
325        };
326
327        let records = response
328            .answers()
329            .iter()
330            .filter_map(|record| {
331                if let HickoryRData::AAAA(addr) = &record.data {
332                    Some(DnsRecord {
333                        name: domain.to_string(),
334                        record_type: RecordType::AAAA,
335                        ttl: record.ttl,
336                        data: RecordData::AAAA {
337                            address: addr.0.to_string(),
338                        },
339                    })
340                } else {
341                    None
342                }
343            })
344            .collect();
345
346        Ok(records)
347    }
348
349    async fn resolve_cname(
350        &self,
351        resolver: &TokioResolver,
352        domain: &str,
353    ) -> Result<Vec<DnsRecord>> {
354        let Some(response) = dns_lookup_or_empty(
355            resolver.lookup(domain, HickoryRecordType::CNAME).await,
356            "CNAME",
357        )?
358        else {
359            return Ok(vec![]);
360        };
361
362        let records = response
363            .answers()
364            .iter()
365            .filter_map(|record| {
366                if let HickoryRData::CNAME(cname) = &record.data {
367                    Some(DnsRecord {
368                        name: domain.to_string(),
369                        record_type: RecordType::CNAME,
370                        ttl: record.ttl,
371                        data: RecordData::CNAME {
372                            target: cname.0.to_string(),
373                        },
374                    })
375                } else {
376                    None
377                }
378            })
379            .collect();
380
381        Ok(records)
382    }
383
384    async fn resolve_mx(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
385        let Some(response) =
386            dns_lookup_or_empty(resolver.lookup(domain, HickoryRecordType::MX).await, "MX")?
387        else {
388            return Ok(vec![]);
389        };
390
391        let mut records: Vec<DnsRecord> = response
392            .answers()
393            .iter()
394            .filter_map(|record| {
395                if let HickoryRData::MX(mx) = &record.data {
396                    Some(DnsRecord {
397                        name: domain.to_string(),
398                        record_type: RecordType::MX,
399                        ttl: record.ttl,
400                        data: RecordData::MX {
401                            preference: mx.preference,
402                            exchange: mx.exchange.to_string(),
403                        },
404                    })
405                } else {
406                    None
407                }
408            })
409            .collect();
410
411        records.sort_by_key(|r| {
412            if let RecordData::MX { preference, .. } = &r.data {
413                *preference
414            } else {
415                0
416            }
417        });
418
419        Ok(records)
420    }
421
422    async fn resolve_ns(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
423        let Some(response) =
424            dns_lookup_or_empty(resolver.lookup(domain, HickoryRecordType::NS).await, "NS")?
425        else {
426            return Ok(vec![]);
427        };
428
429        let records = response
430            .answers()
431            .iter()
432            .filter_map(|record| {
433                if let HickoryRData::NS(ns) = &record.data {
434                    Some(DnsRecord {
435                        name: domain.to_string(),
436                        record_type: RecordType::NS,
437                        ttl: record.ttl,
438                        data: RecordData::NS {
439                            nameserver: ns.0.to_string(),
440                        },
441                    })
442                } else {
443                    None
444                }
445            })
446            .collect();
447
448        Ok(records)
449    }
450
451    async fn resolve_txt(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
452        let Some(response) =
453            dns_lookup_or_empty(resolver.lookup(domain, HickoryRecordType::TXT).await, "TXT")?
454        else {
455            return Ok(vec![]);
456        };
457
458        let records = response
459            .answers()
460            .iter()
461            .filter_map(|record| {
462                if let HickoryRData::TXT(txt) = &record.data {
463                    let text = txt
464                        .txt_data
465                        .iter()
466                        .map(|data| String::from_utf8_lossy(data).to_string())
467                        .collect::<Vec<_>>()
468                        .join("");
469
470                    Some(DnsRecord {
471                        name: domain.to_string(),
472                        record_type: RecordType::TXT,
473                        ttl: record.ttl,
474                        data: RecordData::TXT { text },
475                    })
476                } else {
477                    None
478                }
479            })
480            .collect();
481
482        Ok(records)
483    }
484
485    async fn resolve_soa(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
486        let Some(response) =
487            dns_lookup_or_empty(resolver.lookup(domain, HickoryRecordType::SOA).await, "SOA")?
488        else {
489            return Ok(vec![]);
490        };
491
492        let records = response
493            .answers()
494            .iter()
495            .filter_map(|record| {
496                if let HickoryRData::SOA(soa) = &record.data {
497                    Some(DnsRecord {
498                        name: domain.to_string(),
499                        record_type: RecordType::SOA,
500                        ttl: record.ttl,
501                        data: RecordData::SOA {
502                            mname: soa.mname.to_string(),
503                            rname: soa.rname.to_string(),
504                            serial: soa.serial,
505                            refresh: soa.refresh.try_into().unwrap_or(0),
506                            retry: soa.retry.try_into().unwrap_or(0),
507                            expire: soa.expire.try_into().unwrap_or(0),
508                            minimum: soa.minimum,
509                        },
510                    })
511                } else {
512                    None
513                }
514            })
515            .collect();
516
517        Ok(records)
518    }
519
520    async fn resolve_ptr(&self, resolver: &TokioResolver, query: &str) -> Result<Vec<DnsRecord>> {
521        // If it's an IP address, convert to reverse DNS format
522        let query = if let Ok(ip) = IpAddr::from_str(query) {
523            reverse_dns_name(&ip)
524        } else {
525            query.to_string()
526        };
527
528        let Some(response) =
529            dns_lookup_or_empty(resolver.lookup(&query, HickoryRecordType::PTR).await, "PTR")?
530        else {
531            return Ok(vec![]);
532        };
533
534        let records = response
535            .answers()
536            .iter()
537            .filter_map(|record| {
538                if let HickoryRData::PTR(ptr) = &record.data {
539                    Some(DnsRecord {
540                        name: query.clone(),
541                        record_type: RecordType::PTR,
542                        ttl: record.ttl,
543                        data: RecordData::PTR {
544                            target: ptr.0.to_string(),
545                        },
546                    })
547                } else {
548                    None
549                }
550            })
551            .collect();
552
553        Ok(records)
554    }
555
556    async fn resolve_caa(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
557        let Some(response) =
558            dns_lookup_or_empty(resolver.lookup(domain, HickoryRecordType::CAA).await, "CAA")?
559        else {
560            return Ok(vec![]);
561        };
562
563        let records = response
564            .answers()
565            .iter()
566            .filter_map(|record| {
567                if let HickoryRData::CAA(caa) = &record.data {
568                    let (flags, tag, value) = parse_caa(caa);
569                    Some(DnsRecord {
570                        name: domain.to_string(),
571                        record_type: RecordType::CAA,
572                        ttl: record.ttl,
573                        data: RecordData::CAA { flags, tag, value },
574                    })
575                } else {
576                    None
577                }
578            })
579            .collect();
580
581        Ok(records)
582    }
583
584    async fn resolve_dnskey(
585        &self,
586        resolver: &TokioResolver,
587        domain: &str,
588    ) -> Result<Vec<DnsRecord>> {
589        use hickory_resolver::proto::dnssec::rdata::DNSSECRData;
590
591        let Some(response) = dns_lookup_or_empty(
592            resolver.lookup(domain, HickoryRecordType::DNSKEY).await,
593            "DNSKEY",
594        )?
595        else {
596            return Ok(vec![]);
597        };
598
599        let records = response
600            .answers()
601            .iter()
602            .filter_map(|record| {
603                if let HickoryRData::DNSSEC(DNSSECRData::DNSKEY(dnskey)) = &record.data {
604                    use base64::{engine::general_purpose::STANDARD, Engine};
605                    let public_key_buf = dnskey.public_key();
606                    let public_key = STANDARD.encode(public_key_buf.public_bytes());
607                    Some(DnsRecord {
608                        name: domain.to_string(),
609                        record_type: RecordType::DNSKEY,
610                        ttl: record.ttl,
611                        data: RecordData::DNSKEY {
612                            flags: dnskey.flags(),
613                            // Protocol is always 3 for DNSSEC (RFC 4034)
614                            protocol: 3,
615                            algorithm: u8::from(public_key_buf.algorithm()),
616                            public_key,
617                        },
618                    })
619                } else {
620                    None
621                }
622            })
623            .collect();
624
625        Ok(records)
626    }
627
628    async fn resolve_ds(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
629        use hickory_resolver::proto::dnssec::rdata::DNSSECRData;
630
631        let Some(response) =
632            dns_lookup_or_empty(resolver.lookup(domain, HickoryRecordType::DS).await, "DS")?
633        else {
634            return Ok(vec![]);
635        };
636
637        let records = response
638            .answers()
639            .iter()
640            .filter_map(|record| {
641                if let HickoryRData::DNSSEC(DNSSECRData::DS(ds)) = &record.data {
642                    let digest = ds
643                        .digest()
644                        .iter()
645                        .map(|b| format!("{:02X}", b))
646                        .collect::<String>();
647                    Some(DnsRecord {
648                        name: domain.to_string(),
649                        record_type: RecordType::DS,
650                        ttl: record.ttl,
651                        data: RecordData::DS {
652                            key_tag: ds.key_tag(),
653                            algorithm: u8::from(ds.algorithm()),
654                            digest_type: u8::from(ds.digest_type()),
655                            digest,
656                        },
657                    })
658                } else {
659                    None
660                }
661            })
662            .collect();
663
664        Ok(records)
665    }
666
667    async fn resolve_tlsa(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
668        // TLSA queries are how DANE clients discover the certificate
669        // association data for a TLS endpoint. The convention is
670        // `_<port>._<proto>.<host>` (e.g. `_443._tcp.example.com`); seer
671        // does not enforce the label shape because TLSA is also used for
672        // other transports.
673        let Some(response) = dns_lookup_or_empty(
674            resolver.lookup(domain, HickoryRecordType::TLSA).await,
675            "TLSA",
676        )?
677        else {
678            return Ok(vec![]);
679        };
680
681        let records = response
682            .answers()
683            .iter()
684            .filter_map(|record| {
685                if let HickoryRData::TLSA(tlsa) = &record.data {
686                    let cert_data = tlsa
687                        .cert_data
688                        .iter()
689                        .map(|b| format!("{:02X}", b))
690                        .collect::<String>();
691                    Some(DnsRecord {
692                        name: domain.to_string(),
693                        record_type: RecordType::TLSA,
694                        ttl: record.ttl,
695                        data: RecordData::TLSA {
696                            cert_usage: u8::from(tlsa.cert_usage),
697                            selector: u8::from(tlsa.selector),
698                            matching: u8::from(tlsa.matching),
699                            cert_data,
700                        },
701                    })
702                } else {
703                    None
704                }
705            })
706            .collect();
707
708        Ok(records)
709    }
710
711    async fn resolve_sshfp(
712        &self,
713        resolver: &TokioResolver,
714        domain: &str,
715    ) -> Result<Vec<DnsRecord>> {
716        let Some(response) = dns_lookup_or_empty(
717            resolver.lookup(domain, HickoryRecordType::SSHFP).await,
718            "SSHFP",
719        )?
720        else {
721            return Ok(vec![]);
722        };
723
724        let records = response
725            .answers()
726            .iter()
727            .filter_map(|record| {
728                if let HickoryRData::SSHFP(sshfp) = &record.data {
729                    let fingerprint = sshfp
730                        .fingerprint
731                        .iter()
732                        .map(|b| format!("{:02X}", b))
733                        .collect::<String>();
734                    Some(DnsRecord {
735                        name: domain.to_string(),
736                        record_type: RecordType::SSHFP,
737                        ttl: record.ttl,
738                        data: RecordData::SSHFP {
739                            algorithm: u8::from(sshfp.algorithm),
740                            fingerprint_type: u8::from(sshfp.fingerprint_type),
741                            fingerprint,
742                        },
743                    })
744                } else {
745                    None
746                }
747            })
748            .collect();
749
750        Ok(records)
751    }
752
753    async fn resolve_any(&self, resolver: &TokioResolver, domain: &str) -> Result<Vec<DnsRecord>> {
754        let mut all_records = Vec::new();
755
756        // Query common record types
757        let record_types = [
758            RecordType::A,
759            RecordType::AAAA,
760            RecordType::MX,
761            RecordType::NS,
762            RecordType::TXT,
763            RecordType::SOA,
764            RecordType::CAA,
765        ];
766
767        for record_type in record_types {
768            match self.resolve_type(resolver, domain, record_type).await {
769                Ok(records) => all_records.extend(records),
770                Err(_) => continue, // Skip record types that don't exist
771            }
772        }
773
774        Ok(all_records)
775    }
776
777    async fn resolve_type(
778        &self,
779        resolver: &TokioResolver,
780        domain: &str,
781        record_type: RecordType,
782    ) -> Result<Vec<DnsRecord>> {
783        match record_type {
784            RecordType::A => self.resolve_a(resolver, domain).await,
785            RecordType::AAAA => self.resolve_aaaa(resolver, domain).await,
786            RecordType::CNAME => self.resolve_cname(resolver, domain).await,
787            RecordType::MX => self.resolve_mx(resolver, domain).await,
788            RecordType::NS => self.resolve_ns(resolver, domain).await,
789            RecordType::TXT => self.resolve_txt(resolver, domain).await,
790            RecordType::SOA => self.resolve_soa(resolver, domain).await,
791            RecordType::CAA => self.resolve_caa(resolver, domain).await,
792            RecordType::DNSKEY => self.resolve_dnskey(resolver, domain).await,
793            RecordType::DS => self.resolve_ds(resolver, domain).await,
794            _ => Err(SeerError::DnsError("unsupported record type".to_string())),
795        }
796    }
797}
798
799/// Whether a domain appears to exist in the public DNS. Used as a
800/// corroborating availability signal when registry data (RDAP/WHOIS) is
801/// inconclusive — e.g. a thin/blocked WHOIS body and an RDAP failure that is
802/// not an authoritative 404.
803#[derive(Debug, Clone, Copy, PartialEq, Eq)]
804pub enum DnsPresence {
805    /// The apex returned NS records — the domain is delegated and exists.
806    Present,
807    /// NXDOMAIN / empty answer — the domain has no DNS presence.
808    Absent,
809    /// The DNS query itself failed; presence is unknown.
810    Unknown,
811}
812
813/// Maps an apex NS lookup result to a [`DnsPresence`]. Pure so the mapping is
814/// unit-testable without a live resolver. `resolve(.., NS, ..)` already folds
815/// NXDOMAIN/NODATA into `Ok(vec![])` (see `dns_lookup_or_empty`), so an empty
816/// `Ok` is the "no presence" signal and an `Err` is a genuine query failure.
817fn classify_ns_presence(result: &Result<Vec<DnsRecord>>) -> DnsPresence {
818    match result {
819        Ok(records) if records.is_empty() => DnsPresence::Absent,
820        Ok(_) => DnsPresence::Present,
821        Err(_) => DnsPresence::Unknown,
822    }
823}
824
825impl DnsResolver {
826    /// Probes whether a domain has any DNS presence by querying its apex NS
827    /// records. A registered, delegated domain returns NS records; an
828    /// unregistered domain returns NXDOMAIN (an empty record set).
829    ///
830    /// This is a heuristic, not proof: a registered-but-undelegated domain
831    /// also has no NS records, so callers should treat
832    /// [`DnsPresence::Absent`] as "likely available" (medium confidence).
833    pub async fn presence(&self, domain: &str) -> DnsPresence {
834        classify_ns_presence(&self.resolve(domain, RecordType::NS, None).await)
835    }
836}
837
838// Domain normalization is now handled by the shared validation module
839
840fn reverse_dns_name(ip: &IpAddr) -> String {
841    match ip {
842        IpAddr::V4(addr) => {
843            let octets = addr.octets();
844            format!(
845                "{}.{}.{}.{}.in-addr.arpa",
846                octets[3], octets[2], octets[1], octets[0]
847            )
848        }
849        IpAddr::V6(addr) => {
850            let segments = addr.segments();
851            // 32 hex nibbles + 31 dots + ".ip6.arpa" (9) = 72 chars
852            let mut result = String::with_capacity(72);
853            let mut first = true;
854            for segment in segments.iter().rev() {
855                for shift in [0, 4, 8, 12] {
856                    if !first {
857                        result.push('.');
858                    }
859                    first = false;
860                    let nibble = (segment >> shift) & 0xF;
861                    result
862                        .push(char::from_digit(nibble as u32, 16).expect("nibble is always 0-15"));
863                }
864            }
865            result.push_str(".ip6.arpa");
866            result
867        }
868    }
869}
870
871fn parse_caa(caa: &CAA) -> (u8, String, String) {
872    // hickory 0.26: CAA fields are public. `issuer_critical` and `tag` are
873    // plain fields; `value` is a `Vec<u8>` because RFC 8659 permits binary
874    // values for unknown property types. For seer's reporting purposes the
875    // common tags (issue/issuewild/iodef) are always UTF-8, so a lossy
876    // conversion preserves prior behavior without panicking on the rare
877    // binary case.
878    let flags = if caa.issuer_critical { 128 } else { 0 };
879    let tag = caa.tag.clone();
880    let value = String::from_utf8_lossy(&caa.value).to_string();
881    (flags, tag, value)
882}
883
884/// Validates SRV service/protocol labels (alphanumeric and hyphens only, no dots)
885fn is_valid_srv_label(label: &str) -> bool {
886    !label.is_empty()
887        && label.len() <= 63
888        && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
889        && !label.starts_with('-')
890        && !label.ends_with('-')
891}
892
893#[cfg(test)]
894mod tests {
895    //! Unit tests for the pure helpers and public surface of the DNS
896    //! resolver. Tests that would exercise the hickory wire protocol
897    //! are covered by live-network tests marked `#[ignore]` in the
898    //! sibling modules (`dns/dnssec.rs`, `dns/follow.rs`). Deeper
899    //! coverage of `resolve_*` paths would require a hickory mock,
900    //! which is out of scope for this module.
901    //
902    // TODO: mock hickory resolver for full path coverage.
903
904    use super::*;
905    use std::net::{Ipv4Addr, Ipv6Addr};
906
907    // --- RecordType::from_str edge cases -----------------------------
908
909    #[test]
910    fn record_type_from_str_accepts_lowercase() {
911        assert_eq!(RecordType::from_str("a").unwrap(), RecordType::A);
912        assert_eq!(RecordType::from_str("mx").unwrap(), RecordType::MX);
913        assert_eq!(RecordType::from_str("cname").unwrap(), RecordType::CNAME);
914        assert_eq!(RecordType::from_str("dnskey").unwrap(), RecordType::DNSKEY);
915    }
916
917    #[test]
918    fn record_type_from_str_accepts_mixed_case() {
919        assert_eq!(RecordType::from_str("Mx").unwrap(), RecordType::MX);
920        assert_eq!(RecordType::from_str("cNaMe").unwrap(), RecordType::CNAME);
921    }
922
923    #[test]
924    fn record_type_from_str_rejects_whitespace_padded() {
925        // No trim is done inside from_str; leading/trailing whitespace
926        // must currently cause a parse error so callers don't pass
927        // malformed labels through.
928        assert!(RecordType::from_str(" A").is_err());
929        assert!(RecordType::from_str("A ").is_err());
930        assert!(RecordType::from_str("\tA\n").is_err());
931    }
932
933    #[test]
934    fn record_type_from_str_rejects_unknown() {
935        assert!(RecordType::from_str("NOTAREAL").is_err());
936        assert!(RecordType::from_str("A1").is_err());
937        assert!(RecordType::from_str("").is_err());
938    }
939
940    #[test]
941    fn record_type_from_str_accepts_star_as_any() {
942        assert_eq!(RecordType::from_str("*").unwrap(), RecordType::ANY);
943        assert_eq!(RecordType::from_str("ANY").unwrap(), RecordType::ANY);
944        assert_eq!(RecordType::from_str("any").unwrap(), RecordType::ANY);
945    }
946
947    // --- is_valid_srv_label ------------------------------------------
948
949    #[test]
950    fn srv_label_accepts_alphanumeric_and_hyphen() {
951        assert!(is_valid_srv_label("http"));
952        assert!(is_valid_srv_label("ldap-tls"));
953        assert!(is_valid_srv_label("a1"));
954        assert!(is_valid_srv_label("tcp"));
955    }
956
957    #[test]
958    fn srv_label_rejects_empty() {
959        assert!(!is_valid_srv_label(""));
960    }
961
962    #[test]
963    fn srv_label_rejects_leading_or_trailing_hyphen() {
964        assert!(!is_valid_srv_label("-http"));
965        assert!(!is_valid_srv_label("http-"));
966        assert!(!is_valid_srv_label("-"));
967    }
968
969    #[test]
970    fn srv_label_rejects_dots() {
971        // Dots would let an attacker construct `_service._tcp.evil.com.target`
972        // and pivot the query to a different domain.
973        assert!(!is_valid_srv_label("http.evil"));
974        assert!(!is_valid_srv_label("a.b"));
975    }
976
977    #[test]
978    fn srv_label_rejects_special_chars() {
979        assert!(!is_valid_srv_label("http evil"));
980        assert!(!is_valid_srv_label("http/evil"));
981        assert!(!is_valid_srv_label("http\0"));
982        assert!(!is_valid_srv_label("http\n"));
983    }
984
985    #[test]
986    fn srv_label_rejects_over_63_chars() {
987        let too_long = "a".repeat(64);
988        assert!(!is_valid_srv_label(&too_long));
989        let exactly_63 = "a".repeat(63);
990        assert!(is_valid_srv_label(&exactly_63));
991    }
992
993    // --- classify_ns_presence ----------------------------------------
994
995    #[test]
996    fn classify_ns_presence_absent_on_empty_ok() {
997        // resolve(.., NS) folds NXDOMAIN/NODATA into Ok(vec![]).
998        let r: Result<Vec<DnsRecord>> = Ok(vec![]);
999        assert_eq!(classify_ns_presence(&r), DnsPresence::Absent);
1000    }
1001
1002    #[test]
1003    fn classify_ns_presence_present_on_records() {
1004        let rec = DnsRecord {
1005            name: "example.test.".to_string(),
1006            record_type: RecordType::NS,
1007            ttl: 3600,
1008            data: RecordData::NS {
1009                nameserver: "ns1.example.net.".to_string(),
1010            },
1011        };
1012        let r: Result<Vec<DnsRecord>> = Ok(vec![rec]);
1013        assert_eq!(classify_ns_presence(&r), DnsPresence::Present);
1014    }
1015
1016    #[test]
1017    fn classify_ns_presence_unknown_on_error() {
1018        let r: Result<Vec<DnsRecord>> = Err(SeerError::DnsError("servfail".to_string()));
1019        assert_eq!(classify_ns_presence(&r), DnsPresence::Unknown);
1020    }
1021
1022    // --- reverse_dns_name --------------------------------------------
1023
1024    #[test]
1025    fn reverse_dns_name_formats_ipv4_correctly() {
1026        let ip: IpAddr = Ipv4Addr::new(192, 0, 2, 1).into();
1027        assert_eq!(reverse_dns_name(&ip), "1.2.0.192.in-addr.arpa");
1028    }
1029
1030    #[test]
1031    fn reverse_dns_name_formats_ipv6_correctly() {
1032        // ::1 (loopback) → 32 nibbles of 0 followed by ...0.0.0.1 reversed.
1033        let ip: IpAddr = Ipv6Addr::LOCALHOST.into();
1034        let name = reverse_dns_name(&ip);
1035        assert!(
1036            name.ends_with(".ip6.arpa"),
1037            "must end with .ip6.arpa; got: {}",
1038            name
1039        );
1040        // The first nibble (most-reversed position) must be 1 (from ::1 low bit).
1041        assert!(
1042            name.starts_with("1."),
1043            "expected '1.' prefix, got: {}",
1044            name
1045        );
1046        // 32 nibbles + 31 dots + ".ip6.arpa" (9 chars) = 72.
1047        assert_eq!(name.len(), 72);
1048    }
1049
1050    // --- DnsResolver construction ------------------------------------
1051
1052    #[test]
1053    fn resolver_new_has_default_timeout() {
1054        let r = DnsResolver::new();
1055        assert_eq!(r.timeout, DEFAULT_TIMEOUT);
1056    }
1057
1058    #[test]
1059    fn resolver_with_timeout_overrides_default() {
1060        let custom = Duration::from_secs(42);
1061        let r = DnsResolver::new().with_timeout(custom);
1062        assert_eq!(r.timeout, custom);
1063    }
1064
1065    #[test]
1066    fn resolver_default_matches_new() {
1067        let a = DnsResolver::default();
1068        let b = DnsResolver::new();
1069        assert_eq!(a.timeout, b.timeout);
1070    }
1071
1072    // --- create_custom_resolver validation ---------------------------
1073
1074    #[tokio::test]
1075    async fn custom_resolver_rejects_invalid_input() {
1076        // After hostname support was added, a string that is neither a
1077        // valid IP nor a resolvable hostname should fail with a clear
1078        // "failed to resolve" error rather than panicking or hanging.
1079        // We pick a name that is *syntactically* impossible to resolve.
1080        let r = DnsResolver::new();
1081        let err = r.create_custom_resolver("..").await.unwrap_err();
1082        let msg = err.to_string().to_lowercase();
1083        assert!(
1084            msg.contains("dns resolution failed") || msg.contains("invalid"),
1085            "expected resolution failure, got: {}",
1086            msg
1087        );
1088    }
1089
1090    #[tokio::test]
1091    async fn custom_resolver_rejects_private_ipv4() {
1092        // SSRF defense: private / reserved ranges must be blocked even
1093        // when passed as a literal IP rather than a hostname.
1094        let r = DnsResolver::new();
1095        for reserved in ["127.0.0.1", "10.0.0.1", "192.168.1.1", "169.254.169.254"] {
1096            let err = r.create_custom_resolver(reserved).await.unwrap_err();
1097            let msg = err.to_string().to_lowercase();
1098            assert!(
1099                msg.contains("blocked") || msg.contains("reserved"),
1100                "reserved IP {} must be rejected, got error: {}",
1101                reserved,
1102                msg
1103            );
1104        }
1105    }
1106
1107    #[tokio::test]
1108    async fn custom_resolver_rejects_loopback_ipv6() {
1109        let r = DnsResolver::new();
1110        let err = r.create_custom_resolver("::1").await.unwrap_err();
1111        let msg = err.to_string().to_lowercase();
1112        assert!(
1113            msg.contains("blocked") || msg.contains("reserved"),
1114            "::1 must be rejected, got error: {}",
1115            msg
1116        );
1117    }
1118
1119    #[tokio::test]
1120    async fn custom_resolver_accepts_public_ipv4() {
1121        // A known public resolver IP must be acceptable.
1122        let r = DnsResolver::new();
1123        let result = r.create_custom_resolver("8.8.8.8").await;
1124        assert!(
1125            result.is_ok(),
1126            "8.8.8.8 must be accepted as a public nameserver, got: {:?}",
1127            result.err()
1128        );
1129    }
1130
1131    // --- SRV query validation (integration between helper + resolver) ----
1132
1133    #[tokio::test]
1134    async fn resolve_srv_rejects_invalid_service_label() {
1135        let r = DnsResolver::new();
1136        // With_dot service name would construct a malformed DNS query.
1137        let result = r.resolve_srv("http.evil", "tcp", "example.com", None).await;
1138        assert!(result.is_err());
1139        let msg = result.unwrap_err().to_string().to_lowercase();
1140        assert!(
1141            msg.contains("invalid srv service"),
1142            "expected SRV service validation error, got: {}",
1143            msg
1144        );
1145    }
1146
1147    #[tokio::test]
1148    async fn resolve_srv_rejects_invalid_protocol_label() {
1149        let r = DnsResolver::new();
1150        let result = r.resolve_srv("http", "tcp.evil", "example.com", None).await;
1151        assert!(result.is_err());
1152        let msg = result.unwrap_err().to_string().to_lowercase();
1153        assert!(
1154            msg.contains("invalid srv protocol"),
1155            "expected SRV protocol validation error, got: {}",
1156            msg
1157        );
1158    }
1159
1160    // --- Normalization applied before resolution ---------------------
1161
1162    #[tokio::test]
1163    async fn resolve_normalizes_uppercase_domain_input() {
1164        // We can't hit the network in unit tests, but we can at least
1165        // assert that normalization rejects clearly-invalid input
1166        // before any network call is made. Domains with a leading `.`
1167        // are rejected by the normalizer.
1168        let r = DnsResolver::new();
1169        let result = r.resolve(".bad.example", RecordType::A, None).await;
1170        assert!(result.is_err(), "leading-dot domain must be rejected");
1171    }
1172
1173    // --- SRV record -------------------------------------------------
1174
1175    #[tokio::test]
1176    async fn resolve_rejects_srv_record_type_without_srv_helper() {
1177        // Calling `resolve` with SRV should return the helpful error
1178        // instructing the caller to use `resolve_srv` instead.
1179        let r = DnsResolver::new();
1180        let result = r.resolve("example.com", RecordType::SRV, None).await;
1181        assert!(result.is_err());
1182        let msg = result.unwrap_err().to_string();
1183        assert!(
1184            msg.contains("SRV records require service name format"),
1185            "expected helpful SRV error, got: {}",
1186            msg
1187        );
1188    }
1189}