feat(rpki): provider scaffold — base, factory, paths, ripestat skeleton

New decnet/rpki/ module mirrors decnet/asn/ shape. Validator ABC,
lazy singleton factory (DECNET_RPKI_PROVIDER=ripestat default),
paths.py with DECNET_RPKI_ROOT override. RipeStatValidator stub
returns 'unknown' unconditionally — HTTP wired in next commit.

enrich_rpki(ip, asn) -> (status, source) | (None, None); short-circuits
on DECNET_RPKI_ENABLED=false or asn=None.
This commit is contained in:
2026-05-21 16:10:01 -04:00
parent e3d9908bed
commit 1a11287f76
8 changed files with 218 additions and 0 deletions

43
decnet/rpki/__init__.py Normal file
View File

@@ -0,0 +1,43 @@
"""RPKI validity enrichment — maps (IP, ASN) pairs to route origin validity.
Public surface:
* :func:`enrich_rpki` — takes an IP string and ASN int, returns
``(rpki_status, provider_name)`` or ``(None, None)``.
Validator selection goes through :func:`~decnet.rpki.factory.get_validator`
(env ``DECNET_RPKI_PROVIDER``, default ``ripestat``). Direct imports of
concrete validators are forbidden — mirrors the ``get_bus`` /
``get_repository`` rule.
"""
from __future__ import annotations
import os
from typing import Optional, Tuple
from decnet.rpki.factory import get_validator
def enrich_rpki(ip: str, asn: Optional[int]) -> Tuple[Optional[str], Optional[str]]:
"""Return ``(rpki_status, provider_name)`` or ``(None, None)``.
Never raises — any failure collapses to ``(None, None)`` so the
caller (profiler) can upsert the attacker row regardless.
Short-circuits to ``(None, None)`` when ``asn`` is None (no ASN
means no route origin to validate) or when
``DECNET_RPKI_ENABLED=false``.
"""
if os.environ.get("DECNET_RPKI_ENABLED", "true").lower() == "false":
return (None, None)
if asn is None:
return (None, None)
try:
validator = get_validator()
result = validator.validate(ip, asn)
return (result.status, validator.name)
except Exception:
return (None, None)
__all__ = ["enrich_rpki"]

38
decnet/rpki/base.py Normal file
View File

@@ -0,0 +1,38 @@
"""RPKI validator protocol.
Concrete validators (:mod:`decnet.rpki.ripestat`, future offline providers)
implement this. Callers must go through
:func:`~decnet.rpki.factory.get_validator`; never import a concrete
validator class directly.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Optional
RpkiStatus = Literal["valid", "invalid", "not-found", "unknown"]
@dataclass(frozen=True)
class RpkiResult:
"""Outcome of a single RPKI validity check."""
status: RpkiStatus
prefix: Optional[str] = None # announced prefix the validator resolved for this IP
validated_at: Optional[datetime] = None
class Validator(ABC):
"""Abstract RPKI validator."""
#: Short tag written to ``Attacker.rpki_source`` (e.g. ``'ripestat'``).
name: str
@abstractmethod
def validate(self, ip: str, asn: int) -> RpkiResult:
"""Return RPKI validity for (ip, asn). Never raises."""
def refresh(self) -> None:
"""No-op for online validators; offline providers may override."""

39
decnet/rpki/factory.py Normal file
View File

@@ -0,0 +1,39 @@
"""RPKI validator factory — mirror of :mod:`decnet.asn.factory`.
Dispatch key: ``DECNET_RPKI_PROVIDER`` (default ``ripestat``). Lazy
singleton.
"""
from __future__ import annotations
import os
from typing import Optional
from decnet.rpki.base import Validator
_cached: Optional[Validator] = None
_cached_key: Optional[str] = None
def get_validator() -> Validator:
"""Return the configured :class:`Validator` singleton."""
global _cached, _cached_key
key = os.environ.get("DECNET_RPKI_PROVIDER", "ripestat").lower()
if _cached is not None and _cached_key == key:
return _cached
if key == "ripestat":
from decnet.rpki.ripestat.validator import RipeStatValidator
validator: Validator = RipeStatValidator()
else:
raise ValueError(f"Unsupported RPKI provider: {key!r}")
_cached = validator
_cached_key = key
return validator
def reset_cache() -> None:
"""Forget the singleton — tests swap validators via the env var."""
global _cached, _cached_key
_cached = None
_cached_key = None

18
decnet/rpki/paths.py Normal file
View File

@@ -0,0 +1,18 @@
"""Filesystem layout for RPKI data — mirror of :mod:`decnet.asn.paths`.
``RPKI_ROOT`` is where the validator stores its SQLite cache.
Default ``/var/lib/decnet/rpki``. Override with ``DECNET_RPKI_ROOT``
for test harnesses.
"""
from __future__ import annotations
import os
from pathlib import Path
RPKI_ROOT = Path(os.environ.get("DECNET_RPKI_ROOT", "/var/lib/decnet/rpki"))
def ensure_root() -> Path:
"""Create ``RPKI_ROOT`` if absent and return it. No-op if present."""
RPKI_ROOT.mkdir(parents=True, exist_ok=True)
return RPKI_ROOT

View File

View File

@@ -0,0 +1,20 @@
"""RIPE STAT RPKI validator.
Resolves the most-specific announced prefix covering ``ip`` via the
RIPE STAT ``network-info`` endpoint, then validates ``(asn, prefix)``
via ``rpki-validation``. Results are cached in a SQLite database under
:data:`~decnet.rpki.paths.RPKI_ROOT` to avoid per-event network calls.
HTTP is wired in the next commit; this skeleton returns ``unknown``
unconditionally so the rest of the pipeline compiles and tests pass.
"""
from __future__ import annotations
from decnet.rpki.base import RpkiResult, Validator
class RipeStatValidator(Validator):
name = "ripestat"
def validate(self, ip: str, asn: int) -> RpkiResult:
return RpkiResult(status="unknown")

0
tests/rpki/__init__.py Normal file
View File

View File

@@ -0,0 +1,60 @@
"""RipeStatValidator + factory + public API tests."""
from __future__ import annotations
import pytest
def test_factory_returns_ripestat_by_default() -> None:
from decnet.rpki.factory import get_validator
v = get_validator()
assert v.name == "ripestat"
def test_factory_rejects_unknown_provider(monkeypatch: pytest.MonkeyPatch) -> None:
from decnet.rpki import factory
monkeypatch.setenv("DECNET_RPKI_PROVIDER", "nope")
factory.reset_cache()
with pytest.raises(ValueError):
factory.get_validator()
factory.reset_cache()
def test_enrich_rpki_short_circuits_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DECNET_RPKI_ENABLED", "false")
from decnet.rpki import enrich_rpki
assert enrich_rpki("8.8.8.8", 15169) == (None, None)
def test_enrich_rpki_short_circuits_when_asn_none() -> None:
from decnet.rpki import enrich_rpki
assert enrich_rpki("8.8.8.8", None) == (None, None)
def test_enrich_rpki_returns_status_and_source() -> None:
from decnet.rpki import enrich_rpki
status, source = enrich_rpki("8.8.8.8", 15169)
assert status in {"valid", "invalid", "not-found", "unknown"}
assert source == "ripestat"
def test_enrich_rpki_survives_validator_exception(monkeypatch: pytest.MonkeyPatch) -> None:
from decnet.rpki import factory as rpki_factory
from decnet.rpki.base import Validator, RpkiResult
class BrokenValidator(Validator):
name = "broken"
def validate(self, ip: str, asn: int) -> RpkiResult:
raise RuntimeError("boom")
monkeypatch.setattr(rpki_factory, "_cached", BrokenValidator())
monkeypatch.setattr(rpki_factory, "_cached_key", "ripestat")
from decnet.rpki import enrich_rpki
assert enrich_rpki("8.8.8.8", 15169) == (None, None)