use crate::error::{RdapError, Result};
use crate::request::{QueryType, RdapRequest};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::IpAddr;
use url::Url;
const IANA_BOOTSTRAP_URL: &str = "https://data.iana.org/rdap/";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BootstrapRegistry {
version: String,
publication: Option<String>,
description: Option<String>,
services: Vec<Vec<serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ObjectTagsRegistry {
version: String,
publication: Option<String>,
description: Option<String>,
services: Vec<Vec<serde_json::Value>>,
}
pub struct BootstrapClient {
http_client: reqwest::Client,
base_url: Url,
}
impl BootstrapClient {
pub fn new() -> Result<Self> {
Ok(Self {
http_client: reqwest::Client::new(),
base_url: Url::parse(IANA_BOOTSTRAP_URL)?,
})
}
pub async fn lookup(&self, request: &RdapRequest) -> Result<Vec<Url>> {
let registry_file = match request.query_type {
QueryType::Domain => "dns.json",
QueryType::Ip => {
if request.query.contains(':') {
"ipv6.json"
} else {
"ipv4.json"
}
}
QueryType::Autnum => "asn.json",
QueryType::Entity => {
return self.lookup_entity_by_tag(&request.query).await;
}
_ => {
return Err(RdapError::Bootstrap(
"This query type requires explicit server (-s/--server)".to_string(),
));
}
};
let registry = self.fetch_registry(registry_file).await?;
let urls = self.match_registry(®istry, request)?;
Ok(urls)
}
async fn fetch_registry(&self, filename: &str) -> Result<BootstrapRegistry> {
let url = self.base_url.join(filename)?;
log::debug!("Fetching bootstrap registry: {}", url);
let response = self.http_client.get(url.as_str()).send().await?;
if !response.status().is_success() {
return Err(RdapError::Bootstrap(format!(
"Failed to fetch registry: HTTP {}",
response.status()
)));
}
let registry: BootstrapRegistry = response.json().await?;
Ok(registry)
}
fn match_registry(
&self,
registry: &BootstrapRegistry,
request: &RdapRequest,
) -> Result<Vec<Url>> {
match request.query_type {
QueryType::Domain => self.match_domain(registry, &request.query),
QueryType::Ip => self.match_ip(registry, &request.query),
QueryType::Autnum => self.match_asn(registry, &request.query),
_ => Err(RdapError::Bootstrap("Unsupported query type".to_string())),
}
}
async fn lookup_entity_by_tag(&self, handle: &str) -> Result<Vec<Url>> {
let tag = extract_entity_tag(handle).ok_or_else(|| {
RdapError::Bootstrap(format!(
"Cannot extract object tag from entity handle '{}'. \
Entity handles should end with a provider tag (e.g. XXXX-ARIN, ORG-XX-RIPE). \
Use -s/--server to specify the RDAP server explicitly.",
handle
))
})?;
let registry = self.fetch_object_tags_registry().await?;
self.match_object_tag(®istry, &tag)
}
async fn fetch_object_tags_registry(&self) -> Result<ObjectTagsRegistry> {
let url = self.base_url.join("object-tags.json")?;
log::debug!("Fetching object tags registry: {}", url);
let response = self.http_client.get(url.as_str()).send().await?;
if !response.status().is_success() {
return Err(RdapError::Bootstrap(format!(
"Failed to fetch object tags registry: HTTP {}",
response.status()
)));
}
let registry: ObjectTagsRegistry = response.json().await?;
Ok(registry)
}
fn match_object_tag(&self, registry: &ObjectTagsRegistry, tag: &str) -> Result<Vec<Url>> {
for service in ®istry.services {
if service.len() >= 3
&& let (Some(tags), Some(urls)) = (service[1].as_array(), service[2].as_array())
{
for tag_entry in tags {
if let Some(registry_tag) = tag_entry.as_str()
&& registry_tag.eq_ignore_ascii_case(tag)
{
let url_list: Vec<Url> = urls
.iter()
.filter_map(|v| v.as_str().and_then(|s| Url::parse(s).ok()))
.collect();
if !url_list.is_empty() {
return Ok(url_list);
}
}
}
}
}
Err(RdapError::Bootstrap(format!(
"No RDAP server found for object tag '{}'. \
Use -s/--server to specify the RDAP server explicitly.",
tag
)))
}
fn match_domain(&self, registry: &BootstrapRegistry, domain: &str) -> Result<Vec<Url>> {
let domain = domain.trim_end_matches('.').to_lowercase();
let mut map: HashMap<String, Vec<String>> = HashMap::new();
for service in ®istry.services {
if service.len() >= 2
&& let (Some(entries), Some(urls)) = (service[0].as_array(), service[1].as_array())
{
let url_strings: Vec<String> = urls
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
for entry in entries {
if let Some(tld) = entry.as_str() {
map.insert(tld.to_lowercase(), url_strings.clone());
}
}
}
}
let mut parts: Vec<&str> = domain.split('.').collect();
while !parts.is_empty() {
let test_domain = parts.join(".");
if let Some(urls) = map.get(&test_domain) {
return Ok(urls.iter().filter_map(|s| Url::parse(s).ok()).collect());
}
parts.remove(0);
}
Ok(vec![])
}
fn match_ip(&self, registry: &BootstrapRegistry, ip: &str) -> Result<Vec<Url>> {
let addr: IpAddr = ip
.parse()
.map_err(|_| RdapError::InvalidQuery(format!("Invalid IP address: {}", ip)))?;
for service in ®istry.services {
if service.len() >= 2
&& let (Some(entries), Some(urls)) = (service[0].as_array(), service[1].as_array())
{
for entry in entries {
if let Some(cidr) = entry.as_str()
&& self.ip_in_range(&addr, cidr)
{
let url_list: Vec<Url> = urls
.iter()
.filter_map(|v| v.as_str().and_then(|s| Url::parse(s).ok()))
.collect();
return Ok(url_list);
}
}
}
}
Ok(vec![])
}
fn ip_in_range(&self, addr: &IpAddr, cidr: &str) -> bool {
if let Some(slash_pos) = cidr.find('/') {
let ip_part = &cidr[..slash_pos];
let prefix_len: u8 = cidr[slash_pos + 1..].parse().unwrap_or(0);
if let Ok(network_addr) = ip_part.parse::<IpAddr>() {
return match (network_addr, addr) {
(IpAddr::V4(net), IpAddr::V4(addr)) => {
if prefix_len > 32 {
return false;
}
let net_int = u32::from_be_bytes(net.octets());
let addr_int = u32::from_be_bytes(addr.octets());
let mask = if prefix_len == 0 {
0
} else {
!0u32 << (32 - prefix_len)
};
(net_int & mask) == (addr_int & mask)
}
(IpAddr::V6(net), IpAddr::V6(addr)) => {
if prefix_len > 128 {
return false;
}
let net_int = u128::from_be_bytes(net.octets());
let addr_int = u128::from_be_bytes(addr.octets());
let mask = if prefix_len == 0 {
0
} else {
!0u128 << (128 - prefix_len)
};
(net_int & mask) == (addr_int & mask)
}
_ => false, };
}
}
false
}
fn match_asn(&self, registry: &BootstrapRegistry, asn_str: &str) -> Result<Vec<Url>> {
let asn_str = asn_str.trim_start_matches("AS").trim_start_matches("as");
let asn: u32 = asn_str
.parse()
.map_err(|_| RdapError::InvalidQuery(format!("Invalid AS number: {}", asn_str)))?;
for service in ®istry.services {
if service.len() >= 2
&& let (Some(entries), Some(urls)) = (service[0].as_array(), service[1].as_array())
{
for entry in entries {
if let Some(range_str) = entry.as_str()
&& self.asn_in_range(asn, range_str)
{
let url_list: Vec<Url> = urls
.iter()
.filter_map(|v| v.as_str().and_then(|s| Url::parse(s).ok()))
.collect();
return Ok(url_list);
}
}
}
}
Ok(vec![])
}
fn asn_in_range(&self, asn: u32, range_str: &str) -> bool {
if let Some(dash_pos) = range_str.find('-') {
if let (Ok(start), Ok(end)) = (
range_str[..dash_pos].parse::<u32>(),
range_str[dash_pos + 1..].parse::<u32>(),
) {
return asn >= start && asn <= end;
}
} else {
if let Ok(single) = range_str.parse::<u32>() {
return asn == single;
}
}
false
}
}
pub fn extract_entity_tag(handle: &str) -> Option<String> {
let handle = handle.trim();
if let Some(last_hyphen) = handle.rfind('-') {
let tag = &handle[last_hyphen + 1..];
if !tag.is_empty() && tag.chars().all(|c| c.is_ascii_alphanumeric()) {
return Some(tag.to_uppercase());
}
}
None
}
pub const KNOWN_ENTITY_TAGS: &[&str] = &[
"ARIN", "RIPE", "APNIC", "LACNIC", "AFRINIC", "FRNIC", "GLAUCA", "NORID",
];
pub fn rir_to_rdap_url(name: &str) -> Option<Url> {
let url_str = match name.to_uppercase().as_str() {
"RIPE" => "https://rdap.db.ripe.net/",
"ARIN" => "https://rdap.arin.net/registry/",
"APNIC" => "https://rdap.apnic.net/",
"LACNIC" => "https://rdap.lacnic.net/rdap/",
"AFRINIC" => "https://rdap.afrinic.net/rdap/",
"FRNIC" => "https://rdap.nic.fr/",
"GLAUCA" => "https://whois-web.as207960.net/rdap/",
"NORID" => "https://rdap.norid.no/",
_ => return None,
};
Url::parse(url_str).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_entity_tag_ripe() {
assert_eq!(
extract_entity_tag("ORG-LA1994-RIPE"),
Some("RIPE".to_string())
);
}
#[test]
fn test_extract_entity_tag_arin() {
assert_eq!(extract_entity_tag("YANGY12-ARIN"), Some("ARIN".to_string()));
}
#[test]
fn test_extract_entity_tag_apnic() {
assert_eq!(extract_entity_tag("IRT-APNIC-AP"), Some("AP".to_string()));
}
#[test]
fn test_extract_entity_tag_multi_hyphen() {
assert_eq!(
extract_entity_tag("RIPE-NCC-END-MNT"),
Some("MNT".to_string())
);
}
#[test]
fn test_extract_entity_tag_no_hyphen() {
assert_eq!(extract_entity_tag("SOMEHANDLE"), None);
}
#[test]
fn test_extract_entity_tag_trailing_hyphen() {
assert_eq!(extract_entity_tag("HANDLE-"), None);
}
#[test]
fn test_extract_entity_tag_lowercase() {
assert_eq!(
extract_entity_tag("org-la1994-ripe"),
Some("RIPE".to_string())
);
}
#[test]
fn test_match_object_tag() {
let client = BootstrapClient::new().unwrap();
let registry = ObjectTagsRegistry {
version: "1.0".to_string(),
publication: None,
description: None,
services: vec![vec![
serde_json::json!(["contact@example.com"]),
serde_json::json!(["ARIN"]),
serde_json::json!(["https://rdap.arin.net/registry/"]),
]],
};
let result = client.match_object_tag(®istry, "ARIN");
assert!(result.is_ok());
let urls = result.unwrap();
assert_eq!(urls.len(), 1);
assert_eq!(urls[0].as_str(), "https://rdap.arin.net/registry/");
}
#[test]
fn test_match_object_tag_case_insensitive() {
let client = BootstrapClient::new().unwrap();
let registry = ObjectTagsRegistry {
version: "1.0".to_string(),
publication: None,
description: None,
services: vec![vec![
serde_json::json!(["contact@ripe.net"]),
serde_json::json!(["RIPE"]),
serde_json::json!(["https://rdap.db.ripe.net/"]),
]],
};
let result = client.match_object_tag(®istry, "ripe");
assert!(result.is_ok());
}
#[test]
fn test_match_object_tag_not_found() {
let client = BootstrapClient::new().unwrap();
let registry = ObjectTagsRegistry {
version: "1.0".to_string(),
publication: None,
description: None,
services: vec![],
};
let result = client.match_object_tag(®istry, "UNKNOWN");
assert!(result.is_err());
}
#[test]
fn test_rir_to_rdap_url_known() {
assert_eq!(
rir_to_rdap_url("ripe").unwrap().as_str(),
"https://rdap.db.ripe.net/"
);
assert_eq!(
rir_to_rdap_url("ARIN").unwrap().as_str(),
"https://rdap.arin.net/registry/"
);
assert_eq!(
rir_to_rdap_url("apnic").unwrap().as_str(),
"https://rdap.apnic.net/"
);
assert_eq!(
rir_to_rdap_url("LACNIC").unwrap().as_str(),
"https://rdap.lacnic.net/rdap/"
);
assert_eq!(
rir_to_rdap_url("afrinic").unwrap().as_str(),
"https://rdap.afrinic.net/rdap/"
);
}
#[test]
fn test_rir_to_rdap_url_unknown() {
assert!(rir_to_rdap_url("unknown").is_none());
assert!(rir_to_rdap_url("").is_none());
}
}