179 lines
6.5 KiB
Python
Executable File
179 lines
6.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Generate a current-only AFRINIC IPv4 holdings constraints fixture.
|
|
|
|
The AFRINIC delegated extended format contains one IPv4 allocation/assignment
|
|
per line. This tool keeps only active registry holdings (``allocated`` and
|
|
``assigned``), computes their exact union, and emits an allow-all policy with
|
|
the union as IPv4 deny entries. ``reserved`` and ``available`` records are
|
|
intentionally excluded.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime as dt
|
|
import hashlib
|
|
import ipaddress
|
|
import pathlib
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from collections import Counter
|
|
from typing import Iterable
|
|
|
|
|
|
DEFAULT_SOURCE_URL = (
|
|
"https://ftp.afrinic.net/pub/stats/afrinic/"
|
|
"delegated-afrinic-extended-latest"
|
|
)
|
|
ACTIVE_STATUSES = {"allocated", "assigned"}
|
|
|
|
|
|
def merge_intervals(
|
|
intervals: Iterable[tuple[int, int]],
|
|
) -> list[tuple[int, int]]:
|
|
merged: list[tuple[int, int]] = []
|
|
for start, end in sorted(intervals):
|
|
if merged and start <= merged[-1][1] + 1:
|
|
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
|
else:
|
|
merged.append((start, end))
|
|
return merged
|
|
|
|
|
|
def parse_source(raw: bytes) -> tuple[list[tuple[int, int]], Counter[str], Counter[str]]:
|
|
status_counts: Counter[str] = Counter()
|
|
ipv4_status_counts: Counter[str] = Counter()
|
|
active: list[tuple[int, int]] = []
|
|
for line_number, raw_line in enumerate(raw.decode("utf-8", "replace").splitlines(), 1):
|
|
if not raw_line or raw_line.startswith("#"):
|
|
continue
|
|
fields = raw_line.split("|")
|
|
if len(fields) < 7 or fields[2] != "ipv4":
|
|
continue
|
|
status = fields[6]
|
|
status_counts[status] += 1
|
|
ipv4_status_counts[status] += 1
|
|
if status not in ACTIVE_STATUSES:
|
|
continue
|
|
try:
|
|
start = int(ipaddress.IPv4Address(fields[3]))
|
|
count = int(fields[4])
|
|
except (ValueError, TypeError) as error:
|
|
raise ValueError(f"line {line_number}: invalid IPv4 record: {raw_line}") from error
|
|
if count <= 0 or start + count > 1 << 32:
|
|
raise ValueError(f"line {line_number}: invalid IPv4 range: {raw_line}")
|
|
active.append((start, start + count - 1))
|
|
return active, status_counts, ipv4_status_counts
|
|
|
|
|
|
def render(
|
|
source_url: str,
|
|
retrieved: str,
|
|
source_sha256: str,
|
|
source_ipv4_count: int,
|
|
status_counts: Counter[str],
|
|
active_source: list[tuple[int, int]],
|
|
) -> tuple[str, dict[str, int]]:
|
|
merged = merge_intervals(active_source)
|
|
networks = [
|
|
network
|
|
for start, end in merged
|
|
for network in ipaddress.summarize_address_range(
|
|
ipaddress.IPv4Address(start), ipaddress.IPv4Address(end)
|
|
)
|
|
]
|
|
networks.sort(key=lambda network: int(network.network_address))
|
|
active_addresses = sum(end - start + 1 for start, end in merged)
|
|
counts = {
|
|
"source_ipv4_records": source_ipv4_count,
|
|
"allocated": status_counts["allocated"],
|
|
"assigned": status_counts["assigned"],
|
|
"reserved": status_counts["reserved"],
|
|
"available": status_counts["available"],
|
|
"selected_source_records": len(active_source),
|
|
"selected_addresses": active_addresses,
|
|
"merged_intervals": len(merged),
|
|
"cidr_rules": len(networks),
|
|
}
|
|
lines = [
|
|
"# Feature #151 M7 current AFRINIC IPv4 holdings deny fixture.",
|
|
"#",
|
|
"# Source: AFRINIC delegated extended latest.",
|
|
f"# URL: {source_url}",
|
|
f"# Retrieved: {retrieved}",
|
|
f"# Source SHA-256: {source_sha256}",
|
|
"#",
|
|
"# Selection: IPv4 records with status allocated or assigned only.",
|
|
"# Excluded statuses: reserved and available (not current registry holdings).",
|
|
(
|
|
"# Source IPv4 records: {source_ipv4_records}; allocated={allocated}; "
|
|
"assigned={assigned}; reserved={reserved}; available={available}."
|
|
).format(**counts),
|
|
"# Selected source records: {selected_source_records}; selected addresses: {selected_addresses}.".format(
|
|
**counts
|
|
),
|
|
"# Overlap/adjacency union intervals: {merged_intervals}; exact CIDR deny rules: {cidr_rules}.".format(
|
|
**counts
|
|
),
|
|
"# IPv6 and ASN remain allow-all to isolate the current AFRINIC IPv4 policy.",
|
|
"allow 0.0.0.0/0",
|
|
"allow ::/0",
|
|
"allow 0 - 4294967295",
|
|
"",
|
|
]
|
|
lines.extend(f"deny {network}" for network in networks)
|
|
return "\n".join(lines) + "\n", counts
|
|
|
|
|
|
def fetch_source(url: str, timeout: float) -> bytes:
|
|
request = urllib.request.Request(
|
|
url,
|
|
headers={"User-Agent": "rpki-dev-feature151-afrinic-holdings/1.0"},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
return response.read()
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
source = parser.add_mutually_exclusive_group()
|
|
source.add_argument("--url", default=DEFAULT_SOURCE_URL, help="AFRINIC source URL")
|
|
source.add_argument("--input", type=pathlib.Path, help="local delegated file for replay")
|
|
parser.add_argument("--output", type=pathlib.Path, required=True)
|
|
parser.add_argument("--retrieved", help="UTC date recorded in the fixture header")
|
|
parser.add_argument("--timeout", type=float, default=60.0)
|
|
args = parser.parse_args()
|
|
|
|
source_url = args.url if args.input is None else f"file://{args.input.resolve()}"
|
|
raw = args.input.read_bytes() if args.input is not None else fetch_source(args.url, args.timeout)
|
|
retrieved = args.retrieved or dt.datetime.now(dt.timezone.utc).date().isoformat()
|
|
active, status_counts, ipv4_status_counts = parse_source(raw)
|
|
rendered, counts = render(
|
|
source_url,
|
|
retrieved,
|
|
hashlib.sha256(raw).hexdigest(),
|
|
sum(ipv4_status_counts.values()),
|
|
status_counts,
|
|
active,
|
|
)
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(rendered, encoding="utf-8")
|
|
print(
|
|
"generated {output}: {cidr_rules} CIDRs from {selected_source_records} active "
|
|
"records ({selected_addresses} addresses); source_sha256={sha256}".format(
|
|
output=args.output,
|
|
sha256=hashlib.sha256(raw).hexdigest(),
|
|
**counts,
|
|
)
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except (OSError, ValueError, urllib.error.URLError) as error:
|
|
print(f"error: {error}", file=sys.stderr)
|
|
raise SystemExit(2)
|