Skip to content

Commit ac1df8a

Browse files
committed
Add ARIN support for IRR route objects and RPKI ROAs
1 parent 7a4016f commit ac1df8a

9 files changed

Lines changed: 526 additions & 3 deletions

File tree

README.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# rir-updater
22

3-
CLI tool for syncing route objects to RIPE NCC and RADb, and RPKI ROAs to RIPE.
3+
CLI tool for syncing route objects to RIPE NCC, ARIN, and RADb, and RPKI ROAs to RIPE and ARIN.
44

55
## Requirements
66

@@ -16,7 +16,7 @@ uv sync
1616

1717
## Configuration
1818

19-
Copy `config.example.yaml` and fill in your values. Both `ripe` and `radb` sections are optional — include only the registries you use.
19+
Copy `config.example.yaml` and fill in your values. All registry sections (`ripe`, `arin`, `radb`) are optional — include only the registries you use.
2020

2121
```yaml
2222
ripe:
@@ -44,6 +44,25 @@ ripe:
4444
origin: "AS12345"
4545
max_length: 32
4646

47+
arin:
48+
org_handle: "EXAMPLEORG-1"
49+
credentials:
50+
api_key: "op://vault/item/arin-api-key"
51+
routes:
52+
- prefix: "192.0.2.0/24"
53+
origin: "AS12345"
54+
description: "Example IPv4 prefix"
55+
- prefix: "2001:db8::/32"
56+
origin: "AS12345"
57+
description: "Example IPv6 prefix"
58+
roas:
59+
- prefix: "192.0.2.0/24"
60+
origin: "AS12345"
61+
max_length: 24
62+
- prefix: "2001:db8::/32"
63+
origin: "AS12345"
64+
max_length: 32
65+
4766
radb:
4867
maintainer: "MAINT-AS12345"
4968
contact_email: "admin@example.com"
@@ -76,6 +95,14 @@ Secrets are fetched from 1Password via the `op` CLI. The `credentials` block in
7695
| `test_db_username` | RIPE test DB username (optional, overrides `db_username` in test mode) |
7796
| `test_db_password` | RIPE test DB password (optional, overrides `db_password` in test mode) |
7897

98+
### ARIN
99+
100+
| Field | Used for |
101+
|-------|----------|
102+
| `api_key` | ARIN API key for all IRR and RPKI requests |
103+
104+
The API key must be linked to a POC with authority over your organization's resources. Create one at ARIN Online → Settings → Security Info → Manage API Keys.
105+
79106
### RADb
80107

81108
| Field | Used for |
@@ -102,7 +129,7 @@ uv run rir-updater config.yaml --production --commit
102129
uv run rir-updater config.yaml --setup-test
103130
```
104131

105-
RADb always runs against production — `--production` and `--setup-test` only affect the RIPE section.
132+
RADb always runs against production — `--production` and `--setup-test` only affect the RIPE and ARIN sections. ARIN uses its OT&E environment in test mode (`reg.ote.arin.net`) and production otherwise.
106133

107134
### RIPE test database bootstrap
108135

config.example.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,25 @@ ripe:
2323
- prefix: "2001:db8::/32"
2424
origin: "AS12345"
2525

26+
arin:
27+
org_handle: "EXAMPLEORG-1"
28+
credentials:
29+
api_key: "op://vault/item/arin-api-key"
30+
routes:
31+
- prefix: "192.0.2.0/24"
32+
origin: "AS12345"
33+
description: "Example IPv4 prefix"
34+
- prefix: "2001:db8::/32"
35+
origin: "AS12345"
36+
description: "Example IPv6 prefix"
37+
roas: # optional
38+
- prefix: "192.0.2.0/24"
39+
origin: "AS12345"
40+
max_length: 24
41+
- prefix: "2001:db8::/32"
42+
origin: "AS12345"
43+
max_length: 32
44+
2645
radb:
2746
maintainer: "MAINT-AS12345"
2847
contact_email: "admin@example.com"

src/rir_updater/arin/__init__.py

Whitespace-only changes.

src/rir_updater/arin/client.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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)}

src/rir_updater/config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,25 @@ class RadbConfig(BaseModel):
119119
routes: list[RouteObject] = []
120120

121121

122+
class ArinCredentials(BaseModel):
123+
"""1Password reference for ARIN API key. Resolved at runtime via `op read`."""
124+
125+
api_key: str
126+
127+
128+
class ArinConfig(BaseModel):
129+
"""Configuration for the ARIN registry (IRR route objects and RPKI ROAs)."""
130+
131+
org_handle: str
132+
credentials: ArinCredentials
133+
routes: list[RouteObject] = []
134+
roas: list[ROA] = []
135+
136+
122137
class Config(BaseModel):
123138
ripe: RipeConfig | None = None
124139
radb: RadbConfig | None = None
140+
arin: ArinConfig | None = None
125141

126142

127143
def load_config(path: Path) -> Config:

src/rir_updater/credentials.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,8 @@ def get_radb_portal_auth(username_ref: str, password_ref: str) -> tuple[str, str
4545
def get_radb_mntner_password(password_ref: str) -> str:
4646
"""Return the RADb mntner password used for object-level authorization."""
4747
return read_op(password_ref)
48+
49+
50+
def get_arin_api_key(key_ref: str) -> str:
51+
"""Return the ARIN API key used for all IRR and RPKI requests."""
52+
return read_op(key_ref)

src/rir_updater/main.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
from pydantic import ValidationError
66

7+
from rir_updater.arin.client import ArinClient
78
from rir_updater.config import load_config
89
from rir_updater.credentials import (
10+
get_arin_api_key,
911
get_radb_mntner_password,
1012
get_radb_portal_auth,
1113
get_ripe_db_auth,
@@ -101,6 +103,23 @@ def _run(args, parser):
101103
result = client.sync_route(route)
102104
print(f"{result}: radb route {route.prefix} {route.origin}")
103105

106+
if config.arin:
107+
with ArinClient(
108+
org_handle=config.arin.org_handle,
109+
api_key=get_arin_api_key(config.arin.credentials.api_key),
110+
dry_run=not args.commit,
111+
use_test_env=not args.production,
112+
) as client:
113+
for route in config.arin.routes:
114+
result = client.sync_route(route)
115+
print(f"{result}: arin route {route.prefix} {route.origin}")
116+
117+
if config.arin.roas:
118+
counts = client.sync_roas(config.arin.roas)
119+
print(
120+
f"ARIN ROAs: {counts['added']} added, {counts['deleted']} deleted"
121+
)
122+
104123

105124
if __name__ == "__main__":
106125
main()

tests/arin/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)