1use serde_json::Value;
16use url::Url;
17
18use crate::{body_text, pinned_client, read_bounded, PrivateAddresses};
19
20pub const CLOUDFLARE: &str = "https://cloudflare-dns.com/dns-query";
22
23pub const JSON_ACCEPT: &str = "application/dns-json";
26
27pub const TXT: (u16, &str) = (16, "TXT");
29
30pub const MAX_ANSWER_BYTES: usize = 64 * 1024;
33
34#[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
44fn 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
64pub 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
98fn tidy(record_type: u16, data: &str) -> String {
102 match record_type {
103 2 => data.trim_end_matches('.').to_owned(),
105 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
117fn 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
129pub 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 #[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 #[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}