//! RDAP domain lookups, the successor protocol to WHOIS. //! //! Registries are retiring port-43 WHOIS one TLD at a time. Google Registry //! already has: `whois.nic.google` is NXDOMAIN, which took `.app` and `.dev` //! down with it. RDAP is the replacement, and unlike WHOIS it has a registry //! of registries, so the server for a TLD is discoverable rather than //! hardcoded. //! //! The base URL for a TLD comes from IANA's bootstrap file, cached in process //! for [`BOOTSTRAP_TTL`]. [`FALLBACK_BASES`] covers the TLDs we actually //! monitor so a bootstrap outage does not blind the check. A TLD in neither //! place has no RDAP path and falls back to WHOIS (see [`super::whois`]). use std::collections::HashMap; use std::sync::{Arc, RwLock}; use std::time::{Duration, Instant}; /// IANA's TLD-to-RDAP-service map. const BOOTSTRAP_URL: &str = "https://data.iana.org/rdap/dns.json"; /// How long a fetched bootstrap stays good. The file changes when a registry /// joins or moves, which is a matter of months, so a day is generous. const BOOTSTRAP_TTL: Duration = Duration::from_hours(24); /// Bases for the TLDs under monitoring, used when the bootstrap is /// unreachable. Values match IANA's file as of 2026-08-06. const FALLBACK_BASES: &[(&str, &str)] = &[ ("app", "https://pubapi.registry.google/rdap/"), ("dev", "https://pubapi.registry.google/rdap/"), ("work", "https://rdap.nic.work/"), ("com", "https://rdap.verisign.com/com/v1/"), ("net", "https://rdap.verisign.com/net/v1/"), ("org", "https://rdap.publicinterestregistry.org/rdap/"), ("info", "https://rdap.identitydigital.services/rdap/"), ]; static BOOTSTRAP_CACHE: RwLock> = RwLock::new(None); struct CachedBootstrap { fetched_at: Instant, bases: Arc>, } /// Domain facts an RDAP response carries, in the same shape the WHOIS parser /// produces so both paths feed one result type. pub struct RdapDomain { pub registrar: Option, pub expiry_date: Option, pub nameservers: Vec, } /// Look up a domain over RDAP. `Ok(None)` means no RDAP service is known for /// the TLD, which is a signal to try WHOIS, not a failure. pub async fn lookup(domain: &str) -> Result, String> { let Some(tld) = domain.rsplit('.').next().filter(|t| !t.is_empty()) else { return Ok(None); }; let Some(base) = base_for_tld(tld).await else { return Ok(None); }; query(&base, domain).await.map(Some) } /// Resolve a TLD to its RDAP base URL, preferring the IANA bootstrap and /// falling back to the built-in table. async fn base_for_tld(tld: &str) -> Option { let tld = tld.to_lowercase(); if let Some(bases) = bootstrap().await && let Some(base) = bases.get(&tld) { return Some(base.clone()); } FALLBACK_BASES .iter() .find(|(t, _)| *t == tld) .map(|(_, base)| (*base).to_string()) } /// The cached bootstrap map, refetched once past [`BOOTSTRAP_TTL`]. A failed /// fetch returns the stale map if there is one, since a map from last week /// beats no map at all. async fn bootstrap() -> Option>> { if let Ok(guard) = BOOTSTRAP_CACHE.read() && let Some(cached) = guard.as_ref() && cached.fetched_at.elapsed() < BOOTSTRAP_TTL { return Some(Arc::clone(&cached.bases)); } match fetch_bootstrap().await { Ok(bases) => { let bases = Arc::new(bases); if let Ok(mut guard) = BOOTSTRAP_CACHE.write() { *guard = Some(CachedBootstrap { fetched_at: Instant::now(), bases: Arc::clone(&bases), }); } Some(bases) } Err(e) => { tracing::warn!("RDAP bootstrap fetch failed, using fallback table: {e}"); BOOTSTRAP_CACHE .read() .ok() .and_then(|guard| guard.as_ref().map(|c| Arc::clone(&c.bases))) } } } async fn fetch_bootstrap() -> Result, String> { let client = crate::tls::https_client_builder() .timeout(Duration::from_secs(15)) .build() .map_err(|e| format!("RDAP client build failed: {e}"))?; let body = client .get(BOOTSTRAP_URL) .send() .await .map_err(|e| format!("RDAP bootstrap request failed: {e}"))? .error_for_status() .map_err(|e| format!("RDAP bootstrap returned {e}"))? .text() .await .map_err(|e| format!("RDAP bootstrap body read failed: {e}"))?; parse_bootstrap(&body) } /// Flatten the bootstrap's `services` array into a TLD-to-base map. /// /// Each service is `[[tld, ...], [url, ...]]`; the first HTTPS URL wins, and /// a service with no HTTPS URL is skipped rather than downgraded. pub fn parse_bootstrap(body: &str) -> Result, String> { let doc: serde_json::Value = serde_json::from_str(body).map_err(|e| format!("RDAP bootstrap not valid JSON: {e}"))?; let services = doc .get("services") .and_then(|s| s.as_array()) .ok_or_else(|| "RDAP bootstrap has no services array".to_string())?; let mut bases = HashMap::new(); for service in services { let Some(entry) = service.as_array() else { continue; }; let (Some(tlds), Some(urls)) = ( entry.first().and_then(|v| v.as_array()), entry.get(1).and_then(|v| v.as_array()), ) else { continue; }; let Some(url) = urls .iter() .filter_map(|u| u.as_str()) .find(|u| u.starts_with("https://")) else { continue; }; for tld in tlds.iter().filter_map(|t| t.as_str()) { bases.insert(tld.to_lowercase(), url.to_string()); } } if bases.is_empty() { return Err("RDAP bootstrap contained no usable services".to_string()); } Ok(bases) } async fn query(base: &str, domain: &str) -> Result { let url = format!("{}domain/{domain}", ensure_trailing_slash(base)); let client = crate::tls::https_client_builder() .timeout(Duration::from_secs(10)) .build() .map_err(|e| format!("RDAP client build failed: {e}"))?; let response = client .get(&url) .header("Accept", "application/rdap+json") .send() .await .map_err(|e| format!("RDAP request to {url} failed: {e}"))?; let status = response.status(); if !status.is_success() { return Err(format!("RDAP query to {url} returned HTTP {status}")); } let body = response .text() .await .map_err(|e| format!("RDAP body read failed: {e}"))?; parse_domain(&body) } fn ensure_trailing_slash(base: &str) -> String { if base.ends_with('/') { base.to_string() } else { format!("{base}/") } } /// Pull registrar, expiry and nameservers out of an RDAP domain object. pub fn parse_domain(body: &str) -> Result { let doc: serde_json::Value = serde_json::from_str(body).map_err(|e| format!("RDAP response not valid JSON: {e}"))?; let expiry_date = doc .get("events") .and_then(|e| e.as_array()) .and_then(|events| { events .iter() .find(|e| e.get("eventAction").and_then(|a| a.as_str()) == Some("expiration")) }) .and_then(|e| e.get("eventDate")) .and_then(|d| d.as_str()) .map(str::to_string); let registrar = doc .get("entities") .and_then(|e| e.as_array()) .and_then(|entities| { entities.iter().find(|e| { e.get("roles") .and_then(|r| r.as_array()) .is_some_and(|roles| roles.iter().any(|r| r.as_str() == Some("registrar"))) }) }) .and_then(vcard_full_name); let mut nameservers = Vec::new(); if let Some(list) = doc.get("nameservers").and_then(|n| n.as_array()) { for ns in list { let Some(name) = ns.get("ldhName").and_then(|n| n.as_str()) else { continue; }; let name = name.trim_end_matches('.').to_lowercase(); if !name.is_empty() && !nameservers.contains(&name) { nameservers.push(name); } } } Ok(RdapDomain { registrar, expiry_date, nameservers, }) } /// Extract the `fn` (formatted name) property from an entity's jCard. /// /// jCard is `["vcard", [[name, params, type, value], ...]]`, so the value /// sits at index 3 of the property whose index 0 is `"fn"`. fn vcard_full_name(entity: &serde_json::Value) -> Option { let properties = entity.get("vcardArray")?.as_array()?.get(1)?.as_array()?; properties .iter() .filter_map(|p| p.as_array()) .find(|p| p.first().and_then(|k| k.as_str()) == Some("fn")) .and_then(|p| p.get(3)) .and_then(|v| v.as_str()) .filter(|v| !v.is_empty()) .map(str::to_string) } #[cfg(test)] mod tests { use super::*; const HTPY_RDAP: &str = r#"{ "objectClassName": "domain", "ldhName": "htpy.app", "events": [ {"eventAction": "registration", "eventDate": "2026-03-11T19:13:33.195Z"}, {"eventAction": "expiration", "eventDate": "2027-03-11T19:13:33.195Z"}, {"eventAction": "last changed", "eventDate": "2026-03-16T19:13:33.195Z"} ], "entities": [ { "roles": ["registrar"], "vcardArray": ["vcard", [ ["version", {}, "text", "4.0"], ["fn", {}, "text", "CloudFlare, Inc."] ]] } ], "nameservers": [ {"ldhName": "PAM.NS.CLOUDFLARE.COM."}, {"ldhName": "simon.ns.cloudflare.com"} ] }"#; #[test] fn parse_domain_extracts_expiry_registrar_nameservers() { let parsed = parse_domain(HTPY_RDAP).unwrap(); assert_eq!( parsed.expiry_date.as_deref(), Some("2027-03-11T19:13:33.195Z") ); assert_eq!(parsed.registrar.as_deref(), Some("CloudFlare, Inc.")); assert_eq!( parsed.nameservers, vec![ "pam.ns.cloudflare.com".to_string(), "simon.ns.cloudflare.com".to_string() ] ); } #[test] fn parse_domain_ignores_non_expiration_events() { // Pins the eventAction filter: registration comes first in the array, // so a missing filter would return the wrong date. let parsed = parse_domain(HTPY_RDAP).unwrap(); assert_ne!( parsed.expiry_date.as_deref(), Some("2026-03-11T19:13:33.195Z") ); } #[test] fn parse_domain_skips_entities_without_registrar_role() { let body = r#"{ "entities": [ {"roles": ["technical"], "vcardArray": ["vcard", [["fn", {}, "text", "Tech Co"]]]}, {"roles": ["registrar"], "vcardArray": ["vcard", [["fn", {}, "text", "Real Registrar"]]]} ] }"#; let parsed = parse_domain(body).unwrap(); assert_eq!(parsed.registrar.as_deref(), Some("Real Registrar")); } #[test] fn parse_domain_tolerates_missing_sections() { let parsed = parse_domain(r#"{"objectClassName": "domain"}"#).unwrap(); assert!(parsed.expiry_date.is_none()); assert!(parsed.registrar.is_none()); assert!(parsed.nameservers.is_empty()); } #[test] fn parse_domain_deduplicates_nameservers() { let body = r#"{"nameservers": [{"ldhName": "ns1.example.com."}, {"ldhName": "NS1.example.com"}]}"#; let parsed = parse_domain(body).unwrap(); assert_eq!(parsed.nameservers, vec!["ns1.example.com".to_string()]); } #[test] fn parse_domain_rejects_non_json() { assert!(parse_domain("not json at all").is_err()); } #[test] fn parse_bootstrap_flattens_services() { let body = r#"{ "version": "1.0", "services": [ [["app", "dev"], ["https://pubapi.registry.google/rdap/"]], [["work"], ["https://rdap.nic.work/"]] ] }"#; let bases = parse_bootstrap(body).unwrap(); assert_eq!( bases.get("app").map(String::as_str), Some("https://pubapi.registry.google/rdap/") ); assert_eq!( bases.get("dev").map(String::as_str), Some("https://pubapi.registry.google/rdap/") ); assert_eq!( bases.get("work").map(String::as_str), Some("https://rdap.nic.work/") ); } #[test] fn parse_bootstrap_prefers_https() { let body = r#"{"services": [[["test"], ["http://insecure.example/", "https://secure.example/"]]]}"#; let bases = parse_bootstrap(body).unwrap(); assert_eq!( bases.get("test").map(String::as_str), Some("https://secure.example/") ); } #[test] fn parse_bootstrap_skips_http_only_services() { let body = r#"{ "services": [ [["insecure"], ["http://only.example/"]], [["fine"], ["https://ok.example/"]] ] }"#; let bases = parse_bootstrap(body).unwrap(); assert!(!bases.contains_key("insecure")); assert!(bases.contains_key("fine")); } #[test] fn parse_bootstrap_lowercases_tlds() { let body = r#"{"services": [[["APP"], ["https://example/"]]]}"#; let bases = parse_bootstrap(body).unwrap(); assert!(bases.contains_key("app")); } #[test] fn parse_bootstrap_rejects_missing_services() { assert!(parse_bootstrap(r#"{"version": "1.0"}"#).is_err()); } #[test] fn parse_bootstrap_rejects_empty_result() { assert!(parse_bootstrap(r#"{"services": []}"#).is_err()); } #[test] fn fallback_covers_monitored_tlds() { // The point of the fallback table: an IANA outage must not blind the // check for a TLD we actually watch. for tld in ["app", "dev", "work", "com", "net", "org", "info"] { assert!( FALLBACK_BASES.iter().any(|(t, _)| *t == tld), "no fallback RDAP base for .{tld}" ); } } #[test] fn fallback_bases_are_https() { for (tld, base) in FALLBACK_BASES { assert!(base.starts_with("https://"), ".{tld} fallback is not HTTPS"); } } #[test] fn ensure_trailing_slash_is_idempotent() { assert_eq!(ensure_trailing_slash("https://x/rdap/"), "https://x/rdap/"); assert_eq!(ensure_trailing_slash("https://x/rdap"), "https://x/rdap/"); } }