monovm-whois-rust 1.0.0

Domain WHOIS and RDAP lookups with availability detection, structured record parsing, referral chasing, caching and rate limiting.
Documentation
//! RDAP over HTTP: the protocol that was meant to replace WHOIS.
//!
//! Two things make RDAP worth preferring where a registry offers it. The answer
//! is JSON with a defined schema (RFC 9083), so a record can be parsed rather
//! than guessed at; and "no such domain" is an HTTP 404 with a machine-readable
//! `errorCode`, so availability is a fact instead of an inference from wording.
//!
//! That is why a 404 is not treated as a failure here: its body is the answer,
//! and it is handed to the detector like any other. A 429 or a 5xx *is* a
//! failure, because returning that body would let an error page be read as a
//! free domain.

use std::time::Instant;

use crate::error::{Error, Refusal, Result};
use crate::registry::Endpoint;
use crate::transport::{Query, RawResponse, ResponseKind, TransportConfig};

/// Sent so registries can see who is querying, as their terms of service ask.
const USER_AGENT: &str = concat!(
    "monovm-whois/",
    env!("CARGO_PKG_VERSION"),
    " (+https://github.com/monovm/whois-rs)"
);

/// The media type RFC 7480 defines for RDAP.
const RDAP_MEDIA_TYPE: &str = "application/rdap+json, application/json;q=0.9, */*;q=0.1";

/// Statuses that mean "I will not answer you", as distinct from 404's
/// "the thing you asked about does not exist".
fn refusal_for(status: u16) -> Option<Refusal> {
    match status {
        429 => Some(Refusal::RateLimited),
        401 | 403 => Some(Refusal::AccessRestricted),
        451 => Some(Refusal::Blocked),
        503 => Some(Refusal::Unavailable),
        _ => None,
    }
}

/// Whether the body of a non-success response is still an RDAP answer.
///
/// 404 is the documented way to say a domain is not registered, and 422 is what
/// a few registries return for a name they consider syntactically invalid; both
/// carry a body worth reading. Everything else is noise.
fn is_answer(status: u16) -> bool {
    matches!(status, 200..=299 | 404 | 422)
}

/// A blocking RDAP client.
#[cfg(all(feature = "rdap", feature = "blocking"))]
#[derive(Debug, Clone)]
pub struct RdapTransport {
    client: reqwest::blocking::Client,
    config: TransportConfig,
}

#[cfg(all(feature = "rdap", feature = "blocking"))]
impl RdapTransport {
    /// A client with the default timeouts.
    pub fn new() -> Result<Self> {
        RdapTransport::with_config(TransportConfig::default())
    }

    /// A client with explicit timeouts.
    pub fn with_config(config: TransportConfig) -> Result<Self> {
        let client = build_blocking_client(&config)?;
        Ok(RdapTransport { client, config })
    }

    /// Wrap an already-configured `reqwest` client.
    ///
    /// For callers who need a proxy, a custom certificate store, or connection
    /// pooling shared with the rest of their application.
    pub fn with_client(client: reqwest::blocking::Client, config: TransportConfig) -> Self {
        RdapTransport { client, config }
    }

    /// The timeouts in force.
    pub fn config(&self) -> &TransportConfig {
        &self.config
    }
}

#[cfg(all(feature = "rdap", feature = "blocking"))]
impl crate::transport::Transport for RdapTransport {
    fn supports(&self, endpoint: &Endpoint) -> bool {
        endpoint.is_rdap()
    }

    fn fetch(&self, query: &Query) -> Result<RawResponse> {
        let Endpoint::Rdap(endpoint) = &query.endpoint else {
            return Err(Error::Definitions(format!(
                "{} is not an RDAP endpoint",
                query.endpoint
            )));
        };

        let url = endpoint.query_url(&query.wire_name);
        let started = Instant::now();

        let response = self
            .client
            .get(&url)
            .header(reqwest::header::ACCEPT, RDAP_MEDIA_TYPE)
            .send()
            .map_err(|error| classify_reqwest(&url, error, started))?;

        let status = response.status().as_u16();
        if let Some(reason) = refusal_for(status) {
            return Err(Error::Refused {
                server: url,
                reason,
            });
        }
        if !is_answer(status) {
            return Err(Error::Http { url, status });
        }

        let body = response.text().map_err(|error| Error::Io {
            server: url.clone(),
            source: std::io::Error::other(error),
        })?;

        finish(query, body, status, started, &url)
    }

    fn name(&self) -> String {
        "rdap".to_string()
    }
}

/// A Tokio-based RDAP client.
#[cfg(all(feature = "rdap", feature = "async"))]
#[derive(Debug, Clone)]
pub struct AsyncRdapTransport {
    client: reqwest::Client,
    config: TransportConfig,
}

#[cfg(all(feature = "rdap", feature = "async"))]
impl AsyncRdapTransport {
    /// A client with the default timeouts.
    pub fn new() -> Result<Self> {
        AsyncRdapTransport::with_config(TransportConfig::default())
    }

    /// A client with explicit timeouts.
    pub fn with_config(config: TransportConfig) -> Result<Self> {
        let client = reqwest::Client::builder()
            .user_agent(USER_AGENT)
            .connect_timeout(config.connect_timeout)
            .timeout(config.connect_timeout + config.read_timeout)
            // RDAP redirects between registry and registrar services are common
            // and safe to follow; the cap stops a misconfigured chain looping.
            .redirect(reqwest::redirect::Policy::limited(5))
            .build()
            .map_err(|error| Error::Definitions(format!("could not build HTTP client: {error}")))?;

        Ok(AsyncRdapTransport { client, config })
    }

    /// Wrap an already-configured `reqwest` client.
    pub fn with_client(client: reqwest::Client, config: TransportConfig) -> Self {
        AsyncRdapTransport { client, config }
    }

    /// The timeouts in force.
    pub fn config(&self) -> &TransportConfig {
        &self.config
    }

    async fn fetch_inner(&self, query: &Query) -> Result<RawResponse> {
        let Endpoint::Rdap(endpoint) = &query.endpoint else {
            return Err(Error::Definitions(format!(
                "{} is not an RDAP endpoint",
                query.endpoint
            )));
        };

        let url = endpoint.query_url(&query.wire_name);
        let started = Instant::now();

        let response = self
            .client
            .get(&url)
            .header(reqwest::header::ACCEPT, RDAP_MEDIA_TYPE)
            .send()
            .await
            .map_err(|error| classify_reqwest(&url, error, started))?;

        let status = response.status().as_u16();
        if let Some(reason) = refusal_for(status) {
            return Err(Error::Refused {
                server: url,
                reason,
            });
        }
        if !is_answer(status) {
            return Err(Error::Http { url, status });
        }

        let body = response.text().await.map_err(|error| Error::Io {
            server: url.clone(),
            source: std::io::Error::other(error),
        })?;

        finish(query, body, status, started, &url)
    }
}

#[cfg(all(feature = "rdap", feature = "async"))]
impl crate::transport::AsyncTransport for AsyncRdapTransport {
    fn supports(&self, endpoint: &Endpoint) -> bool {
        endpoint.is_rdap()
    }

    fn fetch<'a>(
        &'a self,
        query: &'a Query,
    ) -> crate::transport::BoxFuture<'a, Result<RawResponse>> {
        Box::pin(self.fetch_inner(query))
    }

    fn name(&self) -> String {
        "async-rdap".to_string()
    }
}

#[cfg(all(feature = "rdap", feature = "blocking"))]
fn build_blocking_client(config: &TransportConfig) -> Result<reqwest::blocking::Client> {
    reqwest::blocking::Client::builder()
        .user_agent(USER_AGENT)
        .connect_timeout(config.connect_timeout)
        .timeout(config.connect_timeout + config.read_timeout)
        .redirect(reqwest::redirect::Policy::limited(5))
        .build()
        .map_err(|error| Error::Definitions(format!("could not build HTTP client: {error}")))
}

#[cfg(feature = "rdap")]
fn classify_reqwest(url: &str, error: reqwest::Error, started: Instant) -> Error {
    if error.is_timeout() {
        return Error::Timeout {
            server: url.to_string(),
            elapsed: started.elapsed(),
        };
    }
    if error.is_connect() {
        return Error::Connect {
            server: url.to_string(),
            source: std::io::Error::other(error),
        };
    }

    Error::Io {
        server: url.to_string(),
        source: std::io::Error::other(error),
    }
}

#[cfg(feature = "rdap")]
fn finish(
    query: &Query,
    body: String,
    status: u16,
    started: Instant,
    url: &str,
) -> Result<RawResponse> {
    // A 404 with no body still carries its meaning in the status line, so
    // synthesise something the detector can match rather than erroring out.
    let text = if body.trim().is_empty() {
        if status == 404 {
            r#"{"errorCode":404,"title":"Not Found"}"#.to_string()
        } else {
            return Err(Error::EmptyResponse {
                server: url.to_string(),
            });
        }
    } else {
        body
    };

    Ok(RawResponse::new(
        query.endpoint.clone(),
        ResponseKind::RdapJson,
        text,
        started.elapsed(),
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn refusals_are_separated_from_answers() {
        assert_eq!(refusal_for(429), Some(Refusal::RateLimited));
        assert_eq!(refusal_for(403), Some(Refusal::AccessRestricted));
        assert_eq!(refusal_for(503), Some(Refusal::Unavailable));
        assert_eq!(refusal_for(404), None);
        assert_eq!(refusal_for(200), None);
    }

    #[test]
    fn only_meaningful_statuses_count_as_answers() {
        assert!(is_answer(200));
        // RDAP's way of saying the domain does not exist.
        assert!(is_answer(404));
        assert!(is_answer(422));
        assert!(!is_answer(500));
        assert!(!is_answer(429));
        assert!(!is_answer(301));
    }

    #[test]
    fn an_empty_404_still_produces_a_readable_body() {
        let query = Query::new(
            Endpoint::rdap("https://rdap.example/"),
            "example.com",
            crate::domain::Tld::parse("com").unwrap(),
        );
        let response = finish(&query, String::new(), 404, Instant::now(), "url").unwrap();

        assert_eq!(response.kind(), ResponseKind::RdapJson);
        assert!(response.text().contains("\"errorCode\":404"));
    }

    #[test]
    fn an_empty_success_is_an_error() {
        let query = Query::new(
            Endpoint::rdap("https://rdap.example/"),
            "example.com",
            crate::domain::Tld::parse("com").unwrap(),
        );
        let error = finish(&query, "  ".into(), 200, Instant::now(), "url").unwrap_err();
        assert!(matches!(error, Error::EmptyResponse { .. }));
    }
}