aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--abusectl/rdap.py52
-rw-r--r--tests/test_rdap.py51
2 files changed, 103 insertions, 0 deletions
diff --git a/abusectl/rdap.py b/abusectl/rdap.py
index 92466b5..fc9cb12 100644
--- a/abusectl/rdap.py
+++ b/abusectl/rdap.py
@@ -129,3 +129,55 @@ def bootstrap(registry: str, cache_root=None, fetch=http_fetch) -> dict:
directory.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data))
return data
+
+
+import ipaddress
+
+
+def server_for_ip(address: str, bootstrap_data: dict) -> str | None:
+ """Return the RDAP base URL for an address, by longest prefix.
+
+ Longest prefix rather than first match: a block delegated to a new
+ operator appears as a more specific range inside its parent, and the
+ wider one would name the operator that gave it away.
+ """
+ try:
+ ip = ipaddress.ip_address(address)
+ except ValueError:
+ return None
+
+ best_length = -1
+ best_url = None
+
+ for entry in bootstrap_data.get("services", []):
+ ranges, urls = entry[0], entry[1]
+ if not urls:
+ continue
+ for cidr in ranges:
+ try:
+ network = ipaddress.ip_network(cidr, strict=False)
+ except ValueError:
+ continue
+ if ip.version != network.version or ip not in network:
+ continue
+ if network.prefixlen > best_length:
+ best_length = network.prefixlen
+ best_url = urls[0]
+
+ return best_url
+
+
+def server_for_tld(tld: str, bootstrap_data: dict) -> str | None:
+ """Return the RDAP base URL for a TLD, or None when none is published.
+
+ Many TLDs publish no RDAP service at all, and that is a normal
+ outcome rather than a defect.
+ """
+ wanted = tld.lower().strip(".")
+ for entry in bootstrap_data.get("services", []):
+ names, urls = entry[0], entry[1]
+ if not urls:
+ continue
+ if any(name.lower() == wanted for name in names):
+ return urls[0]
+ return None
diff --git a/tests/test_rdap.py b/tests/test_rdap.py
index 3fc2c65..14a1fd4 100644
--- a/tests/test_rdap.py
+++ b/tests/test_rdap.py
@@ -125,5 +125,56 @@ class Bootstrap(unittest.TestCase):
rdap.bootstrap("ipv4", cache_root=self.cache, fetch=fetch)
+class ServerSelection(unittest.TestCase):
+ IPV4 = {
+ "services": [
+ [["192.0.2.0/24"], ["https://wide.example.invalid/"]],
+ [["192.0.2.128/25"], ["https://narrow.example.invalid/"]],
+ [["198.51.100.0/24"], ["https://other.example.invalid/"]],
+ ]
+ }
+ DNS = {
+ "services": [
+ [["invalid"], ["https://registry.example.invalid/"]],
+ [["test"], ["https://test.example.invalid/"]],
+ ]
+ }
+
+ def test_an_address_selects_its_range(self):
+ self.assertEqual(
+ rdap.server_for_ip("198.51.100.7", self.IPV4),
+ "https://other.example.invalid/",
+ )
+
+ def test_the_longest_prefix_wins(self):
+ """192.0.2.200 is in both /24 and /25; the /25 is more specific.
+
+ Choosing the wider range would ask a registry that has delegated
+ the block away, and its answer would name the wrong operator.
+ """
+ self.assertEqual(
+ rdap.server_for_ip("192.0.2.200", self.IPV4),
+ "https://narrow.example.invalid/",
+ )
+
+ def test_an_unlisted_address_selects_nothing(self):
+ self.assertIsNone(rdap.server_for_ip("203.0.113.9", self.IPV4))
+
+ def test_a_tld_selects_its_registry(self):
+ self.assertEqual(
+ rdap.server_for_tld("invalid", self.DNS),
+ "https://registry.example.invalid/",
+ )
+
+ def test_tld_matching_ignores_case(self):
+ self.assertEqual(
+ rdap.server_for_tld("INVALID", self.DNS),
+ "https://registry.example.invalid/",
+ )
+
+ def test_an_unlisted_tld_selects_nothing(self):
+ self.assertIsNone(rdap.server_for_tld("example", self.DNS))
+
+
if __name__ == "__main__":
unittest.main()