Skip to main content

seer_core/dns/
resolver.rs

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