Skip to main content

seer_core/rdap/
client.rs

1use std::collections::HashMap;
2use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use once_cell::sync::Lazy;
7use reqwest::Client;
8use serde::Deserialize;
9use tokio::sync::RwLock;
10use tracing::{debug, instrument, warn};
11
12use super::types::RdapResponse;
13use crate::error::{Result, SeerError};
14use crate::retry::{RetryExecutor, RetryPolicy};
15use crate::validation::normalize_domain;
16
17const IANA_BOOTSTRAP_DNS: &str = "https://data.iana.org/rdap/dns.json";
18const IANA_BOOTSTRAP_IPV4: &str = "https://data.iana.org/rdap/ipv4.json";
19const IANA_BOOTSTRAP_IPV6: &str = "https://data.iana.org/rdap/ipv6.json";
20const IANA_BOOTSTRAP_ASN: &str = "https://data.iana.org/rdap/asn.json";
21
22/// Default timeout for RDAP queries (30 seconds).
23/// RDAP servers can be slow, especially during bootstrap loading which fetches
24/// from 4 IANA registries. Some regional registries also have high latency.
25const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
26
27/// TTL for bootstrap data (24 hours)
28const BOOTSTRAP_TTL: Duration = Duration::from_secs(24 * 60 * 60);
29
30/// Shared HTTP client for all RDAP operations (bootstrap + queries).
31/// Reusing a single Client enables connection pooling across requests.
32static RDAP_HTTP_CLIENT: Lazy<Client> = Lazy::new(|| {
33    Client::builder()
34        .timeout(DEFAULT_TIMEOUT)
35        .user_agent("Seer/1.0 (RDAP Client)")
36        .pool_max_idle_per_host(10)
37        .build()
38        .expect("Failed to build RDAP HTTP client - invalid configuration")
39});
40
41/// Bootstrap cache with TTL support
42static BOOTSTRAP_CACHE: Lazy<RwLock<Option<CachedBootstrap>>> = Lazy::new(|| RwLock::new(None));
43
44/// Cached bootstrap data with timestamp for TTL tracking
45struct CachedBootstrap {
46    data: BootstrapData,
47    loaded_at: Instant,
48}
49
50impl CachedBootstrap {
51    fn new(data: BootstrapData) -> Self {
52        Self {
53            data,
54            loaded_at: Instant::now(),
55        }
56    }
57
58    fn is_expired(&self) -> bool {
59        self.loaded_at.elapsed() > BOOTSTRAP_TTL
60    }
61
62    fn age(&self) -> Duration {
63        self.loaded_at.elapsed()
64    }
65}
66
67/// Parsed IANA bootstrap data.
68/// Uses Arc<str> for URL strings to reduce cloning overhead when multiple
69/// TLDs/prefixes share the same RDAP server URL.
70struct BootstrapData {
71    dns: HashMap<String, Arc<str>>,
72    ipv4: Vec<(IpRange, Arc<str>)>,
73    ipv6: Vec<(IpRange, Arc<str>)>,
74    asn: Vec<(AsnRange, Arc<str>)>,
75}
76
77#[derive(Clone)]
78struct IpRange {
79    prefix: String,
80}
81
82#[derive(Clone)]
83struct AsnRange {
84    start: u32,
85    end: u32,
86}
87
88#[derive(Deserialize)]
89struct BootstrapResponse {
90    services: Vec<Vec<serde_json::Value>>,
91}
92
93#[derive(Debug, Clone)]
94pub struct RdapClient {
95    retry_policy: RetryPolicy,
96}
97
98impl Default for RdapClient {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104impl RdapClient {
105    /// Creates a new RDAP client with default settings.
106    pub fn new() -> Self {
107        Self {
108            retry_policy: RetryPolicy::default(),
109        }
110    }
111
112    /// Sets the retry policy for transient network failures.
113    ///
114    /// The default policy retries up to 3 times with exponential backoff.
115    pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
116        self.retry_policy = policy;
117        self
118    }
119
120    /// Disables retries (single attempt only).
121    pub fn without_retries(mut self) -> Self {
122        self.retry_policy = RetryPolicy::no_retry();
123        self
124    }
125
126    /// Ensures bootstrap data is loaded and not expired.
127    /// Uses stale-while-revalidate: if refresh fails, stale data is used.
128    async fn ensure_bootstrap(&self) -> Result<()> {
129        // Check if we have valid (non-expired) data
130        {
131            let cache = BOOTSTRAP_CACHE.read().await;
132            if let Some(cached) = cache.as_ref() {
133                if !cached.is_expired() {
134                    return Ok(());
135                }
136            }
137        }
138
139        // Need to load or refresh - acquire write lock
140        let mut cache = BOOTSTRAP_CACHE.write().await;
141
142        // Double-check after acquiring write lock (another task may have loaded)
143        if let Some(cached) = cache.as_ref() {
144            if !cached.is_expired() {
145                return Ok(());
146            }
147        }
148
149        // Try to load fresh data
150        debug!("Loading/refreshing RDAP bootstrap data");
151        match load_bootstrap_data_with_retry(&self.retry_policy).await {
152            Ok(data) => {
153                debug!(
154                    dns_entries = data.dns.len(),
155                    ipv4_entries = data.ipv4.len(),
156                    ipv6_entries = data.ipv6.len(),
157                    asn_entries = data.asn.len(),
158                    "RDAP bootstrap loaded/refreshed"
159                );
160                *cache = Some(CachedBootstrap::new(data));
161                Ok(())
162            }
163            Err(e) => {
164                // Stale-while-revalidate: use stale data if refresh fails
165                if let Some(cached) = cache.as_ref() {
166                    warn!(
167                        error = %e,
168                        age_hours = cached.age().as_secs() / 3600,
169                        "Bootstrap refresh failed, using stale data"
170                    );
171                    Ok(())
172                } else {
173                    // No stale data available
174                    Err(e)
175                }
176            }
177        }
178    }
179
180    /// Looks up the RDAP server URL for a domain's TLD from bootstrap data.
181    fn get_rdap_url_for_domain(cache: &BootstrapData, domain: &str) -> Option<Arc<str>> {
182        let tld = domain.rsplit('.').next()?;
183        cache.dns.get(&tld.to_lowercase()).cloned()
184    }
185
186    /// Looks up the RDAP server URL for an IP address from bootstrap data.
187    fn get_rdap_url_for_ip(cache: &BootstrapData, ip: &IpAddr) -> Option<Arc<str>> {
188        match ip {
189            IpAddr::V4(addr) => {
190                for (range, url) in &cache.ipv4 {
191                    if ipv4_matches_prefix(&range.prefix, addr) {
192                        return Some(Arc::clone(url));
193                    }
194                }
195            }
196            IpAddr::V6(addr) => {
197                for (range, url) in &cache.ipv6 {
198                    if ipv6_matches_prefix(&range.prefix, addr) {
199                        return Some(Arc::clone(url));
200                    }
201                }
202            }
203        }
204
205        None
206    }
207
208    /// Looks up the RDAP server URL for an ASN from bootstrap data.
209    fn get_rdap_url_for_asn(cache: &BootstrapData, asn: u32) -> Option<Arc<str>> {
210        for (range, url) in &cache.asn {
211            if asn >= range.start && asn <= range.end {
212                return Some(Arc::clone(url));
213            }
214        }
215
216        None
217    }
218
219    /// Looks up RDAP registration data for a domain.
220    ///
221    /// Uses IANA bootstrap data to find the appropriate RDAP server for the TLD.
222    #[instrument(skip(self), fields(domain = %domain))]
223    pub async fn lookup_domain(&self, domain: &str) -> Result<RdapResponse> {
224        self.ensure_bootstrap().await?;
225
226        let domain = normalize_domain(domain)?;
227
228        // Extract URL while holding the lock, then release before HTTP request
229        let url = {
230            let cache_guard = BOOTSTRAP_CACHE.read().await;
231            let cache = cache_guard.as_ref().ok_or_else(|| {
232                SeerError::RdapBootstrapError("bootstrap data not loaded".to_string())
233            })?;
234
235            let base_url =
236                Self::get_rdap_url_for_domain(&cache.data, &domain).ok_or_else(|| {
237                    SeerError::RdapBootstrapError(format!("no RDAP server for {}", domain))
238                })?;
239
240            build_rdap_url(&base_url, &format!("domain/{}", domain))
241        }; // Lock released here
242
243        debug!(url = %url, "Querying RDAP");
244        self.query_rdap_with_retry(&url).await
245    }
246
247    /// Looks up RDAP registration data for an IP address.
248    ///
249    /// Uses IANA bootstrap data to find the appropriate RIR (Regional Internet Registry).
250    #[instrument(skip(self), fields(ip = %ip))]
251    pub async fn lookup_ip(&self, ip: &str) -> Result<RdapResponse> {
252        self.ensure_bootstrap().await?;
253
254        let ip_addr: IpAddr = ip
255            .parse()
256            .map_err(|_| SeerError::InvalidIpAddress(ip.to_string()))?;
257
258        // Extract URL while holding the lock, then release before HTTP request
259        let url = {
260            let cache_guard = BOOTSTRAP_CACHE.read().await;
261            let cache = cache_guard.as_ref().ok_or_else(|| {
262                SeerError::RdapBootstrapError("bootstrap data not loaded".to_string())
263            })?;
264
265            let base_url = Self::get_rdap_url_for_ip(&cache.data, &ip_addr).ok_or_else(|| {
266                SeerError::RdapBootstrapError(format!("no RDAP server for {}", ip))
267            })?;
268
269            build_rdap_url(&base_url, &format!("ip/{}", ip))
270        }; // Lock released here
271
272        debug!(url = %url, "Querying RDAP");
273        self.query_rdap_with_retry(&url).await
274    }
275
276    /// Looks up RDAP registration data for an Autonomous System Number (ASN).
277    ///
278    /// Uses IANA bootstrap data to find the appropriate RIR for the ASN range.
279    #[instrument(skip(self), fields(asn = %asn))]
280    pub async fn lookup_asn(&self, asn: u32) -> Result<RdapResponse> {
281        self.ensure_bootstrap().await?;
282
283        // Extract URL while holding the lock, then release before HTTP request
284        let url = {
285            let cache_guard = BOOTSTRAP_CACHE.read().await;
286            let cache = cache_guard.as_ref().ok_or_else(|| {
287                SeerError::RdapBootstrapError("bootstrap data not loaded".to_string())
288            })?;
289
290            let base_url = Self::get_rdap_url_for_asn(&cache.data, asn).ok_or_else(|| {
291                SeerError::RdapBootstrapError(format!("no RDAP server for AS{}", asn))
292            })?;
293
294            build_rdap_url(&base_url, &format!("autnum/{}", asn))
295        }; // Lock released here
296
297        debug!(url = %url, "Querying RDAP");
298        self.query_rdap_with_retry(&url).await
299    }
300
301    /// Queries an RDAP endpoint with retry logic.
302    async fn query_rdap_with_retry(&self, url: &str) -> Result<RdapResponse> {
303        let executor = RetryExecutor::new(self.retry_policy.clone());
304        let url = url.to_string();
305
306        executor
307            .execute(|| {
308                let http = RDAP_HTTP_CLIENT.clone();
309                let url = url.clone();
310                async move { query_rdap_internal(&http, &url).await }
311            })
312            .await
313    }
314}
315
316/// Internal function to query an RDAP endpoint (used by retry executor).
317async fn query_rdap_internal(http: &Client, url: &str) -> Result<RdapResponse> {
318    let response = http
319        .get(url)
320        .header("Accept", "application/rdap+json")
321        .send()
322        .await?;
323
324    if !response.status().is_success() {
325        return Err(SeerError::RdapError(format!(
326            "query failed with status {}",
327            response.status()
328        )));
329    }
330
331    let rdap: RdapResponse = response.json().await?;
332    Ok(rdap)
333}
334
335/// Loads IANA RDAP bootstrap data from all registries with retry.
336async fn load_bootstrap_data_with_retry(policy: &RetryPolicy) -> Result<BootstrapData> {
337    let executor = RetryExecutor::new(policy.clone());
338    executor.execute(load_bootstrap_data).await
339}
340
341/// Loads IANA RDAP bootstrap data from all registries.
342async fn load_bootstrap_data() -> Result<BootstrapData> {
343    debug!("Loading RDAP bootstrap data from IANA");
344
345    let http = &*RDAP_HTTP_CLIENT;
346
347    let dns_future = http.get(IANA_BOOTSTRAP_DNS).send();
348    let ipv4_future = http.get(IANA_BOOTSTRAP_IPV4).send();
349    let ipv6_future = http.get(IANA_BOOTSTRAP_IPV6).send();
350    let asn_future = http.get(IANA_BOOTSTRAP_ASN).send();
351
352    let (dns_resp, ipv4_resp, ipv6_resp, asn_resp) =
353        tokio::try_join!(dns_future, ipv4_future, ipv6_future, asn_future)?;
354
355    let dns_data: BootstrapResponse = dns_resp.json().await?;
356    let ipv4_data: BootstrapResponse = ipv4_resp.json().await?;
357    let ipv6_data: BootstrapResponse = ipv6_resp.json().await?;
358    let asn_data: BootstrapResponse = asn_resp.json().await?;
359
360    let mut dns = HashMap::new();
361    let mut ipv4 = Vec::new();
362    let mut ipv6 = Vec::new();
363    let mut asn = Vec::new();
364
365    // Parse DNS bootstrap
366    for service in dns_data.services {
367        if service.len() >= 2 {
368            if let (Some(tlds), Some(urls)) = (service[0].as_array(), service[1].as_array()) {
369                if let Some(url) = urls.first().and_then(|u| u.as_str()) {
370                    let url_arc: Arc<str> = Arc::from(url);
371                    for tld in tlds {
372                        if let Some(tld_str) = tld.as_str() {
373                            dns.insert(tld_str.to_lowercase(), Arc::clone(&url_arc));
374                        }
375                    }
376                }
377            }
378        }
379    }
380
381    // Parse IPv4 bootstrap
382    for service in ipv4_data.services {
383        if service.len() >= 2 {
384            if let (Some(prefixes), Some(urls)) = (service[0].as_array(), service[1].as_array()) {
385                if let Some(url) = urls.first().and_then(|u| u.as_str()) {
386                    let url_arc: Arc<str> = Arc::from(url);
387                    for prefix in prefixes {
388                        if let Some(prefix_str) = prefix.as_str() {
389                            ipv4.push((
390                                IpRange {
391                                    prefix: prefix_str.to_string(),
392                                },
393                                Arc::clone(&url_arc),
394                            ));
395                        }
396                    }
397                }
398            }
399        }
400    }
401
402    // Parse IPv6 bootstrap
403    for service in ipv6_data.services {
404        if service.len() >= 2 {
405            if let (Some(prefixes), Some(urls)) = (service[0].as_array(), service[1].as_array()) {
406                if let Some(url) = urls.first().and_then(|u| u.as_str()) {
407                    let url_arc: Arc<str> = Arc::from(url);
408                    for prefix in prefixes {
409                        if let Some(prefix_str) = prefix.as_str() {
410                            ipv6.push((
411                                IpRange {
412                                    prefix: prefix_str.to_string(),
413                                },
414                                Arc::clone(&url_arc),
415                            ));
416                        }
417                    }
418                }
419            }
420        }
421    }
422
423    // Parse ASN bootstrap
424    for service in asn_data.services {
425        if service.len() >= 2 {
426            if let (Some(ranges), Some(urls)) = (service[0].as_array(), service[1].as_array()) {
427                if let Some(url) = urls.first().and_then(|u| u.as_str()) {
428                    let url_arc: Arc<str> = Arc::from(url);
429                    for range in ranges {
430                        if let Some(range_str) = range.as_str() {
431                            if let Some((start, end)) = parse_asn_range(range_str) {
432                                asn.push((AsnRange { start, end }, Arc::clone(&url_arc)));
433                            }
434                        }
435                    }
436                }
437            }
438        }
439    }
440
441    Ok(BootstrapData {
442        dns,
443        ipv4,
444        ipv6,
445        asn,
446    })
447}
448
449/// Builds a full RDAP query URL from a base URL and path.
450fn build_rdap_url(base_url: &str, path: &str) -> String {
451    if base_url.ends_with('/') {
452        format!("{}{}", base_url, path)
453    } else {
454        format!("{}/{}", base_url, path)
455    }
456}
457
458fn parse_asn_range(range: &str) -> Option<(u32, u32)> {
459    if let Some(pos) = range.find('-') {
460        let start = range[..pos].parse().ok()?;
461        let end = range[pos + 1..].parse().ok()?;
462        Some((start, end))
463    } else {
464        let num = range.parse().ok()?;
465        Some((num, num))
466    }
467}
468
469fn ipv4_matches_prefix(prefix: &str, ip: &Ipv4Addr) -> bool {
470    let (addr_part, mask_part) = match prefix.split_once('/') {
471        Some((a, m)) => (a, Some(m)),
472        None => (prefix, None),
473    };
474
475    let prefix_ip: Ipv4Addr = match addr_part.parse() {
476        Ok(ip) => ip,
477        Err(_) => return false,
478    };
479
480    let mask_bits: u32 = match mask_part.and_then(|s| s.parse().ok()) {
481        Some(bits) if bits <= 32 => bits,
482        Some(_) => return false,
483        None => 32,
484    };
485
486    let mask = if mask_bits == 0 {
487        0
488    } else {
489        u32::MAX << (32 - mask_bits)
490    };
491
492    let ip_value = u32::from(*ip);
493    let prefix_value = u32::from(prefix_ip);
494
495    (ip_value & mask) == (prefix_value & mask)
496}
497
498fn ipv6_matches_prefix(prefix: &str, ip: &Ipv6Addr) -> bool {
499    let (addr_part, mask_part) = match prefix.split_once('/') {
500        Some((a, m)) => (a, Some(m)),
501        None => (prefix, None),
502    };
503
504    let prefix_ip: Ipv6Addr = match addr_part.parse() {
505        Ok(ip) => ip,
506        Err(_) => return false,
507    };
508
509    let mask_bits: u32 = match mask_part.and_then(|s| s.parse().ok()) {
510        Some(bits) if bits <= 128 => bits,
511        Some(_) => return false,
512        None => 128,
513    };
514
515    let mask = if mask_bits == 0 {
516        0u128
517    } else {
518        u128::MAX << (128 - mask_bits)
519    };
520
521    let ip_value = ipv6_to_u128(ip);
522    let prefix_value = ipv6_to_u128(&prefix_ip);
523
524    (ip_value & mask) == (prefix_value & mask)
525}
526
527fn ipv6_to_u128(ip: &Ipv6Addr) -> u128 {
528    let segments = ip.segments();
529    let mut value = 0u128;
530    for segment in segments {
531        value = (value << 16) | segment as u128;
532    }
533    value
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    #[test]
541    fn test_default_client_has_retry_policy() {
542        let client = RdapClient::new();
543        assert_eq!(client.retry_policy.max_attempts, 3);
544    }
545
546    #[test]
547    fn test_client_without_retries() {
548        let client = RdapClient::new().without_retries();
549        assert_eq!(client.retry_policy.max_attempts, 1);
550    }
551
552    #[test]
553    fn test_client_custom_retry_policy() {
554        let policy = RetryPolicy::new().with_max_attempts(5);
555        let client = RdapClient::new().with_retry_policy(policy);
556        assert_eq!(client.retry_policy.max_attempts, 5);
557    }
558
559    #[test]
560    fn test_cached_bootstrap_expiration() {
561        let data = BootstrapData {
562            dns: HashMap::new(),
563            ipv4: Vec::new(),
564            ipv6: Vec::new(),
565            asn: Vec::new(),
566        };
567        let cached = CachedBootstrap::new(data);
568        // Fresh cache should not be expired
569        assert!(!cached.is_expired());
570    }
571
572    #[test]
573    fn test_ipv4_prefix_matching_partial_mask() {
574        let ip_in = Ipv4Addr::new(203, 0, 114, 1);
575        let ip_out = Ipv4Addr::new(203, 0, 120, 1);
576        assert!(ipv4_matches_prefix("203.0.112.0/21", &ip_in));
577        assert!(!ipv4_matches_prefix("203.0.112.0/21", &ip_out));
578    }
579
580    #[test]
581    fn test_ipv6_prefix_matching_partial_mask() {
582        let ip_in: Ipv6Addr = "2001:db8::1".parse().unwrap();
583        let ip_out: Ipv6Addr = "2001:db9::1".parse().unwrap();
584        assert!(ipv6_matches_prefix("2001:db8::/33", &ip_in));
585        assert!(!ipv6_matches_prefix("2001:db8::/33", &ip_out));
586    }
587}