1use 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
16pub 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 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 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}