|
| 1 | +import ipaddress |
| 2 | +import xml.etree.ElementTree as ET |
| 3 | +from xml.etree.ElementTree import Element, SubElement |
| 4 | + |
| 5 | +import httpx |
| 6 | + |
| 7 | +from rir_updater.config import ROA, RouteObject |
| 8 | +from rir_updater.exceptions import ApiError |
| 9 | + |
| 10 | +PROD_BASE = "https://reg.arin.net/rest" |
| 11 | +OTE_BASE = "https://reg.ote.arin.net/rest" |
| 12 | + |
| 13 | +# XML namespace for IRR route objects |
| 14 | +CORE_NS = "http://www.arin.net/regrws/core/v1" |
| 15 | +# XML namespace for RPKI ROA objects |
| 16 | +RPKI_NS = "http://www.arin.net/regrws/rpki/v1" |
| 17 | + |
| 18 | +ET.register_namespace("", CORE_NS) |
| 19 | + |
| 20 | + |
| 21 | +def _find_text(element: ET.Element, local_name: str) -> str | None: |
| 22 | + """Find a child element by local name, ignoring namespace prefixes.""" |
| 23 | + for child in element: |
| 24 | + tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag |
| 25 | + if tag == local_name: |
| 26 | + return child.text |
| 27 | + return None |
| 28 | + |
| 29 | + |
| 30 | +def _raise_for_status(resp: httpx.Response, context: str) -> None: |
| 31 | + if resp.is_error: |
| 32 | + try: |
| 33 | + root = ET.fromstring(resp.text) |
| 34 | + detail = _find_text(root, "message") or resp.text[:200] |
| 35 | + except ET.ParseError: |
| 36 | + detail = resp.text[:200] or "(empty body)" |
| 37 | + raise ApiError(f"{context} failed ({resp.status_code}): {detail}") |
| 38 | + |
| 39 | + |
| 40 | +class ArinClient: |
| 41 | + """Client for the ARIN IRR REST API and RPKI Management API. |
| 42 | +
|
| 43 | + All requests are authenticated with an API key passed as a `?apikey=` |
| 44 | + query parameter. Request and response bodies use XML. |
| 45 | + """ |
| 46 | + |
| 47 | + def __init__( |
| 48 | + self, |
| 49 | + org_handle: str, |
| 50 | + api_key: str, |
| 51 | + dry_run: bool = False, |
| 52 | + use_test_env: bool = True, |
| 53 | + ): |
| 54 | + self._org_handle = org_handle |
| 55 | + self._api_key = api_key |
| 56 | + self._dry_run = dry_run |
| 57 | + self._base = OTE_BASE if use_test_env else PROD_BASE |
| 58 | + self._http = httpx.Client( |
| 59 | + headers={ |
| 60 | + "Accept": "application/xml", |
| 61 | + "Content-Type": "application/xml", |
| 62 | + }, |
| 63 | + timeout=30, |
| 64 | + ) |
| 65 | + |
| 66 | + def close(self) -> None: |
| 67 | + self._http.close() |
| 68 | + |
| 69 | + def __enter__(self): |
| 70 | + return self |
| 71 | + |
| 72 | + def __exit__(self, *_): |
| 73 | + self.close() |
| 74 | + |
| 75 | + def _params(self) -> dict: |
| 76 | + return {"apikey": self._api_key} |
| 77 | + |
| 78 | + # --- Route objects --- |
| 79 | + |
| 80 | + def _route_url(self, route: RouteObject) -> str: |
| 81 | + # ARIN uses /irr/route/ for both IPv4 and IPv6; the prefix distinguishes them. |
| 82 | + # Key URL segments: network/prefix_len/asn (same pattern as RADb). |
| 83 | + network, prefix_len = route.prefix.split("/") |
| 84 | + asn = route.origin.upper() |
| 85 | + return f"{self._base}/irr/route/{network}/{prefix_len}/{asn}" |
| 86 | + |
| 87 | + def _route_body(self, route: RouteObject) -> str: |
| 88 | + root = Element(f"{{{CORE_NS}}}route") |
| 89 | + SubElement(root, f"{{{CORE_NS}}}orgHandle").text = self._org_handle |
| 90 | + SubElement(root, f"{{{CORE_NS}}}originAS").text = route.origin.upper() |
| 91 | + SubElement(root, f"{{{CORE_NS}}}prefix").text = route.prefix |
| 92 | + if route.description: |
| 93 | + SubElement(root, f"{{{CORE_NS}}}description").text = route.description |
| 94 | + SubElement(root, f"{{{CORE_NS}}}source").text = "ARIN" |
| 95 | + return ET.tostring(root, encoding="unicode") |
| 96 | + |
| 97 | + def _route_exists(self, route: RouteObject) -> bool: |
| 98 | + resp = self._http.get(self._route_url(route), params=self._params()) |
| 99 | + return resp.status_code == 200 |
| 100 | + |
| 101 | + def sync_route(self, route: RouteObject) -> str: |
| 102 | + """Sync a route object. Returns 'created', 'updated', or 'dry-run'.""" |
| 103 | + exists = self._route_exists(route) |
| 104 | + obj_type = "route6" if ":" in route.prefix else "route" |
| 105 | + asn = route.origin.upper() |
| 106 | + |
| 107 | + if self._dry_run: |
| 108 | + action = "update" if exists else "create" |
| 109 | + print(f"[dry-run] would {action} arin {obj_type} {route.prefix} {asn}") |
| 110 | + return "dry-run" |
| 111 | + |
| 112 | + body = self._route_body(route) |
| 113 | + url = self._route_url(route) |
| 114 | + if exists: |
| 115 | + resp = self._http.put(url, params=self._params(), content=body) |
| 116 | + _raise_for_status(resp, f"update arin route {route.prefix} {asn}") |
| 117 | + return "updated" |
| 118 | + else: |
| 119 | + # Unlike RIPE, ARIN POST uses the key URL (not the collection URL). |
| 120 | + resp = self._http.post(url, params=self._params(), content=body) |
| 121 | + _raise_for_status(resp, f"create arin route {route.prefix} {asn}") |
| 122 | + return "created" |
| 123 | + |
| 124 | + # --- ROAs --- |
| 125 | + |
| 126 | + def _roa_key(self, roa: ROA) -> tuple[str, str, int]: |
| 127 | + prefix_len = int(roa.prefix.split("/")[1]) |
| 128 | + max_length = roa.max_length if roa.max_length is not None else prefix_len |
| 129 | + return (roa.prefix, roa.origin.upper(), max_length) |
| 130 | + |
| 131 | + def _get_current_roas(self) -> dict[tuple[str, str, int], str]: |
| 132 | + """Return a map of (prefix, asn, max_length) -> roaHandle for current ROAs.""" |
| 133 | + resp = self._http.get( |
| 134 | + f"{self._base}/roa/{self._org_handle}", params=self._params() |
| 135 | + ) |
| 136 | + if resp.status_code == 404: |
| 137 | + return {} |
| 138 | + _raise_for_status(resp, "fetch current ARIN ROAs") |
| 139 | + result = {} |
| 140 | + root = ET.fromstring(resp.text) |
| 141 | + for roa_el in root.iter(): |
| 142 | + if roa_el.tag.split("}")[-1] != "roaSpec": |
| 143 | + continue |
| 144 | + handle = _find_text(roa_el, "roaHandle") |
| 145 | + asn_text = _find_text(roa_el, "asNumber") |
| 146 | + if not handle or not asn_text: |
| 147 | + continue |
| 148 | + asn = f"AS{asn_text}" |
| 149 | + for res_el in roa_el.iter(): |
| 150 | + if res_el.tag.split("}")[-1] != "resources": |
| 151 | + continue |
| 152 | + start = _find_text(res_el, "startAddress") |
| 153 | + cidr = _find_text(res_el, "cidrLength") |
| 154 | + max_len_text = _find_text(res_el, "maxLength") |
| 155 | + if not start or not cidr: |
| 156 | + continue |
| 157 | + # Normalize to canonical CIDR (handles IPv6 expansion, strict=False |
| 158 | + # in case startAddress is a host address rather than network address). |
| 159 | + prefix = str(ipaddress.ip_network(f"{start}/{cidr}", strict=False)) |
| 160 | + max_len = int(max_len_text) if max_len_text else int(cidr) |
| 161 | + result[(prefix, asn, max_len)] = handle |
| 162 | + return result |
| 163 | + |
| 164 | + def _roa_transaction_body( |
| 165 | + self, |
| 166 | + to_add: set[tuple[str, str, int]], |
| 167 | + to_delete_handles: list[str], |
| 168 | + ) -> str: |
| 169 | + ET.register_namespace("", RPKI_NS) |
| 170 | + root = Element(f"{{{RPKI_NS}}}rpkiTransaction") |
| 171 | + for handle in to_delete_handles: |
| 172 | + del_el = SubElement(root, f"{{{RPKI_NS}}}roaSpecDelete") |
| 173 | + handle_el = SubElement(del_el, f"{{{RPKI_NS}}}roaHandle") |
| 174 | + handle_el.set("autolink", "true") |
| 175 | + handle_el.text = handle |
| 176 | + for prefix, asn, max_len in to_add: |
| 177 | + add_el = SubElement(root, f"{{{RPKI_NS}}}roaSpecAdd") |
| 178 | + spec_el = SubElement(add_el, f"{{{RPKI_NS}}}roaSpec") |
| 179 | + SubElement(spec_el, f"{{{RPKI_NS}}}autoLink").text = "true" |
| 180 | + # ARIN <asNumber> takes the numeric value only, without the "AS" prefix. |
| 181 | + SubElement(spec_el, f"{{{RPKI_NS}}}asNumber").text = asn.removeprefix("AS") |
| 182 | + network, prefix_len = prefix.split("/") |
| 183 | + resources_el = SubElement(spec_el, f"{{{RPKI_NS}}}resources") |
| 184 | + res_el = SubElement(resources_el, f"{{{RPKI_NS}}}roaSpecResource") |
| 185 | + SubElement(res_el, f"{{{RPKI_NS}}}startAddress").text = network |
| 186 | + SubElement(res_el, f"{{{RPKI_NS}}}cidrLength").text = prefix_len |
| 187 | + if max_len != int(prefix_len): |
| 188 | + SubElement(res_el, f"{{{RPKI_NS}}}maxLength").text = str(max_len) |
| 189 | + return ET.tostring(root, encoding="unicode") |
| 190 | + |
| 191 | + def sync_roas(self, roas: list[ROA]) -> dict[str, int]: |
| 192 | + """Diff desired ROAs against current state and publish changes. |
| 193 | +
|
| 194 | + Only ROAs whose prefix appears in the config are managed. ROAs for |
| 195 | + other prefixes in the account are left untouched. |
| 196 | + """ |
| 197 | + desired = {self._roa_key(r) for r in roas} |
| 198 | + managed_prefixes = {r.prefix for r in roas} |
| 199 | + |
| 200 | + if self._dry_run: |
| 201 | + for prefix, asn, max_len in desired: |
| 202 | + print(f"[dry-run] would sync arin ROA {prefix} {asn} max={max_len}") |
| 203 | + return {"added": len(desired), "deleted": 0} |
| 204 | + |
| 205 | + current = self._get_current_roas() |
| 206 | + current_managed = {k: v for k, v in current.items() if k[0] in managed_prefixes} |
| 207 | + to_add = desired - set(current_managed.keys()) |
| 208 | + to_delete_keys = set(current_managed.keys()) - desired |
| 209 | + to_delete_handles = [current_managed[k] for k in to_delete_keys] |
| 210 | + |
| 211 | + if not to_add and not to_delete_handles: |
| 212 | + return {"added": 0, "deleted": 0} |
| 213 | + |
| 214 | + body = self._roa_transaction_body(to_add, to_delete_handles) |
| 215 | + resp = self._http.post( |
| 216 | + f"{self._base}/rpki/{self._org_handle}", |
| 217 | + params=self._params(), |
| 218 | + content=body, |
| 219 | + ) |
| 220 | + _raise_for_status(resp, "publish ARIN ROAs") |
| 221 | + return {"added": len(to_add), "deleted": len(to_delete_handles)} |
0 commit comments