Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Community Participation Guidelines

This repository is governed by Mozilla's code of conduct and etiquette guidelines.
For more details, please read the
[Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/).

## How to Report
For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page.

<!--
## Project Specific Etiquette

In some cases, there will be additional project etiquette i.e.: (https://bugzilla.mozilla.org/page.cgi?id=etiquette.html).
Please update for your project.
-->
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,27 @@ radb:

`roas` is optional. If omitted, only route objects are synced. ROA sync only manages prefixes explicitly listed — other ROAs in the account are left untouched.

### Deleting route objects

Set `delete: true` on any route entry to remove it from the registry instead of syncing it:

```yaml
routes:
- prefix: "192.0.2.0/24"
origin: "AS12345"
delete: true
```

### Automatic RADb mirroring

When a `radb` section is present in the config, every RIPE and ARIN route change (create, update, or delete) is automatically mirrored to RADb. You do not need to list the same prefix in the `radb.routes` section — duplicates are skipped automatically.

Routes listed exclusively under `radb.routes` (with no corresponding RIPE/ARIN entry) are still synced to RADb directly.

### Attribute preservation

On update, only fields managed by this tool (`route`/`route6`, `origin`, `mnt-by`, `source`, `changed`, and optionally `descr`) are modified. All other attributes already present in the registry object (e.g. `remarks`, `admin-c`, `tech-c`) are preserved.

## Credentials

Secrets are fetched from 1Password via the `op` CLI. The `credentials` block in the config file specifies the 1Password reference for each secret:
Expand Down
41 changes: 29 additions & 12 deletions config.example.yaml
Original file line number Diff line number Diff line change
@@ -1,59 +1,76 @@
# rir-updater example configuration
#
# All three registry sections are optional — include only the ones you use.
# Credential values are 1Password references (op://vault/item/field).
# Run `op signin` before using this tool.

ripe:
maintainer: "MAINT-AS12345"
credentials:
db_username: "op://vault/item/username"
db_password: "op://vault/item/password"
rpki_api_key: "op://vault/item/rpki-api-key"
test_db_username: "op://vault/item/test-username" # optional: overrides db_username for test DB
test_db_password: "op://vault/item/test-password" # optional: overrides db_password for test DB
# Optional: separate credentials for the RIPE test DB (apps-test.db.ripe.net).
# If omitted, the production credentials above are used in test mode.
test_db_username: "op://vault/item/test-username"
test_db_password: "op://vault/item/test-password"
# SSO emails are added as auth entries on the test mntner when running --setup-test.
sso_emails:
- "admin1@example.com"
- "admin2@example.com"
- "admin@example.com"
routes:
- prefix: "192.0.2.0/24"
origin: "AS12345"
description: "Example IPv4 prefix"
- prefix: "2001:db8::/32"
origin: "AS12345"
description: "Example IPv6 prefix"
# Set delete: true to remove an object from the registry instead of syncing it.
# When RADb mirroring is configured, deletions cascade to RADb automatically.
# - prefix: "198.51.100.0/24"
# origin: "AS12345"
# delete: true
roas:
- prefix: "192.0.2.0/24"
origin: "AS12345"
max_length: 24
max_length: 24 # exact-match only; omit to default to the prefix length
- prefix: "2001:db8::/32"
origin: "AS12345"
max_length: 48 # allows more-specific announcements up to /48

arin:
org_handle: "EXAMPLEORG-1"
credentials:
api_key: "op://vault/item/arin-api-key"
test_api_key: "op://vault/item/arin-ote-api-key" # optional: overrides api_key for OTE
# Optional: OTE API key for testing against reg.ote.arin.net.
# If omitted, the production key above is used in test mode.
test_api_key: "op://vault/item/arin-ote-api-key"
routes:
- prefix: "192.0.2.0/24"
origin: "AS12345"
description: "Example IPv4 prefix"
- prefix: "2001:db8::/32"
origin: "AS12345"
description: "Example IPv6 prefix"
roas: # optional
roas:
- prefix: "192.0.2.0/24"
origin: "AS12345"
max_length: 24
- prefix: "2001:db8::/32"
origin: "AS12345"
max_length: 32

# When a radb section is present, every RIPE and ARIN route change is
# automatically mirrored to RADb. Only list routes here that are not already
# covered by the ripe or arin sections above — duplicates are skipped.
radb:
maintainer: "MAINT-AS12345"
contact_email: "admin@example.com"
credentials:
portal_username: "op://vault/item/portal-username"
portal_password: "op://vault/item/portal-password"
mntner_password: "op://vault/item/mntner-password"
# Routes only managed in RADb (not present in the ripe or arin sections above).
routes:
- prefix: "192.0.2.0/24"
- prefix: "203.0.113.0/24"
origin: "AS12345"
description: "Example IPv4 prefix"
- prefix: "2001:db8::/32"
origin: "AS12345"
description: "Example IPv6 prefix"
description: "RADb-only prefix"
57 changes: 37 additions & 20 deletions src/rir_updater/arin/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,42 +132,63 @@ def list_roas(self) -> list[ROA]:
)
return result

def _route_exists(self, route: RouteObject) -> bool:
def _get_existing_route(self, route: RouteObject) -> str | None:
resp = self._http.get(self._route_url(route), params=self._params())
return resp.status_code == 200
if resp.status_code == 404:
return None
_raise_for_status(resp, f"fetch arin route {route.prefix}")
return resp.text

def _merge_route_body(self, existing_xml: str, route: RouteObject) -> str:
"""Build a PUT body by updating managed fields while preserving the rest."""
root = ET.fromstring(existing_xml)
managed_tags = {
"orgHandle": self._org_handle,
"originAS": route.origin.upper(),
"prefix": route.prefix,
"source": "ARIN",
}
for child in root:
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
if tag in managed_tags:
child.text = managed_tags[tag]
elif tag == "description":
for item in list(child):
child.remove(item)
for i, text in enumerate((route.description or "").splitlines()):
line_el = SubElement(child, f"{{{CORE_NS}}}line")
line_el.set("number", str(i))
line_el.text = text
return ET.tostring(root, encoding="unicode")

def delete_route(self, route: RouteObject) -> str:
"""Delete a route object. Returns 'deleted', 'not-found', or 'dry-run'."""
obj_type = "route6" if ":" in route.prefix else "route"
asn = route.origin.upper()
"""Delete a route object. Returns 'deleted', 'not-found', or 'dry-run-delete'.""" # noqa: E501
if self._dry_run:
print(f"[dry-run] would delete arin {obj_type} {route.prefix} {asn}")
return "dry-run"
return "dry-run-delete"
url = self._route_url(route)
resp = self._http.delete(url, params=self._params())
if resp.status_code == 404:
return "not-found"
asn = route.origin.upper()
_raise_for_status(resp, f"delete arin route {route.prefix} {asn}")
return "deleted"

def sync_route(self, route: RouteObject) -> str:
"""Sync a route object. Returns 'created', 'updated', or 'dry-run'."""
exists = self._route_exists(route)
obj_type = "route6" if ":" in route.prefix else "route"
asn = route.origin.upper()
"""Sync a route object. Returns 'created', 'updated', or a dry-run variant."""
existing = self._get_existing_route(route)

if self._dry_run:
action = "update" if exists else "create"
print(f"[dry-run] would {action} arin {obj_type} {route.prefix} {asn}")
return "dry-run"
return "dry-run-update" if existing is not None else "dry-run-create"

body = self._route_body(route)
url = self._route_url(route)
if exists:
asn = route.origin.upper()
if existing is not None:
body = self._merge_route_body(existing, route)
resp = self._http.put(url, params=self._params(), content=body)
_raise_for_status(resp, f"update arin route {route.prefix} {asn}")
return "updated"
else:
body = self._route_body(route)
# Unlike RIPE, ARIN POST uses the key URL (not the collection URL).
resp = self._http.post(url, params=self._params(), content=body)
_raise_for_status(resp, f"create arin route {route.prefix} {asn}")
Expand Down Expand Up @@ -268,10 +289,6 @@ def sync_roas(self, roas: list[ROA]) -> dict[str, int]:
to_delete_handles = [current_managed[k] for k in to_delete_keys]

if self._dry_run:
for prefix, asn, max_len in to_add:
print(f"[dry-run] would add arin ROA {prefix} {asn} max={max_len}")
for prefix, asn, max_len in to_delete_keys:
print(f"[dry-run] would delete arin ROA {prefix} {asn} max={max_len}")
return {"added": len(to_add), "deleted": len(to_delete_keys)}

if not to_add and not to_delete_handles:
Expand Down
Loading