1use std::net::{IpAddr, Ipv6Addr};
24
25use crate::error::{Result, SeerError};
26
27const UDP_PORT: u16 = 53;
29const TLS_PORT: u16 = 853;
31const HTTPS_PORT: u16 = 443;
33const DEFAULT_DOH_PATH: &str = "/dns-query";
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum NameserverProtocol {
39 Udp,
41 Tls,
43 Https,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct NameserverSpec {
53 pub protocol: NameserverProtocol,
55 pub host: String,
57 pub port: u16,
59 pub path: Option<String>,
62}
63
64impl NameserverSpec {
65 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 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 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 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 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
194fn 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 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 if s.parse::<IpAddr>().is_ok() {
237 return Ok((s.to_string(), default_port));
238 }
239
240 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
259fn parse_port(s: &str) -> Result<u16> {
261 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 #[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 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 #[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 #[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 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 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 #[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 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"); 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"); parse_err("[not-an-ip]");
510 parse_err("[2001:db8::1]junk"); parse_err("[]:53"); }
513
514 #[test]
515 fn rejects_unbracketed_ipv6_plus_port_shape() {
516 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 #[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}