Skip to main content

seer_core/rdap/
mod.rs

1//! RDAP client for domain, IP and ASN lookups.
2//!
3//! [`RdapClient`] finds the authoritative server through the IANA bootstrap
4//! registries (cached 24h; once loaded, an expired dataset is served while one
5//! background task refreshes it), tries each candidate base
6//! URL in turn, and retries transient failures through [`crate::retry`],
7//! honoring a capped `Retry-After` on HTTP 429. Every server host passes the
8//! SSRF guard before a request is sent. [`classify`] and [`auto_lookup`] route
9//! a free-form query to the right lookup.
10
11mod bootstrap;
12mod client;
13mod types;
14
15pub use client::RdapClient;
16// Shared with `seer doctor`'s bootstrap probe (CLI-only).
17#[cfg(feature = "cli")]
18pub(crate) use client::{IANA_BOOTSTRAP_DNS, MAX_BOOTSTRAP_SIZE};
19pub use types::{ContactInfo, RdapResponse, RegistrarDetail};
20
21use std::net::IpAddr;
22
23use crate::error::{Result, SeerError};
24
25/// Classified RDAP routing decision for a free-form query string.
26///
27/// Separated from [`auto_lookup`] so the routing rules can be covered by
28/// pure unit tests that do not require network or an [`RdapClient`].
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum RdapRoute {
31    /// Query was an IP literal (v4 or v6).
32    Ip(IpAddr),
33    /// Query matched the `AS<digits>` form with no embedded dots.
34    Asn(u32),
35    /// Anything else — dispatched to domain lookup.
36    Domain(String),
37}
38
39/// Classifies a free-form RDAP query into an [`RdapRoute`].
40///
41/// Rules (applied in order):
42///
43/// 1. If `query` parses as an [`IpAddr`], route to [`RdapRoute::Ip`].
44/// 2. If `query` (case-insensitive) starts with `AS`, the remainder is all
45///    ASCII digits, and the trimmed query contains no `.`, route to
46///    [`RdapRoute::Asn`].
47/// 3. Otherwise, route to [`RdapRoute::Domain`].
48///
49/// The `contains('.')` guard is the load-bearing check that prevents real
50/// domains like `as1234.io` from being misclassified as an ASN query — a
51/// regression that existed while RDAP auto-routing lived in the Python
52/// `rdap()` wrapper.
53pub fn classify(query: &str) -> Result<RdapRoute> {
54    let trimmed = query.trim();
55    if trimmed.is_empty() {
56        return Err(SeerError::InvalidInput("empty RDAP query".to_string()));
57    }
58
59    // 1) IP literal (v4 or v6).
60    if let Ok(ip) = trimmed.parse::<IpAddr>() {
61        return Ok(RdapRoute::Ip(ip));
62    }
63
64    // 2) ASN: "AS<digits>" with no '.' anywhere in the trimmed query.
65    //    The no-dot check prevents misrouting real domains that start with
66    //    "AS" (e.g. as1234.io).
67    let upper = trimmed.to_uppercase();
68    if let Some(rest) = upper.strip_prefix("AS") {
69        if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) && !trimmed.contains('.') {
70            let asn: u32 = rest
71                .parse()
72                .map_err(|_| SeerError::InvalidInput(format!("invalid ASN: {query}")))?;
73            return Ok(RdapRoute::Asn(asn));
74        }
75    }
76
77    // 3) Fallback: domain lookup.
78    Ok(RdapRoute::Domain(trimmed.to_string()))
79}
80
81/// Auto-routing RDAP lookup.
82///
83/// Classifies `query` with [`classify`] and dispatches to the appropriate
84/// method on the supplied [`RdapClient`]. This replaces the previous
85/// Python-side dispatcher, which used `int()`-style sniffing and would
86/// silently misroute `AS`-prefixed domains like `as1234.io` to the ASN
87/// endpoint.
88pub async fn auto_lookup(client: &RdapClient, query: &str) -> Result<RdapResponse> {
89    match classify(query)? {
90        RdapRoute::Ip(_ip) => client.lookup_ip(query.trim()).await,
91        RdapRoute::Asn(asn) => client.lookup_asn(asn).await,
92        RdapRoute::Domain(domain) => client.lookup_domain(&domain).await,
93    }
94}
95
96/// Returns true if `err` is an RDAP HTTP 404 — the registry's RDAP server
97/// authoritatively reports no such object. For a domain query this is the
98/// strongest available-ness signal there is. Every other RDAP failure
99/// (timeout, 5xx, 429, bootstrap/connection error) means "we don't know",
100/// NOT "available", and must not match.
101///
102/// Matches the message produced by `client::query_rdap_attempt`
103/// (`"query failed with status 404 ..."`), looking through both the
104/// `RetryExhausted` wrapper and the multi-candidate "all N ... failed; last
105/// error: ..." wrapper, each of which preserves the original 404 marker.
106pub fn rdap_error_is_404(err: &SeerError) -> bool {
107    match err {
108        SeerError::RdapError(msg) => msg.contains("query failed with status 404"),
109        SeerError::RetryExhausted { last_error, .. } => rdap_error_is_404(last_error),
110        _ => false,
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn classifies_ipv4() {
120        assert!(matches!(classify("8.8.8.8").unwrap(), RdapRoute::Ip(_)));
121        assert!(matches!(classify("1.1.1.1").unwrap(), RdapRoute::Ip(_)));
122    }
123
124    #[test]
125    fn classifies_ipv6() {
126        assert!(matches!(
127            classify("2606:4700:4700::1111").unwrap(),
128            RdapRoute::Ip(_)
129        ));
130    }
131
132    #[test]
133    fn classifies_asn_upper() {
134        assert_eq!(classify("AS1234").unwrap(), RdapRoute::Asn(1234));
135    }
136
137    #[test]
138    fn classifies_asn_lower() {
139        assert_eq!(classify("as1234").unwrap(), RdapRoute::Asn(1234));
140    }
141
142    #[test]
143    fn classifies_asn_mixed_case() {
144        assert_eq!(classify("As15169").unwrap(), RdapRoute::Asn(15169));
145    }
146
147    #[test]
148    fn as_prefix_domain_routes_to_domain() {
149        // Regression: as1234.io is a real domain, not ASN. The `.` in the
150        // query must force the domain route.
151        assert_eq!(
152            classify("as1234.io").unwrap(),
153            RdapRoute::Domain("as1234.io".to_string())
154        );
155        assert_eq!(
156            classify("AS1234.IO").unwrap(),
157            RdapRoute::Domain("AS1234.IO".to_string())
158        );
159    }
160
161    #[test]
162    fn asn_with_trailing_junk_routes_to_domain() {
163        // "AS1234x" is not digits-only after the prefix; fall through to domain.
164        assert_eq!(
165            classify("AS1234x").unwrap(),
166            RdapRoute::Domain("AS1234x".to_string())
167        );
168    }
169
170    #[test]
171    fn bare_as_routes_to_domain() {
172        // "AS" alone has an empty digit tail; not an ASN.
173        assert_eq!(classify("AS").unwrap(), RdapRoute::Domain("AS".to_string()));
174    }
175
176    #[test]
177    fn normal_domain_routes_to_domain() {
178        assert_eq!(
179            classify("example.com").unwrap(),
180            RdapRoute::Domain("example.com".to_string())
181        );
182    }
183
184    #[test]
185    fn trims_whitespace() {
186        assert_eq!(classify("  AS64500  ").unwrap(), RdapRoute::Asn(64500));
187        assert_eq!(
188            classify("  example.com  ").unwrap(),
189            RdapRoute::Domain("example.com".to_string())
190        );
191    }
192
193    #[test]
194    fn empty_query_errors() {
195        assert!(matches!(
196            classify("").unwrap_err(),
197            SeerError::InvalidInput(_)
198        ));
199        assert!(matches!(
200            classify("   ").unwrap_err(),
201            SeerError::InvalidInput(_)
202        ));
203    }
204
205    #[test]
206    fn asn_overflow_errors() {
207        // u32::MAX is 4294967295; anything larger must error.
208        assert!(matches!(
209            classify("AS99999999999999").unwrap_err(),
210            SeerError::InvalidInput(_)
211        ));
212    }
213
214    // --- rdap_error_is_404 -------------------------------------------------
215
216    #[test]
217    fn rdap_error_is_404_matches_canonical_marker() {
218        assert!(rdap_error_is_404(&SeerError::RdapError(
219            "query failed with status 404 Not Found".into()
220        )));
221        assert!(rdap_error_is_404(&SeerError::RdapError(
222            "query failed with status 404".into()
223        )));
224    }
225
226    #[test]
227    fn rdap_error_is_404_rejects_other_statuses_and_kinds() {
228        assert!(!rdap_error_is_404(&SeerError::RdapError(
229            "query failed with status 500 Server Error".into()
230        )));
231        assert!(!rdap_error_is_404(&SeerError::RdapError(
232            "query failed with status 429 Too Many Requests".into()
233        )));
234        assert!(!rdap_error_is_404(&SeerError::RdapBootstrapError(
235            "no RDAP server for example.hk".into()
236        )));
237        assert!(!rdap_error_is_404(&SeerError::Timeout("rdap".into())));
238    }
239
240    #[test]
241    fn rdap_error_is_404_rejects_incidental_digits() {
242        // A "404" buried in an unrelated numeric context must not match.
243        assert!(!rdap_error_is_404(&SeerError::RdapError(
244            "error 40404: database corruption".into()
245        )));
246    }
247
248    #[test]
249    fn rdap_error_is_404_looks_through_retry_exhausted() {
250        let inner = SeerError::RdapError("query failed with status 404 Not Found".into());
251        let wrapped = SeerError::RetryExhausted {
252            attempts: 2,
253            last_error: Box::new(inner),
254        };
255        assert!(rdap_error_is_404(&wrapped));
256    }
257
258    #[test]
259    fn rdap_error_is_404_matches_multi_candidate_wrapper() {
260        // wrap_all_candidates_failed embeds the original marker in its message.
261        let err = SeerError::RdapError(
262            "all 2 RDAP candidate URLs failed; last error: query failed with status 404 Not Found"
263                .into(),
264        );
265        assert!(rdap_error_is_404(&err));
266    }
267}