1use std::net::{IpAddr, Ipv6Addr};
22
23use crate::error::{Result, SeerError};
24
25const UDP_PORT: u16 = 53;
27const TLS_PORT: u16 = 853;
29const HTTPS_PORT: u16 = 443;
31const DEFAULT_DOH_PATH: &str = "/dns-query";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum NameserverProtocol {
37 Udp,
39 Tls,
41 Https,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct NameserverSpec {
51 pub protocol: NameserverProtocol,
53 pub host: String,
55 pub port: u16,
57 pub path: Option<String>,
60}
61
62impl NameserverSpec {
63 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 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 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 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
176fn 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 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 if s.parse::<IpAddr>().is_ok() {
219 return Ok((s.to_string(), default_port));
220 }
221
222 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
241fn parse_port(s: &str) -> Result<u16> {
243 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 #[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 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 #[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 #[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 #[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 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"); 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"); parse_err("[not-an-ip]");
480 parse_err("[2001:db8::1]junk"); parse_err("[]:53"); }
483
484 #[test]
485 fn rejects_unbracketed_ipv6_plus_port_shape() {
486 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 #[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}