Skip to main content

reserve_core/lookup/
resolve.rs

1//! @docgen The platform resolver is unusable on a static binary and on Android, so names are resolved in process.
2
3use std::net::{IpAddr, Ipv4Addr, SocketAddr};
4use std::sync::Arc;
5use std::time::Duration;
6
7use hickory_resolver::config::{
8    ConnectionConfig, NameServerConfig, ProtocolConfig, ResolverConfig, ResolverOpts,
9};
10use hickory_resolver::net::runtime::TokioRuntimeProvider;
11use hickory_resolver::proto::rr::RecordType;
12use hickory_resolver::{Resolver, TokioResolver};
13
14use crate::error::Error;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum DnsVerdict {
18    InUse,
19    /// @docgen A registered name with no nameservers looks exactly like an unregistered one, so absence must never be read as available.
20    Absent,
21    NoAnswer,
22}
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
25pub struct DnsRecords {
26    pub by_type: Vec<(String, Vec<String>)>,
27}
28
29impl DnsRecords {
30    #[must_use]
31    pub fn is_empty(&self) -> bool {
32        self.by_type.is_empty()
33    }
34}
35
36const RECORD_TYPES: &[(RecordType, &str)] = &[
37    (RecordType::A, "a"),
38    (RecordType::AAAA, "aaaa"),
39    (RecordType::NS, "ns"),
40    (RecordType::MX, "mx"),
41    (RecordType::TXT, "txt"),
42    (RecordType::SOA, "soa"),
43];
44
45/// @docgen Validation is tried first and dropped only when it fails, so an unsigned or unverifiable zone still answers.
46#[derive(Debug, Clone)]
47pub struct Resolvers {
48    strict: TokioResolver,
49    lenient: TokioResolver,
50}
51
52impl Resolvers {
53    pub(crate) async fn lookup(
54        &self,
55        name: &str,
56        record_type: RecordType,
57    ) -> Result<hickory_resolver::lookup::Lookup, hickory_resolver::net::NetError> {
58        match self.strict.lookup(name, record_type).await {
59            Ok(answer) => Ok(answer),
60            // @docgen A name that genuinely does not exist is an answer, so retrying it would only cost a second query.
61            Err(error) if looks_absent(&error.to_string()) => Err(error),
62            Err(_) => self.lenient.lookup(name, record_type).await,
63        }
64    }
65
66    pub(crate) async fn lookup_ip(
67        &self,
68        host: &str,
69    ) -> Result<hickory_resolver::lookup_ip::LookupIp, hickory_resolver::net::NetError> {
70        match self.strict.lookup_ip(host).await {
71            Ok(answer) => Ok(answer),
72            Err(error) if looks_absent(&error.to_string()) => Err(error),
73            Err(_) => self.lenient.lookup_ip(host).await,
74        }
75    }
76}
77
78pub(crate) fn build(timeout: Duration) -> Result<Resolvers, Error> {
79    Ok(Resolvers {
80        strict: build_one(timeout, true)?,
81        lenient: build_one(timeout, false)?,
82    })
83}
84
85fn build_one(timeout: Duration, validate: bool) -> Result<TokioResolver, Error> {
86    let mut options = ResolverOpts::default();
87    // @docgen One try must fit inside the per-request budget with room left over for the connection itself.
88    options.timeout = (timeout / 4).max(Duration::from_secs(2));
89    options.attempts = 2;
90    // @docgen Networks that discard UDP port 53 resolve nothing without this.
91    options.try_tcp_on_error = true;
92    options.validate = validate;
93
94    if let Ok(builder) = Resolver::builder_tokio()
95        && let Ok(resolver) = builder.with_options(options.clone()).build()
96    {
97        return Ok(resolver);
98    }
99
100    // @docgen Two public providers are configured so one being unreachable or blocked is not fatal.
101    let servers = [
102        IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)),
103        IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)),
104    ]
105    .into_iter()
106    .map(|ip| {
107        NameServerConfig::new(
108            ip,
109            true,
110            vec![
111                ConnectionConfig::new(ProtocolConfig::Udp),
112                ConnectionConfig::new(ProtocolConfig::Tcp),
113            ],
114        )
115    })
116    .collect();
117
118    let config = ResolverConfig::from_parts(None, Vec::new(), servers);
119    Resolver::builder_with_config(config, TokioRuntimeProvider::default())
120        .with_options(options)
121        .build()
122        .map_err(|source| Error::NetworkUnreachable {
123            source: Box::new(source),
124        })
125}
126
127/// @docgen Nameservers are asked first because a delegated name always has them, so one query usually settles it.
128pub(crate) async fn query(resolver: &Resolvers, domain: &str) -> DnsVerdict {
129    let mut saw_absence = false;
130
131    for record_type in [RecordType::NS, RecordType::SOA, RecordType::A] {
132        match resolver.lookup(domain, record_type).await {
133            Ok(answer) if !answer.answers().is_empty() => return DnsVerdict::InUse,
134            Ok(_) => saw_absence = true,
135            Err(error) => {
136                if looks_absent(&error.to_string()) {
137                    saw_absence = true;
138                } else {
139                    return DnsVerdict::NoAnswer;
140                }
141            }
142        }
143    }
144
145    if saw_absence {
146        DnsVerdict::Absent
147    } else {
148        DnsVerdict::NoAnswer
149    }
150}
151
152/// @docgen Record data is whatever the zone owner published, so it is scrubbed before it can reach a terminal.
153fn record_value(line: &str) -> String {
154    let value = line
155        .split_whitespace()
156        .skip(4)
157        .collect::<Vec<_>>()
158        .join(" ");
159    crate::lookup::scrub(&value)
160}
161
162pub async fn dns_records(resolver: &Resolvers, domain: &str) -> DnsRecords {
163    let mut by_type = Vec::new();
164
165    for (record_type, label) in RECORD_TYPES {
166        let Ok(answer) = resolver.lookup(domain, *record_type).await else {
167            continue;
168        };
169        let mut values: Vec<String> = answer
170            .answers()
171            .iter()
172            .filter(|record| record.record_type() == *record_type)
173            .map(|record| record_value(&record.to_string()))
174            .filter(|value| !value.is_empty())
175            .collect();
176        values.sort_unstable();
177        values.dedup();
178        if !values.is_empty() {
179            by_type.push(((*label).to_owned(), values));
180        }
181    }
182
183    DnsRecords { by_type }
184}
185
186/// @docgen Only a real "nothing is here" says anything about the name; an unreachable resolver says nothing.
187fn looks_absent(message: &str) -> bool {
188    let message = message.to_lowercase();
189    let absent = message.contains("no records resolved")
190        || message.contains("nxdomain")
191        || message.contains("no record");
192    let unreachable = message.contains("timed out")
193        || message.contains("timeout")
194        || message.contains("connection")
195        || message.contains("no connections");
196    absent && !unreachable
197}
198
199/// @docgen The HTTP client would otherwise call the platform resolver on a blocking thread and hit the same wall.
200#[derive(Debug)]
201pub(crate) struct HttpResolver(Resolvers);
202
203impl HttpResolver {
204    #[must_use]
205    pub(crate) fn new(resolver: &Resolvers) -> Arc<Self> {
206        Arc::new(Self(resolver.clone()))
207    }
208}
209
210impl reqwest::dns::Resolve for HttpResolver {
211    fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
212        let resolver = self.0.clone();
213        Box::pin(async move {
214            let resolved = resolver.lookup_ip(name.as_str()).await?;
215            // @docgen Port zero lets the client fill in the real port from the URL.
216            let sockets: Vec<SocketAddr> =
217                resolved.iter().map(|ip| SocketAddr::new(ip, 0)).collect();
218            let addresses: reqwest::dns::Addrs = Box::new(sockets.into_iter());
219            Ok(addresses)
220        })
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn a_resolver_builds_without_panicking() {
230        assert!(build(Duration::from_secs(8)).is_ok());
231    }
232
233    #[test]
234    fn absence_is_told_apart_from_unreachability() {
235        assert!(looks_absent("no records resolved for Query"));
236        assert!(looks_absent("NXDOMAIN reported by server"));
237
238        assert!(!looks_absent("request timed out"));
239        assert!(!looks_absent("no connections available"));
240        assert!(!looks_absent("connection refused"));
241        assert!(!looks_absent("no records resolved: request timed out"));
242    }
243
244    #[test]
245    fn dns_has_no_way_to_say_available() {
246        // @docgen The verdict set deliberately has no "available" member: absence in DNS never proves a name can be registered.
247        for verdict in [DnsVerdict::InUse, DnsVerdict::Absent, DnsVerdict::NoAnswer] {
248            assert!(matches!(
249                verdict,
250                DnsVerdict::InUse | DnsVerdict::Absent | DnsVerdict::NoAnswer
251            ));
252        }
253    }
254
255    #[test]
256    fn empty_records_report_empty() {
257        assert!(DnsRecords::default().is_empty());
258        let filled = DnsRecords {
259            by_type: vec![("a".to_owned(), vec!["127.0.0.1".to_owned()])],
260        };
261        assert!(!filled.is_empty());
262    }
263
264    #[test]
265    fn record_data_carrying_an_escape_sequence_cannot_reach_the_terminal() {
266        let hostile = "example.test. 300 IN TXT \u{1b}[2K\u{1b}[Aforged";
267        let shaped = record_value(hostile);
268        assert!(
269            !shaped.contains('\u{1b}'),
270            "a zone owner must not be able to move the cursor"
271        );
272        assert!(shaped.contains("forged"), "the readable text still shows");
273    }
274}