use std::net::IpAddr;
use std::sync::Arc;
use std::time::Duration;
use crate::bootstrap::{Bootstrap, DNS_URL, IPV4_URL, IPV6_URL, Registry};
use crate::contact::{Contact, Scope};
use crate::destination::{self, Destinations, PublicResolver};
use crate::error::Error;
use crate::query::{DomainName, Query};
use crate::rdap::Response;
const TIMEOUT: Duration = Duration::from_secs(30);
const USER_AGENT: &str = concat!("abuse-contact/", env!("CARGO_PKG_VERSION"));
const RDAP_MEDIA_TYPE: &str = "application/rdap+json";
pub const MAX_RECORD_BYTES: usize = 1024 * 1024;
pub const MAX_BOOTSTRAP_BYTES: usize = 4 * 1024 * 1024;
#[derive(Clone, Debug)]
pub struct Client {
http: reqwest::Client,
bootstrap: Bootstrap,
destinations: Destinations,
}
impl Client {
pub async fn new() -> Result<Self, Error> {
let destinations = Destinations::Public;
let http = Self::http_client(destinations)?;
let bootstrap = Bootstrap {
ipv4: fetch_registry(&http, IPV4_URL).await?,
ipv6: fetch_registry(&http, IPV6_URL).await?,
dns: fetch_registry(&http, DNS_URL).await?,
};
Ok(Self {
http,
bootstrap,
destinations,
})
}
pub fn with_bootstrap(bootstrap: Bootstrap, destinations: Destinations) -> Result<Self, Error> {
Ok(Self {
http: Self::http_client(destinations)?,
bootstrap,
destinations,
})
}
pub fn bootstrap(&self) -> &Bootstrap {
&self.bootstrap
}
pub async fn lookup(&self, query: impl Into<Query>) -> Result<Option<Record>, Error> {
match query.into() {
Query::Ip(ip) => self.lookup_ip(ip).await,
Query::Domain(domain) => self.lookup_domain(&domain).await,
}
}
pub async fn lookup_ip(&self, ip: IpAddr) -> Result<Option<Record>, Error> {
let ip = crate::query::unmap(ip);
let target = ip.to_string();
if !crate::is_public(ip) {
return Err(Error::NotPublic { target });
}
let server = self
.bootstrap
.server_for_ip(ip)
.ok_or_else(|| Error::NoServer {
target: target.clone(),
})?;
self.fetch(&record_url(server, "ip", &target), &target)
.await
}
pub async fn lookup_domain(&self, domain: &DomainName) -> Result<Option<Record>, Error> {
let target = domain.as_str().to_owned();
let server = self
.bootstrap
.server_for_domain(domain)
.ok_or_else(|| Error::NoServer {
target: target.clone(),
})?;
self.fetch(&record_url(server, "domain", &target), &target)
.await
}
pub async fn fetch(&self, url: &str, target: &str) -> Result<Option<Record>, Error> {
let parsed = reqwest::Url::parse(url).map_err(|problem| Error::Refused {
server: url.to_owned(),
reason: format!("it is not a URL: {problem}"),
})?;
destination::check_url(&parsed, self.destinations).map_err(|refusal| Error::Refused {
server: url.to_owned(),
reason: refusal.reason,
})?;
let answer = self
.http
.get(parsed)
.header(reqwest::header::ACCEPT, RDAP_MEDIA_TYPE)
.send()
.await
.map_err(|source| request_error(url, source))?;
let url_answered = answer.url().clone();
let status = answer.status();
if status == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
if !status.is_success() {
return Err(Error::Status {
server: url.to_owned(),
status: status.as_u16(),
target: target.to_owned(),
});
}
let body = read_capped(answer, url, MAX_RECORD_BYTES).await?;
let response = serde_json::from_slice(&body).map_err(|source| Error::Decode {
server: url.to_owned(),
source,
})?;
Ok(Some(Record {
response,
server: server_of(&url_answered),
url: url_answered.into(),
}))
}
fn http_client(destinations: Destinations) -> Result<reqwest::Client, Error> {
let mut builder = reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(TIMEOUT)
.redirect(destination::redirect_policy(destinations));
if destinations == Destinations::Public {
builder = builder.dns_resolver(Arc::new(PublicResolver)).no_proxy();
}
builder.build().map_err(|source| Error::Transport {
server: "the HTTP client".to_owned(),
source: Box::new(source),
})
}
}
#[derive(Clone, Debug)]
pub struct Record {
pub response: Response,
pub server: String,
pub url: String,
}
impl Record {
pub fn abuse_contacts(&self, scope: Scope) -> Vec<Contact> {
self.response.abuse_contacts(scope, &self.server)
}
}
fn server_of(url: &reqwest::Url) -> String {
let host = url.host_str().unwrap_or_default();
match url.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_owned(),
}
}
fn record_url(server: &str, kind: &str, target: &str) -> String {
format!("{}/{kind}/{target}", server.trim_end_matches('/'))
}
fn request_error(url: &str, source: reqwest::Error) -> Error {
match destination::refusal_in(&source) {
Some(refusal) => Error::Refused {
server: url.to_owned(),
reason: refusal.reason.clone(),
},
None => Error::Transport {
server: url.to_owned(),
source: Box::new(source),
},
}
}
async fn fetch_registry(http: &reqwest::Client, url: &str) -> Result<Registry, Error> {
let answer = http
.get(url)
.send()
.await
.and_then(reqwest::Response::error_for_status)
.map_err(|source| request_error(url, source))?;
let body = read_capped(answer, url, MAX_BOOTSTRAP_BYTES).await?;
Registry::from_slice(&body).map_err(|source| Error::Decode {
server: url.to_owned(),
source,
})
}
async fn read_capped(
mut answer: reqwest::Response,
url: &str,
limit: usize,
) -> Result<Vec<u8>, Error> {
let too_large = || Error::TooLarge {
server: url.to_owned(),
limit,
};
if answer
.content_length()
.is_some_and(|length| length > limit as u64)
{
return Err(too_large());
}
let mut body = Vec::new();
while let Some(chunk) = answer.chunk().await.map_err(|source| Error::Transport {
server: url.to_owned(),
source: Box::new(source),
})? {
if body.len() + chunk.len() > limit {
return Err(too_large());
}
body.extend_from_slice(&chunk);
}
Ok(body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn joins_a_base_url_that_ends_with_a_slash() {
assert_eq!(
record_url("https://rdap.arin.net/registry/", "ip", "8.8.8.8"),
"https://rdap.arin.net/registry/ip/8.8.8.8"
);
}
#[test]
fn joins_a_base_url_that_does_not_end_with_a_slash() {
assert_eq!(
record_url("https://rdap.example.net", "domain", "example.com"),
"https://rdap.example.net/domain/example.com"
);
}
#[test]
fn the_server_of_a_url_is_its_host() {
let url = |value: &str| reqwest::Url::parse(value).unwrap();
assert_eq!(
server_of(&url("https://rdap.arin.net/registry/ip/8.8.8.8")),
"rdap.arin.net"
);
assert_eq!(
server_of(&url("https://rdap.arin.net:443/registry/")),
"rdap.arin.net"
);
assert_eq!(
server_of(&url("http://127.0.0.1:8080/ip/8.8.8.8")),
"127.0.0.1:8080"
);
assert_eq!(
server_of(&url("https://[2001:db8::1]:8443/")),
"[2001:db8::1]:8443"
);
}
#[test]
fn names_the_crate_and_its_version_to_a_registry() {
assert!(USER_AGENT.starts_with("abuse-contact/"));
}
}