Skip to main content

max / makenotwork

14.7 KB · 456 lines History Blame Raw
1 //! RDAP domain lookups, the successor protocol to WHOIS.
2 //!
3 //! Registries are retiring port-43 WHOIS one TLD at a time. Google Registry
4 //! already has: `whois.nic.google` is NXDOMAIN, which took `.app` and `.dev`
5 //! down with it. RDAP is the replacement, and unlike WHOIS it has a registry
6 //! of registries, so the server for a TLD is discoverable rather than
7 //! hardcoded.
8 //!
9 //! The base URL for a TLD comes from IANA's bootstrap file, cached in process
10 //! for [`BOOTSTRAP_TTL`]. [`FALLBACK_BASES`] covers the TLDs we actually
11 //! monitor so a bootstrap outage does not blind the check. A TLD in neither
12 //! place has no RDAP path and falls back to WHOIS (see [`super::whois`]).
13
14 use std::collections::HashMap;
15 use std::sync::{Arc, RwLock};
16 use std::time::{Duration, Instant};
17
18 /// IANA's TLD-to-RDAP-service map.
19 const BOOTSTRAP_URL: &str = "https://data.iana.org/rdap/dns.json";
20
21 /// How long a fetched bootstrap stays good. The file changes when a registry
22 /// joins or moves, which is a matter of months, so a day is generous.
23 const BOOTSTRAP_TTL: Duration = Duration::from_hours(24);
24
25 /// Bases for the TLDs under monitoring, used when the bootstrap is
26 /// unreachable. Values mirror IANA's bootstrap file.
27 const FALLBACK_BASES: &[(&str, &str)] = &[
28 ("app", "https://pubapi.registry.google/rdap/"),
29 ("dev", "https://pubapi.registry.google/rdap/"),
30 ("work", "https://rdap.nic.work/"),
31 ("com", "https://rdap.verisign.com/com/v1/"),
32 ("net", "https://rdap.verisign.com/net/v1/"),
33 ("org", "https://rdap.publicinterestregistry.org/rdap/"),
34 ("info", "https://rdap.identitydigital.services/rdap/"),
35 ];
36
37 static BOOTSTRAP_CACHE: RwLock<Option<CachedBootstrap>> = RwLock::new(None);
38
39 struct CachedBootstrap {
40 fetched_at: Instant,
41 bases: Arc<HashMap<String, String>>,
42 }
43
44 /// Domain facts an RDAP response carries, in the same shape the WHOIS parser
45 /// produces so both paths feed one result type.
46 pub struct RdapDomain {
47 pub registrar: Option<String>,
48 pub expiry_date: Option<String>,
49 pub nameservers: Vec<String>,
50 }
51
52 /// Look up a domain over RDAP. `Ok(None)` means no RDAP service is known for
53 /// the TLD, which is a signal to try WHOIS, not a failure.
54 pub async fn lookup(domain: &str) -> Result<Option<RdapDomain>, String> {
55 let Some(tld) = domain.rsplit('.').next().filter(|t| !t.is_empty()) else {
56 return Ok(None);
57 };
58 let Some(base) = base_for_tld(tld).await else {
59 return Ok(None);
60 };
61 query(&base, domain).await.map(Some)
62 }
63
64 /// Resolve a TLD to its RDAP base URL, preferring the IANA bootstrap and
65 /// falling back to the built-in table.
66 async fn base_for_tld(tld: &str) -> Option<String> {
67 let tld = tld.to_lowercase();
68
69 if let Some(bases) = bootstrap().await
70 && let Some(base) = bases.get(&tld)
71 {
72 return Some(base.clone());
73 }
74
75 FALLBACK_BASES
76 .iter()
77 .find(|(t, _)| *t == tld)
78 .map(|(_, base)| (*base).to_string())
79 }
80
81 /// The cached bootstrap map, refetched once past [`BOOTSTRAP_TTL`]. A failed
82 /// fetch returns the stale map if there is one, since a map from last week
83 /// beats no map at all.
84 async fn bootstrap() -> Option<Arc<HashMap<String, String>>> {
85 if let Ok(guard) = BOOTSTRAP_CACHE.read()
86 && let Some(cached) = guard.as_ref()
87 && cached.fetched_at.elapsed() < BOOTSTRAP_TTL
88 {
89 return Some(Arc::clone(&cached.bases));
90 }
91
92 match fetch_bootstrap().await {
93 Ok(bases) => {
94 let bases = Arc::new(bases);
95 if let Ok(mut guard) = BOOTSTRAP_CACHE.write() {
96 *guard = Some(CachedBootstrap {
97 fetched_at: Instant::now(),
98 bases: Arc::clone(&bases),
99 });
100 }
101 Some(bases)
102 }
103 Err(e) => {
104 tracing::warn!("RDAP bootstrap fetch failed, using fallback table: {e}");
105 BOOTSTRAP_CACHE
106 .read()
107 .ok()
108 .and_then(|guard| guard.as_ref().map(|c| Arc::clone(&c.bases)))
109 }
110 }
111 }
112
113 async fn fetch_bootstrap() -> Result<HashMap<String, String>, String> {
114 let client = crate::tls::https_client_builder()
115 .timeout(Duration::from_secs(15))
116 .build()
117 .map_err(|e| format!("RDAP client build failed: {e}"))?;
118
119 let body = client
120 .get(BOOTSTRAP_URL)
121 .send()
122 .await
123 .map_err(|e| format!("RDAP bootstrap request failed: {e}"))?
124 .error_for_status()
125 .map_err(|e| format!("RDAP bootstrap returned {e}"))?
126 .text()
127 .await
128 .map_err(|e| format!("RDAP bootstrap body read failed: {e}"))?;
129
130 parse_bootstrap(&body)
131 }
132
133 /// Flatten the bootstrap's `services` array into a TLD-to-base map.
134 ///
135 /// Each service is `[[tld, ...], [url, ...]]`; the first HTTPS URL wins, and
136 /// a service with no HTTPS URL is skipped rather than downgraded.
137 pub fn parse_bootstrap(body: &str) -> Result<HashMap<String, String>, String> {
138 let doc: serde_json::Value =
139 serde_json::from_str(body).map_err(|e| format!("RDAP bootstrap not valid JSON: {e}"))?;
140
141 let services = doc
142 .get("services")
143 .and_then(|s| s.as_array())
144 .ok_or_else(|| "RDAP bootstrap has no services array".to_string())?;
145
146 let mut bases = HashMap::new();
147 for service in services {
148 let Some(entry) = service.as_array() else {
149 continue;
150 };
151 let (Some(tlds), Some(urls)) = (
152 entry.first().and_then(|v| v.as_array()),
153 entry.get(1).and_then(|v| v.as_array()),
154 ) else {
155 continue;
156 };
157
158 let Some(url) = urls
159 .iter()
160 .filter_map(|u| u.as_str())
161 .find(|u| u.starts_with("https://"))
162 else {
163 continue;
164 };
165
166 for tld in tlds.iter().filter_map(|t| t.as_str()) {
167 bases.insert(tld.to_lowercase(), url.to_string());
168 }
169 }
170
171 if bases.is_empty() {
172 return Err("RDAP bootstrap contained no usable services".to_string());
173 }
174 Ok(bases)
175 }
176
177 async fn query(base: &str, domain: &str) -> Result<RdapDomain, String> {
178 let url = format!("{}domain/{domain}", ensure_trailing_slash(base));
179
180 let client = crate::tls::https_client_builder()
181 .timeout(Duration::from_secs(10))
182 .build()
183 .map_err(|e| format!("RDAP client build failed: {e}"))?;
184
185 let response = client
186 .get(&url)
187 .header("Accept", "application/rdap+json")
188 .send()
189 .await
190 .map_err(|e| format!("RDAP request to {url} failed: {e}"))?;
191
192 let status = response.status();
193 if !status.is_success() {
194 return Err(format!("RDAP query to {url} returned HTTP {status}"));
195 }
196
197 let body = response
198 .text()
199 .await
200 .map_err(|e| format!("RDAP body read failed: {e}"))?;
201
202 parse_domain(&body)
203 }
204
205 fn ensure_trailing_slash(base: &str) -> String {
206 if base.ends_with('/') {
207 base.to_string()
208 } else {
209 format!("{base}/")
210 }
211 }
212
213 /// Pull registrar, expiry and nameservers out of an RDAP domain object.
214 pub fn parse_domain(body: &str) -> Result<RdapDomain, String> {
215 let doc: serde_json::Value =
216 serde_json::from_str(body).map_err(|e| format!("RDAP response not valid JSON: {e}"))?;
217
218 let expiry_date = doc
219 .get("events")
220 .and_then(|e| e.as_array())
221 .and_then(|events| {
222 events
223 .iter()
224 .find(|e| e.get("eventAction").and_then(|a| a.as_str()) == Some("expiration"))
225 })
226 .and_then(|e| e.get("eventDate"))
227 .and_then(|d| d.as_str())
228 .map(str::to_string);
229
230 let registrar = doc
231 .get("entities")
232 .and_then(|e| e.as_array())
233 .and_then(|entities| {
234 entities.iter().find(|e| {
235 e.get("roles")
236 .and_then(|r| r.as_array())
237 .is_some_and(|roles| roles.iter().any(|r| r.as_str() == Some("registrar")))
238 })
239 })
240 .and_then(vcard_full_name);
241
242 let mut nameservers = Vec::new();
243 if let Some(list) = doc.get("nameservers").and_then(|n| n.as_array()) {
244 for ns in list {
245 let Some(name) = ns.get("ldhName").and_then(|n| n.as_str()) else {
246 continue;
247 };
248 let name = name.trim_end_matches('.').to_lowercase();
249 if !name.is_empty() && !nameservers.contains(&name) {
250 nameservers.push(name);
251 }
252 }
253 }
254
255 Ok(RdapDomain {
256 registrar,
257 expiry_date,
258 nameservers,
259 })
260 }
261
262 /// Extract the `fn` (formatted name) property from an entity's jCard.
263 ///
264 /// jCard is `["vcard", [[name, params, type, value], ...]]`, so the value
265 /// sits at index 3 of the property whose index 0 is `"fn"`.
266 fn vcard_full_name(entity: &serde_json::Value) -> Option<String> {
267 let properties = entity.get("vcardArray")?.as_array()?.get(1)?.as_array()?;
268
269 properties
270 .iter()
271 .filter_map(|p| p.as_array())
272 .find(|p| p.first().and_then(|k| k.as_str()) == Some("fn"))
273 .and_then(|p| p.get(3))
274 .and_then(|v| v.as_str())
275 .filter(|v| !v.is_empty())
276 .map(str::to_string)
277 }
278
279 #[cfg(test)]
280 mod tests {
281 use super::*;
282
283 const HTPY_RDAP: &str = r#"{
284 "objectClassName": "domain",
285 "ldhName": "htpy.app",
286 "events": [
287 {"eventAction": "registration", "eventDate": "2026-03-11T19:13:33.195Z"},
288 {"eventAction": "expiration", "eventDate": "2027-03-11T19:13:33.195Z"},
289 {"eventAction": "last changed", "eventDate": "2026-03-16T19:13:33.195Z"}
290 ],
291 "entities": [
292 {
293 "roles": ["registrar"],
294 "vcardArray": ["vcard", [
295 ["version", {}, "text", "4.0"],
296 ["fn", {}, "text", "CloudFlare, Inc."]
297 ]]
298 }
299 ],
300 "nameservers": [
301 {"ldhName": "PAM.NS.CLOUDFLARE.COM."},
302 {"ldhName": "simon.ns.cloudflare.com"}
303 ]
304 }"#;
305
306 #[test]
307 fn parse_domain_extracts_expiry_registrar_nameservers() {
308 let parsed = parse_domain(HTPY_RDAP).unwrap();
309 assert_eq!(
310 parsed.expiry_date.as_deref(),
311 Some("2027-03-11T19:13:33.195Z")
312 );
313 assert_eq!(parsed.registrar.as_deref(), Some("CloudFlare, Inc."));
314 assert_eq!(
315 parsed.nameservers,
316 vec![
317 "pam.ns.cloudflare.com".to_string(),
318 "simon.ns.cloudflare.com".to_string()
319 ]
320 );
321 }
322
323 #[test]
324 fn parse_domain_ignores_non_expiration_events() {
325 // Pins the eventAction filter: registration comes first in the array,
326 // so a missing filter would return the wrong date.
327 let parsed = parse_domain(HTPY_RDAP).unwrap();
328 assert_ne!(
329 parsed.expiry_date.as_deref(),
330 Some("2026-03-11T19:13:33.195Z")
331 );
332 }
333
334 #[test]
335 fn parse_domain_skips_entities_without_registrar_role() {
336 let body = r#"{
337 "entities": [
338 {"roles": ["technical"], "vcardArray": ["vcard", [["fn", {}, "text", "Tech Co"]]]},
339 {"roles": ["registrar"], "vcardArray": ["vcard", [["fn", {}, "text", "Real Registrar"]]]}
340 ]
341 }"#;
342 let parsed = parse_domain(body).unwrap();
343 assert_eq!(parsed.registrar.as_deref(), Some("Real Registrar"));
344 }
345
346 #[test]
347 fn parse_domain_tolerates_missing_sections() {
348 let parsed = parse_domain(r#"{"objectClassName": "domain"}"#).unwrap();
349 assert!(parsed.expiry_date.is_none());
350 assert!(parsed.registrar.is_none());
351 assert!(parsed.nameservers.is_empty());
352 }
353
354 #[test]
355 fn parse_domain_deduplicates_nameservers() {
356 let body =
357 r#"{"nameservers": [{"ldhName": "ns1.example.com."}, {"ldhName": "NS1.example.com"}]}"#;
358 let parsed = parse_domain(body).unwrap();
359 assert_eq!(parsed.nameservers, vec!["ns1.example.com".to_string()]);
360 }
361
362 #[test]
363 fn parse_domain_rejects_non_json() {
364 assert!(parse_domain("not json at all").is_err());
365 }
366
367 #[test]
368 fn parse_bootstrap_flattens_services() {
369 let body = r#"{
370 "version": "1.0",
371 "services": [
372 [["app", "dev"], ["https://pubapi.registry.google/rdap/"]],
373 [["work"], ["https://rdap.nic.work/"]]
374 ]
375 }"#;
376 let bases = parse_bootstrap(body).unwrap();
377 assert_eq!(
378 bases.get("app").map(String::as_str),
379 Some("https://pubapi.registry.google/rdap/")
380 );
381 assert_eq!(
382 bases.get("dev").map(String::as_str),
383 Some("https://pubapi.registry.google/rdap/")
384 );
385 assert_eq!(
386 bases.get("work").map(String::as_str),
387 Some("https://rdap.nic.work/")
388 );
389 }
390
391 #[test]
392 fn parse_bootstrap_prefers_https() {
393 let body = r#"{"services": [[["test"], ["http://insecure.example/", "https://secure.example/"]]]}"#;
394 let bases = parse_bootstrap(body).unwrap();
395 assert_eq!(
396 bases.get("test").map(String::as_str),
397 Some("https://secure.example/")
398 );
399 }
400
401 #[test]
402 fn parse_bootstrap_skips_http_only_services() {
403 let body = r#"{
404 "services": [
405 [["insecure"], ["http://only.example/"]],
406 [["fine"], ["https://ok.example/"]]
407 ]
408 }"#;
409 let bases = parse_bootstrap(body).unwrap();
410 assert!(!bases.contains_key("insecure"));
411 assert!(bases.contains_key("fine"));
412 }
413
414 #[test]
415 fn parse_bootstrap_lowercases_tlds() {
416 let body = r#"{"services": [[["APP"], ["https://example/"]]]}"#;
417 let bases = parse_bootstrap(body).unwrap();
418 assert!(bases.contains_key("app"));
419 }
420
421 #[test]
422 fn parse_bootstrap_rejects_missing_services() {
423 assert!(parse_bootstrap(r#"{"version": "1.0"}"#).is_err());
424 }
425
426 #[test]
427 fn parse_bootstrap_rejects_empty_result() {
428 assert!(parse_bootstrap(r#"{"services": []}"#).is_err());
429 }
430
431 #[test]
432 fn fallback_covers_monitored_tlds() {
433 // The point of the fallback table: an IANA outage must not blind the
434 // check for a TLD we actually watch.
435 for tld in ["app", "dev", "work", "com", "net", "org", "info"] {
436 assert!(
437 FALLBACK_BASES.iter().any(|(t, _)| *t == tld),
438 "no fallback RDAP base for .{tld}"
439 );
440 }
441 }
442
443 #[test]
444 fn fallback_bases_are_https() {
445 for (tld, base) in FALLBACK_BASES {
446 assert!(base.starts_with("https://"), ".{tld} fallback is not HTTPS");
447 }
448 }
449
450 #[test]
451 fn ensure_trailing_slash_is_idempotent() {
452 assert_eq!(ensure_trailing_slash("https://x/rdap/"), "https://x/rdap/");
453 assert_eq!(ensure_trailing_slash("https://x/rdap"), "https://x/rdap/");
454 }
455 }
456