From 35582cd703a95ba03c947243447685f97820e79c Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Wed, 5 Aug 2026 10:38:29 -0400 Subject: [PATCH] fix: defer encrypted DNS bootstrap for IP inspection --- src/dns/inspect.rs | 178 ++++++++++++++++++++++++++++++++------------- 1 file changed, 128 insertions(+), 50 deletions(-) diff --git a/src/dns/inspect.rs b/src/dns/inspect.rs index 917a9b3e..8fe6f61f 100644 --- a/src/dns/inspect.rs +++ b/src/dns/inspect.rs @@ -165,6 +165,11 @@ impl InspectionOutput { } } +struct ResolverConfig { + label: String, + server: Option, +} + #[derive(Clone, Debug, Eq, PartialEq)] enum ResolverTarget { Default { @@ -299,18 +304,20 @@ async fn inspect_result( .host_str() .filter(|host| !host.is_empty()) .ok_or_else(|| FetchError::Message("--inspect-dns requires a hostname".to_string()))?; - let target = resolver_target(dns_server).await?; - let start = Instant::now(); + let resolver = resolver_config(dns_server)?; if let Ok(ip) = host.parse::() { + let start = Instant::now(); return Ok(InspectionOutput::IpLiteral { host: host.to_string(), ip, - resolver: target.label().to_string(), + resolver: resolver.label().to_string(), duration: start.elapsed(), }); } + let target = resolver.resolve().await?; + let start = Instant::now(); let result = lookup(host, target, start, timeout).await?; Ok(InspectionOutput::Lookup(result)) } @@ -669,54 +676,28 @@ fn records_from_wire_response( Ok(records) } -async fn resolver_target(dns_server: Option<&str>) -> Result { - match dns_server { - None => Ok(default_resolver_target()), - Some(server) => match crate::dns::custom::parse_dns_server(server)? { - crate::dns::custom::ParsedDnsServer::Udp(addr) => Ok(ResolverTarget::Udp { - label: format!("udp {addr}"), - addr, - }), - crate::dns::custom::ParsedDnsServer::Tcp(addr) => Ok(ResolverTarget::Tcp { - label: format!("tcp {addr}"), - addr, - }), - crate::dns::custom::ParsedDnsServer::Tls { - server_name, - host, - port, - } => { - let addrs = crate::dns::custom::resolve_server_host(&host, port, None).await?; - Ok(ResolverTarget::Tls { - label: format!("tls {host}:{port}"), - server_name, - addrs, - }) - } - crate::dns::custom::ParsedDnsServer::Quic { - server_name, - host, - port, - } => { - let addrs = crate::dns::custom::resolve_server_host(&host, port, None).await?; - Ok(ResolverTarget::Quic { - label: format!("quic {host}:{port}"), - server_name, - addrs, - }) - } - crate::dns::custom::ParsedDnsServer::Doh(url) => Ok(ResolverTarget::Doh { - label: url.to_string(), - url, - }), - }, - } +fn resolver_config(dns_server: Option<&str>) -> Result { + let server = dns_server + .map(crate::dns::custom::parse_dns_server) + .transpose()?; + let label = server + .as_ref() + .map(resolver_label) + .unwrap_or_else(|| "platform resolver (A/AAAA only)".to_string()); + Ok(ResolverConfig { label, server }) } -fn default_resolver_target() -> ResolverTarget { - ResolverTarget::Default { - // The platform resolver API returns addresses, not arbitrary DNS records. - label: "platform resolver (A/AAAA only)".to_string(), +fn resolver_label(server: &crate::dns::custom::ParsedDnsServer) -> String { + match server { + crate::dns::custom::ParsedDnsServer::Udp(addr) => format!("udp {addr}"), + crate::dns::custom::ParsedDnsServer::Tcp(addr) => format!("tcp {addr}"), + crate::dns::custom::ParsedDnsServer::Tls { host, port, .. } => { + format!("tls {host}:{port}") + } + crate::dns::custom::ParsedDnsServer::Quic { host, port, .. } => { + format!("quic {host}:{port}") + } + crate::dns::custom::ParsedDnsServer::Doh(url) => url.to_string(), } } @@ -765,6 +746,52 @@ pub(crate) fn ignored_inspection_flags(cli: &Cli) -> Vec<&'static str> { ignored } +impl ResolverConfig { + fn label(&self) -> &str { + &self.label + } + + async fn resolve(self) -> Result { + let Self { label, server } = self; + match server { + None => Ok(ResolverTarget::Default { label }), + Some(crate::dns::custom::ParsedDnsServer::Udp(addr)) => { + Ok(ResolverTarget::Udp { label, addr }) + } + Some(crate::dns::custom::ParsedDnsServer::Tcp(addr)) => { + Ok(ResolverTarget::Tcp { label, addr }) + } + Some(crate::dns::custom::ParsedDnsServer::Tls { + server_name, + host, + port, + }) => { + let addrs = crate::dns::custom::resolve_server_host(&host, port, None).await?; + Ok(ResolverTarget::Tls { + label, + server_name, + addrs, + }) + } + Some(crate::dns::custom::ParsedDnsServer::Quic { + server_name, + host, + port, + }) => { + let addrs = crate::dns::custom::resolve_server_host(&host, port, None).await?; + Ok(ResolverTarget::Quic { + label, + server_name, + addrs, + }) + } + Some(crate::dns::custom::ParsedDnsServer::Doh(url)) => { + Ok(ResolverTarget::Doh { label, url }) + } + } + } +} + impl ResolverTarget { fn label(&self) -> &str { match self { @@ -969,6 +996,57 @@ mod tests { assert!(out.contains("* Duration:")); } + #[tokio::test] + async fn test_resolver_config_resolves_ip_literal_encrypted_dns_endpoints() { + let dot = resolver_config(Some("dot://127.0.0.1:8853")) + .unwrap() + .resolve() + .await + .unwrap(); + assert!(matches!( + dot, + ResolverTarget::Tls { addrs, .. } + if addrs == ["127.0.0.1:8853".parse::().unwrap()] + )); + + let doq = resolver_config(Some("doq://127.0.0.1:8853")) + .unwrap() + .resolve() + .await + .unwrap(); + assert!(matches!( + doq, + ResolverTarget::Quic { addrs, .. } + if addrs == ["127.0.0.1:8853".parse::().unwrap()] + )); + } + + #[tokio::test] + async fn test_inspect_ip_literal_does_not_resolve_dot_server_hostname() { + let out = inspect( + &Url::parse("http://127.0.0.1").unwrap(), + Some("dot://unresolvable-dot-server.invalid"), + ) + .await + .unwrap(); + + assert!(out.contains("IP literal: 127.0.0.1 (no DNS query needed)")); + assert!(out.contains("Resolver: tls unresolvable-dot-server.invalid:853")); + } + + #[tokio::test] + async fn test_inspect_ip_literal_does_not_resolve_doq_server_hostname() { + let out = inspect( + &Url::parse("http://127.0.0.1").unwrap(), + Some("doq://unresolvable-doq-server.invalid"), + ) + .await + .unwrap(); + + assert!(out.contains("IP literal: 127.0.0.1 (no DNS query needed)")); + assert!(out.contains("Resolver: quic unresolvable-doq-server.invalid:853")); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_lookup_queries_record_types_concurrently() { let active = Arc::new(AtomicUsize::new(0)); @@ -1121,7 +1199,7 @@ mod tests { #[tokio::test] async fn test_default_inspection_uses_platform_resolver() { - let target = resolver_target(None).await.unwrap(); + let target = resolver_config(None).unwrap().resolve().await.unwrap(); assert_eq!( target,