| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
use std::collections::HashMap; |
| 15 |
use std::sync::{Arc, RwLock}; |
| 16 |
use std::time::{Duration, Instant}; |
| 17 |
|
| 18 |
|
| 19 |
const BOOTSTRAP_URL: &str = "https://data.iana.org/rdap/dns.json"; |
| 20 |
|
| 21 |
|
| 22 |
|
| 23 |
const BOOTSTRAP_TTL: Duration = Duration::from_hours(24); |
| 24 |
|
| 25 |
|
| 26 |
|
| 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 |
|
| 45 |
|
| 46 |
pub struct RdapDomain { |
| 47 |
pub registrar: Option<String>, |
| 48 |
pub expiry_date: Option<String>, |
| 49 |
pub nameservers: Vec<String>, |
| 50 |
} |
| 51 |
|
| 52 |
|
| 53 |
|
| 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 |
|
| 65 |
|
| 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 |
|
| 82 |
|
| 83 |
|
| 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 |
|
| 134 |
|
| 135 |
|
| 136 |
|
| 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 |
|
| 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 |
|
| 263 |
|
| 264 |
|
| 265 |
|
| 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 |
|
| 326 |
|
| 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 |
|
| 434 |
|
| 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 |
|