Skip to main content

didbot_http/
doh.rs

1//! DNS over HTTPS, for the records a stub resolver has no API for: a zone's
2//! nameservers, a handle's TXT record, a lexicon authority's TXT record.
3//!
4//! [RFC 8484] carries DNS's own wire format over HTTPS. Cloudflare's and
5//! Google's resolvers also answer a JSON sibling of it: a GET naming the
6//! record and its type, answered as `application/dns-json`. That JSON form is
7//! what this speaks. [`query_url`] and [`parse`] need no network, and
8//! [`lookup`] makes the request through [`crate::pinned_client`].
9//!
10//! Whoever runs the resolver sees every name asked of it, so every caller
11//! takes the resolver's address as a parameter.
12//!
13//! [RFC 8484]: https://www.rfc-editor.org/rfc/rfc8484
14
15use serde_json::Value;
16use url::Url;
17
18use crate::{body_text, pinned_client, read_bounded, PrivateAddresses};
19
20/// Cloudflare's resolver, which answers the JSON form.
21pub const CLOUDFLARE: &str = "https://cloudflare-dns.com/dns-query";
22
23/// The `Accept` header that asks a DoH endpoint for JSON rather than the
24/// wire format.
25pub const JSON_ACCEPT: &str = "application/dns-json";
26
27/// The `TXT` record type, by its number and its name.
28pub const TXT: (u16, &str) = (16, "TXT");
29
30/// The most of one answer [`lookup`] reads. A TXT answer is a few hundred
31/// bytes.
32pub const MAX_ANSWER_BYTES: usize = 64 * 1024;
33
34/// The URL one query is made at.
35#[must_use]
36pub fn query_url(resolver: &str, name: &str, record_type: &str) -> String {
37    let separator = if resolver.contains('?') { '&' } else { '?' };
38    format!(
39        "{resolver}{separator}name={}&type={record_type}",
40        escape(name)
41    )
42}
43
44/// Percent-encodes what a hostname may legitimately carry and a query string
45/// may not. A hostname is letters, digits, hyphens, dots and underscores
46/// (`_atproto`), so this is a narrow guard against a caller's typo reaching
47/// the resolver as query syntax rather than a general encoder.
48fn escape(name: &str) -> String {
49    name.chars()
50        .map(|character| match character {
51            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '.' | '_' => character.to_string(),
52            other => {
53                let mut buffer = [0u8; 4];
54                other
55                    .encode_utf8(&mut buffer)
56                    .bytes()
57                    .map(|byte| format!("%{byte:02X}"))
58                    .collect()
59            }
60        })
61        .collect()
62}
63
64/// Reads one answer: the records of type `record_type`, as strings.
65///
66/// A name that exists with no record of this type is an empty vector. A name
67/// that does not exist at all is an error, because a check walking up a zone
68/// reads the two differently.
69///
70/// # Errors
71///
72/// The body is not the JSON this form defines, or the resolver answered with
73/// a DNS status other than NOERROR.
74pub fn parse(body: &str, record_type: u16) -> Result<Vec<String>, String> {
75    let answer: Value =
76        serde_json::from_str(body).map_err(|error| format!("the resolver answered: {error}"))?;
77    match answer.get("Status").and_then(Value::as_u64) {
78        Some(0) => {}
79        Some(status) => return Err(status_name(status).to_owned()),
80        None => return Err("the resolver answered without a status".to_owned()),
81    }
82    Ok(answer
83        .get("Answer")
84        .and_then(Value::as_array)
85        .map(|records| {
86            records
87                .iter()
88                .filter(|record| {
89                    record.get("type").and_then(Value::as_u64) == Some(record_type.into())
90                })
91                .filter_map(|record| record.get("data").and_then(Value::as_str))
92                .map(|data| tidy(record_type, data))
93                .collect()
94        })
95        .unwrap_or_default())
96}
97
98/// A record's `data` as a caller wants to read it: a nameserver without its
99/// root dot, a TXT record with its character-string quoting undone, and
100/// anything else as it came.
101fn tidy(record_type: u16, data: &str) -> String {
102    match record_type {
103        // NS: `ns-1.example.com.`
104        2 => data.trim_end_matches('.').to_owned(),
105        // TXT: one or more quoted character-strings, which a long record is
106        // split into and which a reader has to join back together.
107        16 => data
108            .split('"')
109            .enumerate()
110            .filter(|(index, _)| index % 2 == 1)
111            .map(|(_, segment)| segment)
112            .collect::<String>(),
113        _ => data.to_owned(),
114    }
115}
116
117/// The DNS RCODEs a public resolver actually answers with, by name.
118fn status_name(status: u64) -> &'static str {
119    match status {
120        1 => "FORMERR: the resolver could not read the query",
121        2 => "SERVFAIL: the zone's nameservers did not answer",
122        3 => "NXDOMAIN: the name does not exist",
123        5 => "REFUSED: the resolver declined to answer",
124        9 => "NOTAUTH: the nameserver is not authoritative for this name",
125        _ => "the resolver answered with a DNS error",
126    }
127}
128
129/// The records of `record_type`, by number and name, at `name`, as the
130/// resolver at `resolver` answers: one GET through a client pinned to the
131/// resolver's own addresses, its answer read under [`MAX_ANSWER_BYTES`] and
132/// by [`parse`].
133///
134/// # Errors
135///
136/// The resolver could not be reached, answered with an HTTP error, or
137/// answered with something [`parse`] refuses.
138pub async fn lookup(
139    resolver: &str,
140    name: &str,
141    record_type: (u16, &str),
142    private: PrivateAddresses,
143) -> Result<Vec<String>, String> {
144    let url = query_url(resolver, name, record_type.1);
145    let parsed = Url::parse(&url).map_err(|error| format!("{resolver} is not a URL: {error}"))?;
146    let client = pinned_client(&parsed, private)
147        .await
148        .map_err(|error| error.to_string())?;
149    let response = client
150        .get(parsed)
151        .header(reqwest::header::ACCEPT, JSON_ACCEPT)
152        .send()
153        .await
154        .map_err(|error| format!("asking {resolver} about {name}: {error}"))?
155        .error_for_status()
156        .map_err(|error| format!("{resolver} answered: {error}"))?;
157    let body = read_bounded(response, MAX_ANSWER_BYTES)
158        .await
159        .map_err(|error| format!("reading {resolver}'s answer: {error}"))?;
160    parse(&body_text(body), record_type.0)
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn a_query_url_survives_a_resolver_that_already_has_a_query() {
169        assert_eq!(
170            query_url(CLOUDFLARE, "pds.example.com", "A"),
171            "https://cloudflare-dns.com/dns-query?name=pds.example.com&type=A"
172        );
173        assert_eq!(
174            query_url(
175                "https://resolver.example/dns?ct=json",
176                "_atproto.a.b",
177                "TXT"
178            ),
179            "https://resolver.example/dns?ct=json&name=_atproto.a.b&type=TXT"
180        );
181        assert!(query_url(CLOUDFLARE, "a b&type=NS", "A").contains("a%20b%26type%3DNS"));
182    }
183
184    /// The three shapes the checks read: an address, a nameserver with its
185    /// root dot, and a TXT record split into character-strings.
186    #[test]
187    fn an_answer_is_read_by_type_and_tidied() {
188        let body = r#"{"Status":0,"Answer":[
189            {"name":"pds.example.com.","type":5,"TTL":60,"data":"other.example.com."},
190            {"name":"pds.example.com.","type":1,"TTL":60,"data":"203.0.113.7"}]}"#;
191        assert_eq!(parse(body, 1).expect("an answer"), vec!["203.0.113.7"]);
192        assert_eq!(parse(body, 28).expect("an answer"), Vec::<String>::new());
193
194        let ns = r#"{"Status":0,"Answer":[{"name":"example.com.","type":2,"TTL":60,
195            "data":"ns-1.awsdns-00.org."}]}"#;
196        assert_eq!(parse(ns, 2).expect("an answer"), vec!["ns-1.awsdns-00.org"]);
197
198        let txt = r#"{"Status":0,"Answer":[{"name":"_atproto.a.example.com.","type":16,"TTL":60,
199            "data":"\"did=did:web:a.\" \"example.com\""}]}"#;
200        assert_eq!(
201            parse(txt, 16).expect("an answer"),
202            vec!["did=did:web:a.example.com"]
203        );
204    }
205
206    /// A name with no record of this type and a name that does not exist are
207    /// different answers: the zone walk reads the first as "keep going" and
208    /// the second as something to report.
209    #[test]
210    fn nothing_here_and_no_such_name_are_told_apart() {
211        assert_eq!(
212            parse(r#"{"Status":0}"#, 2).expect("an answer"),
213            Vec::<String>::new()
214        );
215        assert!(parse(r#"{"Status":3}"#, 2)
216            .unwrap_err()
217            .contains("NXDOMAIN"));
218        assert!(parse(r#"{"Status":2}"#, 2)
219            .unwrap_err()
220            .contains("SERVFAIL"));
221    }
222}