Skip to content

Commit 14ae811

Browse files
committed
Add scripts for RIPE route object and ROA
Python CLI that syncs RIPE DB route objects and RPKI ROAs from a YAML config, authenticated via 1Password CLI. Supports dry-run, test/production environments, and a --setup-test command to bootstrap the RIPE test DB.
1 parent a024723 commit 14ae811

18 files changed

Lines changed: 1399 additions & 2 deletions

.github/workflows/ci.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
ci:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
14+
- name: Install uv
15+
uses: astral-sh/setup-uv@v5
16+
with:
17+
enable-cache: true
18+
19+
- name: Set up Python
20+
run: uv python install
21+
22+
- name: Install dependencies
23+
run: uv sync --frozen
24+
25+
- name: Lint
26+
run: uv run ruff check .
27+
28+
- name: Format check
29+
run: uv run ruff format --check .
30+
31+
- name: Test
32+
run: uv run pytest

.gitignore

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# Local config (use config.example.yaml as template)
2+
config.yaml
3+
14
# Byte-compiled / optimized / DLL files
25
__pycache__/
36
*.py[codz]
@@ -182,9 +185,9 @@ cython_debug/
182185
.abstra/
183186

184187
# Visual Studio Code
185-
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
188+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186189
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187-
# and can be added to the global gitignore or merged into this file. However, if you prefer,
190+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
188191
# you could uncomment the following to ignore the entire vscode folder
189192
# .vscode/
190193

@@ -205,3 +208,8 @@ cython_debug/
205208
marimo/_static/
206209
marimo/_lsp/
207210
__marimo__/
211+
212+
# Claude AI
213+
.claude/
214+
CLAUDE.md
215+
CLAUDE.local.md

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.14

config.example.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
ripe:
2+
maintainer: "MAINT-AS12345"
3+
sso_emails:
4+
- "admin1@example.com"
5+
- "admin2@example.com"
6+
routes:
7+
- prefix: "192.0.2.0/24"
8+
origin: "AS12345"
9+
description: "Example IPv4 prefix"
10+
- prefix: "2001:db8::/32"
11+
origin: "AS12345"
12+
description: "Example IPv6 prefix"
13+
roas:
14+
- prefix: "192.0.2.0/24"
15+
origin: "AS12345"
16+
max_length: 24
17+
- prefix: "2001:db8::/32"
18+
origin: "AS12345"

main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from rir_updater.main import main
2+
3+
if __name__ == "__main__":
4+
main()

pyproject.toml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "rir-updater"
7+
version = "0.1.0"
8+
description = "Scripts to update RIR (Regional Internet Registry) databases"
9+
readme = "README.md"
10+
requires-python = ">=3.14"
11+
dependencies = [
12+
"httpx>=0.28.1",
13+
"pydantic>=2.13.3",
14+
"pyyaml>=6.0.3",
15+
]
16+
17+
[dependency-groups]
18+
dev = [
19+
"pytest>=9.0.3",
20+
"ruff>=0.15.11",
21+
]
22+
23+
[project.scripts]
24+
rir-updater = "rir_updater.main:main"
25+
26+
[tool.hatch.build.targets.wheel]
27+
packages = ["src/rir_updater"]
28+
29+
[tool.pytest.ini_options]
30+
testpaths = ["tests"]
31+
32+
[tool.ruff]
33+
line-length = 88
34+
35+
[tool.ruff.lint]
36+
select = ["E", "F", "I", "UP"]

src/rir_updater/__init__.py

Whitespace-only changes.

src/rir_updater/config.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import ipaddress
2+
import re
3+
from pathlib import Path
4+
5+
import yaml
6+
from pydantic import BaseModel, field_validator
7+
8+
_ASN_RE = re.compile(r"^AS\d+$", re.IGNORECASE)
9+
10+
11+
def _validate_prefix(value: str) -> str:
12+
try:
13+
ipaddress.ip_network(value, strict=True)
14+
except ValueError:
15+
raise ValueError(
16+
f"invalid prefix {value!r} — must be a network address in CIDR notation "
17+
"(e.g. '192.0.2.0/24' or '2001:db8::/32'), host bits must be zero"
18+
)
19+
return value
20+
21+
22+
def _validate_asn(value: str) -> str:
23+
if not _ASN_RE.match(value):
24+
raise ValueError(
25+
f"invalid ASN {value!r} — must be in 'AS<number>' format (e.g. 'AS64496')"
26+
)
27+
return value.upper()
28+
29+
30+
class RouteObject(BaseModel):
31+
prefix: str
32+
origin: str
33+
description: str = ""
34+
35+
@field_validator("prefix")
36+
@classmethod
37+
def validate_prefix(cls, v: str) -> str:
38+
return _validate_prefix(v)
39+
40+
@field_validator("origin")
41+
@classmethod
42+
def validate_origin(cls, v: str) -> str:
43+
return _validate_asn(v)
44+
45+
46+
class ROA(BaseModel):
47+
prefix: str
48+
origin: str
49+
max_length: int | None = None
50+
51+
@field_validator("prefix")
52+
@classmethod
53+
def validate_prefix(cls, v: str) -> str:
54+
return _validate_prefix(v)
55+
56+
@field_validator("origin")
57+
@classmethod
58+
def validate_origin(cls, v: str) -> str:
59+
return _validate_asn(v)
60+
61+
@field_validator("max_length")
62+
@classmethod
63+
def validate_max_length(cls, v: int | None, info) -> int | None:
64+
if v is None:
65+
return v
66+
prefix = info.data.get("prefix", "")
67+
if prefix:
68+
prefix_len = int(prefix.split("/")[1])
69+
if v < prefix_len:
70+
raise ValueError(
71+
f"max_length {v} is less than prefix length {prefix_len}"
72+
)
73+
return v
74+
75+
76+
class RipeConfig(BaseModel):
77+
maintainer: str
78+
sso_emails: list[str] = []
79+
routes: list[RouteObject] = []
80+
roas: list[ROA] = []
81+
82+
83+
class Config(BaseModel):
84+
ripe: RipeConfig | None = None
85+
86+
87+
def load_config(path: Path) -> Config:
88+
raw = yaml.safe_load(path.read_text())
89+
return Config.model_validate(raw)

src/rir_updater/credentials.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import subprocess
2+
3+
from rir_updater.exceptions import CredentialError
4+
5+
6+
def read_op(reference: str) -> str:
7+
"""Fetch a secret from 1Password using the op CLI."""
8+
try:
9+
result = subprocess.run(
10+
["op", "read", reference],
11+
capture_output=True,
12+
text=True,
13+
check=True,
14+
)
15+
return result.stdout.strip()
16+
except FileNotFoundError:
17+
raise CredentialError(
18+
"1Password CLI ('op') not found — install it from https://1password.com/downloads/command-line/"
19+
) from None
20+
except subprocess.CalledProcessError as e:
21+
raise CredentialError(
22+
f"Failed to read secret from 1Password ({reference!r}): {e.stderr.strip()}"
23+
) from e
24+
25+
26+
def get_ripe_db_auth() -> str:
27+
"""Return base64(username:password) for the RIPE DB REST API Basic auth header."""
28+
import base64
29+
30+
username = read_op("op://Code/Mozilla - RIPE NNC/username")
31+
password = read_op("op://Code/Mozilla - RIPE NNC/credential")
32+
return base64.b64encode(f"{username}:{password}".encode()).decode()
33+
34+
35+
def get_ripe_rpki_key() -> str:
36+
"""Return the API key for the RIPE RPKI Management API."""
37+
return read_op("op://Code/Mozilla - RIPE NNC/RPKI API Key")

src/rir_updater/exceptions.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
class RirUpdaterError(Exception):
2+
pass
3+
4+
5+
class ApiError(RirUpdaterError):
6+
pass
7+
8+
9+
class CredentialError(RirUpdaterError):
10+
pass
11+
12+
13+
class ConfigError(RirUpdaterError):
14+
pass

0 commit comments

Comments
 (0)