Skip to main content

seer_core/dns/
nameserver.rs

1//! Upstream nameserver specification parsing.
2//!
3//! Every surface that accepts a custom nameserver (CLI `--server`/positional
4//! nameservers, REPL `@server`, config `nameserver`, Python/REST/MCP) passes
5//! an opaque string into [`crate::dns::DnsResolver`]'s custom-nameserver
6//! path. This module is the single parser for that string, so all surfaces
7//! get every transport for free:
8//!
9//! - **UDP** (classic DNS, port 53): `8.8.8.8`, `dns.google`,
10//!   `9.9.9.9:5353`, `[2001:4860:4860::8888]:53`
11//! - **DoT** (DNS over TLS, port 853): `tls://1.1.1.1`,
12//!   `tls://dns.quad9.net:853`
13//! - **DoH** (DNS over HTTPS, port 443, path `/dns-query`):
14//!   `https://cloudflare-dns.com/dns-query`, `https://dns.google:443`,
15//!   `https://1.1.1.1/dns-query` (hostname or IPv4 literal — an IPv6-literal
16//!   host is rejected, see [`NameserverSpec::parse`])
17//!
18//! Bare IPs/hostnames keep their historical UDP behavior byte-for-byte; the
19//! `host:port` and bracketed-IPv6 forms are additive. Unbracketed IPv6
20//! literals are always parsed as a plain address — bracket them
21//! (`[2001:db8::1]:5353`) to attach a port.
22
23use std::net::{IpAddr, Ipv6Addr};
24
25use crate::error::{Result, SeerError};
26
27/// Default port for plain (UDP) DNS.
28const UDP_PORT: u16 = 53;
29/// Default port for DNS over TLS (RFC 7858).
30const TLS_PORT: u16 = 853;
31/// Default port for DNS over HTTPS (RFC 8484).
32const HTTPS_PORT: u16 = 443;
33/// Conventional DoH query path used by every major public resolver.
34const DEFAULT_DOH_PATH: &str = "/dns-query";
35
36/// Transport protocol for an upstream nameserver.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum NameserverProtocol {
39    /// Classic DNS over UDP (with no scheme prefix).
40    Udp,
41    /// DNS over TLS (`tls://`), RFC 7858.
42    Tls,
43    /// DNS over HTTPS (`https://`), RFC 8484.
44    Https,
45}
46
47/// A parsed upstream nameserver specification.
48///
49/// Produced by [`NameserverSpec::parse`] from the opaque nameserver strings
50/// accepted everywhere in seer (CLI flags, REPL `@server`, `config.toml`).
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct NameserverSpec {
53    /// Transport to speak to the upstream server.
54    pub protocol: NameserverProtocol,
55    /// Hostname or IP literal (IPv6 stored unbracketed).
56    pub host: String,
57    /// Target port (defaulted per protocol: 53 / 853 / 443).
58    pub port: u16,
59    /// DoH query path. Always `Some` for [`NameserverProtocol::Https`]
60    /// (defaulting to `/dns-query`), always `None` otherwise.
61    pub path: Option<String>,
62}
63
64impl NameserverSpec {
65    /// Parses a nameserver specification string.
66    ///
67    /// Accepted forms:
68    /// - `IP`, `hostname`, `host:port`, `[v6]`, `[v6]:port` → UDP (port 53)
69    /// - `tls://host[:port]` → DoT (port 853)
70    /// - `https://host[:port][/path]` → DoH (port 443, path `/dns-query`)
71    ///
72    /// # Errors
73    /// Returns [`SeerError::InvalidInput`] for empty input, embedded
74    /// whitespace, unknown schemes, malformed brackets, bad ports, a path on
75    /// a `tls://` spec, credentials in an `https://` spec, or an `https://`
76    /// spec whose host is an IPv6 literal (hickory derives both the TLS
77    /// server name and the HTTP `:authority` from the one unbracketed host
78    /// string, and an unbracketed IPv6 address is not a valid authority, so
79    /// such a spec could never complete a query).
80    pub fn parse(input: &str) -> Result<Self> {
81        let spec = input.trim();
82        if spec.is_empty() {
83            return Err(SeerError::InvalidInput(
84                "nameserver must not be empty".to_string(),
85            ));
86        }
87        if spec.chars().any(|c| c.is_whitespace() || c.is_control()) {
88            return Err(SeerError::InvalidInput(format!(
89                "nameserver must not contain whitespace: {spec:?}"
90            )));
91        }
92
93        if let Some((scheme, rest)) = spec.split_once("://") {
94            match scheme.to_ascii_lowercase().as_str() {
95                "tls" => Self::parse_tls(rest),
96                "https" => Self::parse_https(rest),
97                other => Err(SeerError::InvalidInput(format!(
98                    "unsupported nameserver scheme '{other}://' — use a bare IP/hostname (UDP), \
99                     tls://host[:port] (DNS over TLS), or https://host[/path] (DNS over HTTPS)"
100                ))),
101            }
102        } else {
103            let (host, port) = parse_host_port(spec, UDP_PORT)?;
104            Ok(Self {
105                protocol: NameserverProtocol::Udp,
106                host,
107                port,
108                path: None,
109            })
110        }
111    }
112
113    /// Parses the remainder of a `tls://` spec (`host[:port]`, no path).
114    fn parse_tls(rest: &str) -> Result<Self> {
115        if rest.contains('/') {
116            return Err(SeerError::InvalidInput(format!(
117                "tls:// nameservers do not take a path: tls://{rest} \
118                 (paths are a DNS-over-HTTPS concept — use https:// for DoH)"
119            )));
120        }
121        let (host, port) = parse_host_port(rest, TLS_PORT)?;
122        Ok(Self {
123            protocol: NameserverProtocol::Tls,
124            host,
125            port,
126            path: None,
127        })
128    }
129
130    /// Parses the remainder of an `https://` spec (`host[:port][/path]`).
131    fn parse_https(rest: &str) -> Result<Self> {
132        let (authority, path) = match rest.find('/') {
133            Some(i) => (&rest[..i], rest[i..].to_string()),
134            None => (rest, DEFAULT_DOH_PATH.to_string()),
135        };
136        if authority.contains('@') {
137            return Err(SeerError::InvalidInput(format!(
138                "credentials are not supported in DoH nameserver URLs: https://{rest}"
139            )));
140        }
141        let (host, port) = parse_host_port(authority, HTTPS_PORT)?;
142        // hickory builds the DoH request's HTTP authority from the same
143        // (unbracketed) string it uses as the TLS server name, and
144        // `2606:4700:4700::1111` is not a valid authority — every query
145        // would fail with "invalid authority". Reject it here with an
146        // actionable message instead of accepting a spec that cannot work.
147        if host.parse::<Ipv6Addr>().is_ok() {
148            return Err(SeerError::InvalidInput(format!(
149                "DNS over HTTPS to an IPv6 literal ([{host}]) is not supported — use the \
150                 resolver's hostname (e.g. https://cloudflare-dns.com/dns-query), an IPv4 \
151                 literal, or tls://[{host}] for DNS over TLS"
152            )));
153        }
154        Ok(Self {
155            protocol: NameserverProtocol::Https,
156            host,
157            port,
158            path: Some(path),
159        })
160    }
161
162    /// The TLS server name presented for certificate verification (DoT/DoH).
163    ///
164    /// For hostname specs this is the hostname. For IP-literal specs it is
165    /// the IP itself: rustls accepts IP-address server names and verifies
166    /// them against the certificate's IP SANs, which the major public
167    /// resolvers (1.1.1.1, 8.8.8.8, 9.9.9.9, …) all carry.
168    pub fn tls_name(&self) -> &str {
169        &self.host
170    }
171}
172
173impl std::fmt::Display for NameserverSpec {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        let host = if self.host.contains(':') {
176            format!("[{}]", self.host)
177        } else {
178            self.host.clone()
179        };
180        match self.protocol {
181            NameserverProtocol::Udp => write!(f, "{}:{}", host, self.port),
182            NameserverProtocol::Tls => write!(f, "tls://{}:{}", host, self.port),
183            NameserverProtocol::Https => write!(
184                f,
185                "https://{}:{}{}",
186                host,
187                self.port,
188                self.path.as_deref().unwrap_or(DEFAULT_DOH_PATH)
189            ),
190        }
191    }
192}
193
194/// Splits `host[:port]` (with bracketed-IPv6 support) into its parts,
195/// applying `default_port` when no port is given.
196///
197/// Unbracketed strings that parse as an [`IpAddr`] are returned whole — an
198/// IPv6 literal's trailing hextet is never misread as a port. Hostnames
199/// cannot legally contain `:`, so for everything else a single trailing
200/// `:port` is split off and validated.
201fn parse_host_port(s: &str, default_port: u16) -> Result<(String, u16)> {
202    if s.is_empty() {
203        return Err(SeerError::InvalidInput(
204            "nameserver host must not be empty".to_string(),
205        ));
206    }
207
208    // Bracketed IPv6: [addr] or [addr]:port
209    if let Some(rest) = s.strip_prefix('[') {
210        let Some((addr, after)) = rest.split_once(']') else {
211            return Err(SeerError::InvalidInput(format!(
212                "unterminated '[' in nameserver address: {s}"
213            )));
214        };
215        if addr.parse::<Ipv6Addr>().is_err() {
216            return Err(SeerError::InvalidInput(format!(
217                "invalid IPv6 literal in nameserver address: [{addr}]"
218            )));
219        }
220        let port = match after {
221            "" => default_port,
222            _ => match after.strip_prefix(':') {
223                Some(p) => parse_port(p)?,
224                None => {
225                    return Err(SeerError::InvalidInput(format!(
226                        "expected ':port' after ']' in nameserver address: {s}"
227                    )));
228                }
229            },
230        };
231        return Ok((addr.to_string(), port));
232    }
233
234    // A full IP literal (IPv4, or unbracketed IPv6 — which may contain many
235    // colons) never has a port suffix.
236    if s.parse::<IpAddr>().is_ok() {
237        return Ok((s.to_string(), default_port));
238    }
239
240    // host:port — hostnames cannot contain ':'.
241    if let Some((host, port)) = s.rsplit_once(':') {
242        if host.contains(':') {
243            return Err(SeerError::InvalidInput(format!(
244                "invalid nameserver address {s}: bracket IPv6 literals to add \
245                 a port (e.g. [2001:db8::1]:53)"
246            )));
247        }
248        if host.is_empty() {
249            return Err(SeerError::InvalidInput(format!(
250                "nameserver host must not be empty: {s}"
251            )));
252        }
253        return Ok((host.to_string(), parse_port(port)?));
254    }
255
256    Ok((s.to_string(), default_port))
257}
258
259/// Parses a decimal port number, rejecting 0, signs, and non-digits.
260fn parse_port(s: &str) -> Result<u16> {
261    // Reject sign characters explicitly: u16::from_str accepts a leading '+'.
262    if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
263        return Err(SeerError::InvalidInput(format!(
264            "invalid nameserver port: {s:?} (expected 1-65535)"
265        )));
266    }
267    match s.parse::<u16>() {
268        Ok(p) if p != 0 => Ok(p),
269        _ => Err(SeerError::InvalidInput(format!(
270            "invalid nameserver port: {s} (expected 1-65535)"
271        ))),
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    fn parse(s: &str) -> NameserverSpec {
280        NameserverSpec::parse(s).unwrap_or_else(|e| panic!("{s:?} must parse: {e}"))
281    }
282
283    fn parse_err(s: &str) -> String {
284        match NameserverSpec::parse(s) {
285            Err(SeerError::InvalidInput(msg)) => msg,
286            Err(other) => panic!("{s:?} must fail with InvalidInput, got: {other:?}"),
287            Ok(spec) => panic!("{s:?} must fail to parse, got: {spec:?}"),
288        }
289    }
290
291    // --- UDP forms (historical behavior + additive host:port) ---------
292
293    #[test]
294    fn udp_bare_ipv4() {
295        let spec = parse("8.8.8.8");
296        assert_eq!(spec.protocol, NameserverProtocol::Udp);
297        assert_eq!(spec.host, "8.8.8.8");
298        assert_eq!(spec.port, 53);
299        assert_eq!(spec.path, None);
300    }
301
302    #[test]
303    fn udp_ipv4_with_port() {
304        let spec = parse("9.9.9.9:5353");
305        assert_eq!(spec.protocol, NameserverProtocol::Udp);
306        assert_eq!(spec.host, "9.9.9.9");
307        assert_eq!(spec.port, 5353);
308    }
309
310    #[test]
311    fn udp_hostname() {
312        let spec = parse("dns.google");
313        assert_eq!(spec.protocol, NameserverProtocol::Udp);
314        assert_eq!(spec.host, "dns.google");
315        assert_eq!(spec.port, 53);
316    }
317
318    #[test]
319    fn udp_hostname_with_port() {
320        let spec = parse("dns.google:5353");
321        assert_eq!(spec.host, "dns.google");
322        assert_eq!(spec.port, 5353);
323    }
324
325    #[test]
326    fn udp_unbracketed_ipv6_is_whole_address() {
327        // The trailing hextet must NOT be misread as a port.
328        let spec = parse("2606:4700:4700::1111");
329        assert_eq!(spec.protocol, NameserverProtocol::Udp);
330        assert_eq!(spec.host, "2606:4700:4700::1111");
331        assert_eq!(spec.port, 53);
332    }
333
334    #[test]
335    fn udp_bracketed_ipv6_without_port() {
336        let spec = parse("[2606:4700:4700::1111]");
337        assert_eq!(spec.host, "2606:4700:4700::1111");
338        assert_eq!(spec.port, 53);
339    }
340
341    #[test]
342    fn udp_bracketed_ipv6_with_port() {
343        let spec = parse("[2001:4860:4860::8888]:5353");
344        assert_eq!(spec.protocol, NameserverProtocol::Udp);
345        assert_eq!(spec.host, "2001:4860:4860::8888");
346        assert_eq!(spec.port, 5353);
347    }
348
349    #[test]
350    fn udp_input_is_trimmed() {
351        let spec = parse("  8.8.8.8  ");
352        assert_eq!(spec.host, "8.8.8.8");
353    }
354
355    // --- DoT forms -----------------------------------------------------
356
357    #[test]
358    fn tls_ip_default_port() {
359        let spec = parse("tls://1.1.1.1");
360        assert_eq!(spec.protocol, NameserverProtocol::Tls);
361        assert_eq!(spec.host, "1.1.1.1");
362        assert_eq!(spec.port, 853);
363        assert_eq!(spec.path, None);
364        assert_eq!(spec.tls_name(), "1.1.1.1");
365    }
366
367    #[test]
368    fn tls_hostname_with_port() {
369        let spec = parse("tls://dns.quad9.net:8853");
370        assert_eq!(spec.protocol, NameserverProtocol::Tls);
371        assert_eq!(spec.host, "dns.quad9.net");
372        assert_eq!(spec.port, 8853);
373        assert_eq!(spec.tls_name(), "dns.quad9.net");
374    }
375
376    #[test]
377    fn tls_bracketed_ipv6_with_port() {
378        let spec = parse("tls://[2606:4700:4700::1111]:853");
379        assert_eq!(spec.host, "2606:4700:4700::1111");
380        assert_eq!(spec.port, 853);
381    }
382
383    #[test]
384    fn tls_scheme_is_case_insensitive() {
385        let spec = parse("TLS://1.1.1.1");
386        assert_eq!(spec.protocol, NameserverProtocol::Tls);
387    }
388
389    #[test]
390    fn tls_rejects_path() {
391        let msg = parse_err("tls://dns.quad9.net/dns-query");
392        assert!(msg.contains("do not take a path"), "got: {msg}");
393    }
394
395    #[test]
396    fn tls_rejects_empty_host() {
397        parse_err("tls://");
398    }
399
400    // --- DoH forms -----------------------------------------------------
401
402    #[test]
403    fn https_hostname_defaults_port_and_path() {
404        let spec = parse("https://cloudflare-dns.com");
405        assert_eq!(spec.protocol, NameserverProtocol::Https);
406        assert_eq!(spec.host, "cloudflare-dns.com");
407        assert_eq!(spec.port, 443);
408        assert_eq!(spec.path.as_deref(), Some("/dns-query"));
409    }
410
411    #[test]
412    fn https_explicit_path() {
413        let spec = parse("https://dns.google/resolve");
414        assert_eq!(spec.host, "dns.google");
415        assert_eq!(spec.path.as_deref(), Some("/resolve"));
416    }
417
418    #[test]
419    fn https_port_and_path() {
420        let spec = parse("https://doh.example.net:8443/custom/query");
421        assert_eq!(spec.host, "doh.example.net");
422        assert_eq!(spec.port, 8443);
423        assert_eq!(spec.path.as_deref(), Some("/custom/query"));
424    }
425
426    #[test]
427    fn https_ip_literal_host() {
428        let spec = parse("https://8.8.8.8/dns-query");
429        assert_eq!(spec.host, "8.8.8.8");
430        assert_eq!(spec.tls_name(), "8.8.8.8");
431    }
432
433    #[test]
434    fn https_rejects_ipv6_literal_host() {
435        // Regression: this parsed, but hickory derives the DoH `:authority`
436        // from the unbracketed host, which is invalid for IPv6 — every query
437        // failed with "invalid authority". Reject up front, pointing at the
438        // forms that do work.
439        for spec in [
440            "https://[2606:4700:4700::1111]:443/dns-query",
441            "https://[2606:4700:4700::1111]",
442        ] {
443            let msg = parse_err(spec);
444            assert!(msg.contains("IPv6 literal"), "got: {msg}");
445            assert!(msg.contains("tls://"), "got: {msg}");
446        }
447        // DoT to a bracketed IPv6 literal is unaffected.
448        let spec = parse("tls://[2606:4700:4700::1111]");
449        assert_eq!(spec.host, "2606:4700:4700::1111");
450        assert_eq!(spec.protocol, NameserverProtocol::Tls);
451    }
452
453    #[test]
454    fn https_rejects_credentials() {
455        let msg = parse_err("https://user:pass@dns.example.net/dns-query");
456        assert!(msg.contains("credentials"), "got: {msg}");
457    }
458
459    // --- Garbage and scheme typos ---------------------------------------
460
461    #[test]
462    fn rejects_empty_and_blank() {
463        parse_err("");
464        parse_err("   ");
465    }
466
467    #[test]
468    fn rejects_embedded_whitespace() {
469        parse_err("8.8.8.8 example.com");
470        parse_err("tls://dns\t.quad9.net");
471    }
472
473    #[test]
474    fn rejects_unknown_schemes() {
475        for bad in [
476            "http://dns.google",
477            "udp://8.8.8.8",
478            "tcp://8.8.8.8",
479            "quic://1.1.1.1",
480        ] {
481            let msg = parse_err(bad);
482            assert!(
483                msg.contains("unsupported nameserver scheme"),
484                "{bad}: {msg}"
485            );
486        }
487    }
488
489    #[test]
490    fn rejects_scheme_typos_as_bad_ports() {
491        // "tls:/1.1.1.1" has no "://" so it falls into the UDP host:port
492        // path, where the "/1.1.1.1" suffix is an invalid port.
493        parse_err("tls:/1.1.1.1");
494    }
495
496    #[test]
497    fn rejects_bad_ports() {
498        parse_err("8.8.8.8:0");
499        parse_err("8.8.8.8:65536");
500        parse_err("8.8.8.8:abc");
501        parse_err("8.8.8.8:+53"); // u16::from_str accepts '+'; the parser must not
502        parse_err("dns.google:");
503        parse_err("tls://dns.quad9.net:");
504    }
505
506    #[test]
507    fn rejects_malformed_brackets() {
508        parse_err("[2001:db8::1"); // unterminated
509        parse_err("[not-an-ip]");
510        parse_err("[2001:db8::1]junk"); // garbage after bracket
511        parse_err("[]:53"); // empty literal
512    }
513
514    #[test]
515    fn rejects_unbracketed_ipv6_plus_port_shape() {
516        // Not a valid IPv6 literal and the "host" still contains colons —
517        // must point the user at the bracketed form.
518        let msg = parse_err("2001:db8::zz:53");
519        assert!(msg.contains("bracket IPv6"), "got: {msg}");
520    }
521
522    #[test]
523    fn rejects_empty_host_with_port() {
524        parse_err(":53");
525    }
526
527    // --- Display ---------------------------------------------------------
528
529    #[test]
530    fn display_round_trips_reasonably() {
531        assert_eq!(parse("8.8.8.8").to_string(), "8.8.8.8:53");
532        assert_eq!(parse("tls://1.1.1.1").to_string(), "tls://1.1.1.1:853");
533        assert_eq!(
534            parse("https://dns.google").to_string(),
535            "https://dns.google:443/dns-query"
536        );
537        assert_eq!(
538            parse("[2001:db8::1]:5353").to_string(),
539            "[2001:db8::1]:5353"
540        );
541    }
542}