Skip to main content

reqwest/dns/
doh.rs

1//! DNS-over-HTTPS (DoH) resolution via hickory-resolver
2
3use hickory_resolver::config::{LookupIpStrategy, NameServerConfig, ResolverConfig};
4use hickory_resolver::net::runtime::TokioRuntimeProvider;
5use hickory_resolver::TokioResolver;
6
7use std::net::IpAddr;
8use std::str::FromStr;
9use std::sync::{Arc, Mutex};
10use std::time::Duration;
11
12use super::{Addrs, Name, Resolve, Resolving, SocketAddrs};
13use super::gai::GaiResolver;
14use crate::error::BoxError;
15
16/// A DNS-over-HTTPS resolver backed by hickory-resolver.
17pub struct DohResolver {
18    state: Arc<Mutex<Option<Arc<TokioResolver>>>>,
19    bootstrap: Arc<dyn Resolve>,
20    doh_host: String,
21    doh_path: String,
22    doh_port: u16,
23}
24
25impl std::fmt::Debug for DohResolver {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        f.debug_struct("DohResolver")
28            .field("doh_host", &self.doh_host)
29            .field("doh_path", &self.doh_path)
30            .field("doh_port", &self.doh_port)
31            .finish()
32    }
33}
34
35impl Clone for DohResolver {
36    fn clone(&self) -> Self {
37        Self {
38            state: self.state.clone(),
39            bootstrap: self.bootstrap.clone(),
40            doh_host: self.doh_host.clone(),
41            doh_path: self.doh_path.clone(),
42            doh_port: self.doh_port,
43        }
44    }
45}
46
47impl DohResolver {
48    /// Create a new DoH resolver from a URL like `https://cloudflare-dns.com/dns-query`.
49    ///
50    /// The host is resolved via the system resolver (GaiResolver) on first lookup.
51    pub fn new(url: &str) -> Result<Self, BoxError> {
52        let parsed = url::Url::parse(url)?;
53        let host = parsed.host_str().ok_or("DoH URL must have a host")?.to_string();
54        // Strip IPv6 brackets; url::host_str() includes them
55        let host = host.trim_start_matches('[').trim_end_matches(']').to_string();
56        let port = parsed.port().unwrap_or(443);
57        let path = parsed.path().to_string();
58        let bootstrap: Arc<dyn Resolve> = Arc::new(GaiResolver::new());
59        Ok(Self {
60            state: Arc::new(Mutex::new(None)),
61            bootstrap,
62            doh_host: host,
63            doh_path: path,
64            doh_port: port,
65        })
66    }
67
68    async fn get_resolver(&self) -> Result<Arc<TokioResolver>, BoxError> {
69        if let Some(ref resolver) = *self.state.lock().unwrap() {
70            return Ok(resolver.clone());
71        }
72
73        let addrs = self
74            .bootstrap
75            .resolve(Name::from_str(&self.doh_host)?)
76            .await?;
77        let ips: Vec<IpAddr> = addrs.map(|a| a.ip()).collect();
78
79        let name_servers: Vec<NameServerConfig> = ips
80            .iter()
81            .map(|&ip| {
82                NameServerConfig::https(
83                    ip,
84                    self.doh_host.clone().into(),
85                    Some(self.doh_path.clone().into()),
86                )
87            })
88            .collect();
89        let config = ResolverConfig::from_parts(None, vec![], name_servers);
90
91        let mut builder =
92            TokioResolver::builder_with_config(config, TokioRuntimeProvider::default());
93        let opts = builder.options_mut();
94        opts.timeout = Duration::from_secs(5);
95        opts.ip_strategy = LookupIpStrategy::Ipv4AndIpv6;
96        let resolver = Arc::new(builder.build().expect("failed to build DoH resolver"));
97
98        let mut guard = self.state.lock().unwrap();
99        if guard.is_none() {
100            *guard = Some(resolver.clone());
101        }
102        Ok(guard.as_ref().unwrap().clone())
103    }
104}
105
106impl Resolve for DohResolver {
107    fn resolve(&self, name: Name) -> Resolving {
108        let this = self.clone();
109        Box::pin(async move {
110            let resolver = this.get_resolver().await?;
111            let lookup = tokio::time::timeout(Duration::from_secs(5), resolver.lookup_ip(name.as_str()))
112                .await
113                .map_err(|_| BoxError::from("DoH lookup timed out"))?
114                .map_err(BoxError::from)?;
115            let ips: Vec<IpAddr> = lookup.iter().collect();
116            let addrs: Addrs = Box::new(SocketAddrs {
117                iter: ips.into_iter(),
118            });
119            Ok(addrs)
120        })
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::Client;
128
129    #[test]
130    fn new_cloudflare() {
131        let resolver = DohResolver::new("https://cloudflare-dns.com/dns-query").unwrap();
132        assert_eq!(resolver.doh_host, "cloudflare-dns.com");
133        assert_eq!(resolver.doh_port, 443);
134        assert_eq!(resolver.doh_path, "/dns-query");
135    }
136
137    #[test]
138    fn new_custom_port() {
139        let resolver = DohResolver::new("https://dns.google:8443/dns-query").unwrap();
140        assert_eq!(resolver.doh_host, "dns.google");
141        assert_eq!(resolver.doh_port, 8443);
142        assert_eq!(resolver.doh_path, "/dns-query");
143    }
144
145    #[test]
146    fn new_ipv6_literal() {
147        let resolver = DohResolver::new("https://[2606:4700:4700::1111]/dns-query").unwrap();
148        assert_eq!(resolver.doh_host, "2606:4700:4700::1111");
149        assert_eq!(resolver.doh_port, 443);
150        assert_eq!(resolver.doh_path, "/dns-query");
151    }
152
153    #[test]
154    fn new_rejects_invalid_url() {
155        let err = DohResolver::new("not a url").unwrap_err();
156        assert!(err.to_string().contains("relative URL"), "{err}");
157    }
158
159    #[test]
160    fn builder_creates_with_doh_resolver() {
161        let resolver = DohResolver::new("https://cloudflare-dns.com/dns-query").unwrap();
162        let client = Client::builder()
163            .dns_resolver(resolver)
164            .build();
165        assert!(client.is_ok());
166    }
167
168    #[test]
169    fn builder_creates_with_dot_resolver() {
170        use crate::dns::dot::DotResolver;
171        let resolver = DotResolver::new("1.1.1.1");
172        let client = Client::builder()
173            .dns_resolver(resolver)
174            .build();
175        assert!(client.is_ok());
176    }
177
178    #[test]
179    fn builder_creates_with_multi_resolver() {
180        let r1: Arc<dyn Resolve> = Arc::new(
181            DohResolver::new("https://cloudflare-dns.com/dns-query").unwrap(),
182        );
183        let r2: Arc<dyn Resolve> = Arc::new(crate::dns::gai::GaiResolver::new());
184        let client = Client::builder()
185            .dns_resolver(vec![r1, r2])
186            .build();
187        assert!(client.is_ok());
188    }
189
190    #[test]
191    fn debug_output() {
192        let resolver = DohResolver::new("https://cloudflare-dns.com:8443/custom-path").unwrap();
193        let debug = format!("{:?}", resolver);
194        assert!(debug.contains("cloudflare-dns.com"), "{debug}");
195        assert!(debug.contains("/custom-path"), "{debug}");
196        assert!(debug.contains("8443"), "{debug}");
197    }
198}