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