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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

RouteForge is a read-only routing preflight and explainability tool for BGP, RPKI, Registry/IRR and Routing Visibility checks.

Current user-facing version: **v0.3.0-alpha**.
Current user-facing version: **v0.4.0-alpha**.

<!-- Screenshot gallery placeholder:
- docs/screenshots/dashboard.png
Expand Down Expand Up @@ -32,7 +32,7 @@ Routing changes often require fast but traceable checks across multiple external

## Current Alpha Status

RouteForge is a **functional alpha** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.3.0-alpha**.
RouteForge is a **functional alpha** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.4.0-alpha**.

## Quickstart with Docker Compose

Expand Down
2 changes: 1 addition & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Release Notes

## v0.3.0-alpha
## v0.4.0-alpha

**Product Demo & Release Readiness**

Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/routes_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
def system_info():
return {
'name': 'RouteForge',
'version': 'v0.3.0-alpha',
'version': 'v0.4.0-alpha',
'demo_mode': settings.demo_mode,
'read_only': True,
'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'],
Expand Down
55 changes: 55 additions & 0 deletions backend/app/core/source_diagnostics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from __future__ import annotations

from typing import Any

OK = "OK"
CACHE_HIT = "CACHE_HIT"
CACHE_MISS = "CACHE_MISS"
EMPTY_RESPONSE = "EMPTY_RESPONSE"
NO_DATA = "NO_DATA"
TIMEOUT = "TIMEOUT"
HTTP_ERROR = "HTTP_ERROR"
PARSE_ERROR = "PARSE_ERROR"
UNKNOWN_STRUCTURE = "UNKNOWN_STRUCTURE"
RATE_LIMITED = "RATE_LIMITED"
ERROR = "ERROR"

KNOWN_SOURCE_STATUSES = {
OK,
CACHE_HIT,
CACHE_MISS,
EMPTY_RESPONSE,
NO_DATA,
TIMEOUT,
HTTP_ERROR,
PARSE_ERROR,
UNKNOWN_STRUCTURE,
RATE_LIMITED,
ERROR,
}


def make_source_diagnostic(
name: str,
endpoint: str,
status: str,
message: str,
duration_ms: int | None = None,
cached: bool | None = None,
cache_age_seconds: int | None = None,
http_status: int | None = None,
error_type: str | None = None,
details: dict[str, Any] | None = None,
) -> dict[str, Any]:
return {
"name": name,
"endpoint": endpoint,
"status": status,
"message": message,
"duration_ms": duration_ms,
"cached": cached,
"cache_age_seconds": cache_age_seconds,
"http_status": http_status,
"error_type": error_type,
"details": details or {},
}
2 changes: 1 addition & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from app.config import settings
from app.database import Base, engine

app = FastAPI(title="RouteForge", version="0.3.0")
app = FastAPI(title="RouteForge", version="0.4.0")

app.add_middleware(
CORSMiddleware,
Expand Down
20 changes: 17 additions & 3 deletions backend/app/services/asn_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ def _extract_prefixes(self, announced_data: dict) -> list[str]:
def check(self, asn_input: str) -> dict:
asn = normalize_asn(asn_input)
resource = format_asn(asn)
overview = self.client.get("as-overview", {"resource": resource})
prefixes = self.client.get("announced-prefixes", {"resource": resource})
overview, overview_diag = self.client.get_with_diagnostics("as-overview", {"resource": resource})
prefixes, prefixes_diag = self.client.get_with_diagnostics("announced-prefixes", {"resource": resource})
overview = overview or {}
prefixes = prefixes or {}
announced_data = prefixes.get("data", {}) if isinstance(prefixes, dict) else {}
extracted_prefixes = self._extract_prefixes(announced_data if isinstance(announced_data, dict) else {})
rpki_batch = self._build_rpki_batch_metadata(prefixes, announced_data, extracted_prefixes)
Expand Down Expand Up @@ -77,6 +79,7 @@ def check(self, asn_input: str) -> dict:
"as_overview": overview.get("error") if isinstance(overview, dict) else None,
"announced_prefixes": prefixes.get("error") if isinstance(prefixes, dict) else None,
},
"source_diagnostics": [overview_diag, prefixes_diag],
"demo_mode": settings.demo_mode,
},
"sources": ["RIPEstat as-overview", "RIPEstat announced-prefixes"],
Expand All @@ -85,7 +88,8 @@ def check(self, asn_input: str) -> dict:
def check_rpki_batch(self, asn_input: str, limit: int) -> dict:
asn = normalize_asn(asn_input)
resource = format_asn(asn)
prefixes_payload = self.client.get("announced-prefixes", {"resource": resource})
prefixes_payload, prefixes_diag = self.client.get_with_diagnostics("announced-prefixes", {"resource": resource})
prefixes_payload = prefixes_payload or {}
announced_data = prefixes_payload.get("data", {}) if isinstance(prefixes_payload, dict) else {}
extracted = self._extract_prefixes(announced_data if isinstance(announced_data, dict) else {})
rpki_batch = self._build_rpki_batch_metadata(prefixes_payload, announced_data, extracted)
Expand All @@ -97,9 +101,18 @@ def check_rpki_batch(self, asn_input: str, limit: int) -> dict:
has_critical = False
has_warning = False

diag_agg = {"total_requests": 0, "ok": 0, "errors": 0, "timeouts": 0, "rate_limited": 0, "unknown_structure": 0}
for prefix in selected:
try:
rpki = rpki_checker.check(prefix, resource)
diag = rpki.get("source_diagnostic") or {}
diag_agg["total_requests"] += 1
st = diag.get("status")
if st == "OK": diag_agg["ok"] += 1
elif st == "TIMEOUT": diag_agg["timeouts"] += 1
elif st == "RATE_LIMITED": diag_agg["rate_limited"] += 1
elif st == "UNKNOWN_STRUCTURE": diag_agg["unknown_structure"] += 1
else: diag_agg["errors"] += 1 if st and st != "OK" else 0
raw_status = (rpki.get("raw_status") or "").strip().lower().replace("-", "_")
if raw_status in summary:
summary[raw_status] += 1
Expand Down Expand Up @@ -160,6 +173,7 @@ def check_rpki_batch(self, asn_input: str, limit: int) -> dict:
"results": results,
"rpki_batch": rpki_batch,
"announced_prefixes": announced_data if isinstance(announced_data, dict) else {},
"source_diagnostics": [prefixes_diag, {"name": "RIPEstat rpki-validation (batch)", "endpoint": "rpki-validation", "status": "OK" if diag_agg["errors"] == 0 and diag_agg["timeouts"] == 0 and diag_agg["rate_limited"] == 0 else "ERROR", "message": "Aggregated RPKI batch diagnostics", "details": diag_agg}],
"demo_mode": settings.demo_mode,
},
}
Expand Down
16 changes: 14 additions & 2 deletions backend/app/services/prefix_checker.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from app.core.normalize import format_asn, normalize_asn, validate_prefix
from app.core.source_diagnostics import NO_DATA, UNKNOWN_STRUCTURE
from app.core.prefix_evaluation import evaluate_prefix_overall
from app.core.status import CheckStatus
from app.config import settings
Expand All @@ -22,12 +23,22 @@ def check(self, prefix: str, origin_as: str | None) -> dict:
normalized_prefix = validate_prefix(prefix)
normalized_origin = format_asn(normalize_asn(origin_as)) if origin_as else None

whois = self.ripe_db.whois(normalized_prefix)
routing_status = self.client.get("routing-status", {"resource": normalized_prefix})
whois, whois_diag = self.client.get_with_diagnostics("whois", {"resource": normalized_prefix})
whois = whois or {}
routing_status, routing_diag = self.client.get_with_diagnostics("routing-status", {"resource": normalized_prefix})
routing_status = routing_status or {}
rpki_check = self.rpki.check(normalized_prefix, normalized_origin)
registry_check = self.registry.check(normalized_prefix, normalized_origin, whois)
routing_visibility_check = self.routing_visibility.check(normalized_prefix, normalized_origin, routing_status)

source_diagnostics = [rpki_check.get("source_diagnostic"), whois_diag, routing_diag]
if routing_visibility_check.get("raw", {}).get("structure_unknown"):
routing_diag["status"] = UNKNOWN_STRUCTURE
routing_diag["message"] = "Response received, but visible origins could not be extracted"
if not whois.get("data") and not whois.get("error"):
whois_diag["status"] = NO_DATA
whois_diag["message"] = "No registry data available in response"

warnings: list[str] = []
if whois.get("error") or routing_status.get("error"):
warnings.append("Mindestens eine zusätzliche Datenquelle war nicht erreichbar.")
Expand Down Expand Up @@ -87,6 +98,7 @@ def check(self, prefix: str, origin_as: str | None) -> dict:
"routing_visibility": routing_visibility_check.get("raw", {}).get("routing_payload", {}).get("error") if isinstance(routing_visibility_check.get("raw"), dict) else None,
},
"warnings": warnings,
"source_diagnostics": [d for d in source_diagnostics if isinstance(d, dict)],
"demo_mode": settings.demo_mode,
},
"sources": ["RIPEstat rpki-validation", "RIPEstat routing-status", "RIPEstat whois", "RIPEstat routing visibility"],
Expand Down
7 changes: 5 additions & 2 deletions backend/app/services/preflight_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ def check(self, prefix: str, planned_origin_as: str) -> dict:
normalized_prefix = validate_prefix(prefix)
normalized_origin = format_asn(normalize_asn(planned_origin_as))

whois = self.ripe_db.whois(normalized_prefix)
routing_status = self.client.get("routing-status", {"resource": normalized_prefix})
whois, whois_diag = self.client.get_with_diagnostics("whois", {"resource": normalized_prefix})
whois = whois or {}
routing_status, routing_diag = self.client.get_with_diagnostics("routing-status", {"resource": normalized_prefix})
routing_status = routing_status or {}
rpki_check = self.rpki.check(normalized_prefix, normalized_origin)
registry_check = self.registry.check(normalized_prefix, normalized_origin, whois)
routing_visibility_check = self.routing_visibility.check(normalized_prefix, normalized_origin, routing_status)
Expand Down Expand Up @@ -64,6 +66,7 @@ def check(self, prefix: str, planned_origin_as: str) -> dict:
"registry": registry_check.get("raw", {}).get("error") if isinstance(registry_check.get("raw"), dict) else None,
},
"warnings": warnings,
"source_diagnostics": [d for d in [rpki_check.get("source_diagnostic"), whois_diag, routing_diag] if isinstance(d, dict)],
"demo_mode": settings.demo_mode,
},
"sources": ["RIPEstat rpki-validation", "RIPEstat whois", "RIPEstat routing-status"],
Expand Down
72 changes: 66 additions & 6 deletions backend/app/services/ripe_stat_client.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import time

import httpx
from sqlalchemy.orm import Session

from app.config import settings
from app.core.source_diagnostics import (
EMPTY_RESPONSE,
ERROR,
HTTP_ERROR,
OK,
PARSE_ERROR,
RATE_LIMITED,
TIMEOUT,
make_source_diagnostic,
)
from app.services.cache import get_cached, set_cached


Expand All @@ -10,21 +22,69 @@ def __init__(self, db: Session):
self.db = db

def get(self, endpoint: str, params: dict) -> dict:
payload, _diagnostic = self.get_with_diagnostics(endpoint, params)
return payload or {"error": "empty response", "endpoint": endpoint, "params": params}

def get_with_diagnostics(self, endpoint: str, params: dict) -> tuple[dict | None, dict]:
started = time.perf_counter()
if settings.demo_mode:
return self._get_demo_data(endpoint, params)
cached = get_cached(self.db, endpoint, params)
if cached:
return cached
payload = self._get_demo_data(endpoint, params)
return payload, make_source_diagnostic(
name=f"RIPEstat {endpoint}",
endpoint=endpoint,
status=OK,
message="Demo data returned",
duration_ms=int((time.perf_counter() - started) * 1000),
cached=False,
details={"demo_mode": True},
)

cached_payload = get_cached(self.db, endpoint, params)
if cached_payload:
return cached_payload, make_source_diagnostic(
name=f"RIPEstat {endpoint}",
endpoint=endpoint,
status=OK,
message="RIPEstat response returned from cache",
duration_ms=int((time.perf_counter() - started) * 1000),
cached=True,
)

url = f"{settings.ripestat_base_url.rstrip('/')}/{endpoint}/data.json"
try:
with httpx.Client(timeout=settings.http_timeout_seconds) as client:
resp = client.get(url, params=params)
if resp.status_code == 429:
return {"error": "rate limited", "endpoint": endpoint, "params": params}, make_source_diagnostic(
name=f"RIPEstat {endpoint}", endpoint=endpoint, status=RATE_LIMITED, message="RIPEstat rate limit reached", duration_ms=int((time.perf_counter() - started) * 1000), cached=False, http_status=resp.status_code, error_type="http_429"
)
resp.raise_for_status()
data = resp.json()
except httpx.TimeoutException as exc:
return {"error": str(exc), "endpoint": endpoint, "params": params}, make_source_diagnostic(
name=f"RIPEstat {endpoint}", endpoint=endpoint, status=TIMEOUT, message="RIPEstat did not respond before timeout", duration_ms=int((time.perf_counter() - started) * 1000), cached=False, error_type=type(exc).__name__
)
except httpx.HTTPStatusError as exc:
return {"error": str(exc), "endpoint": endpoint, "params": params}, make_source_diagnostic(
name=f"RIPEstat {endpoint}", endpoint=endpoint, status=HTTP_ERROR, message="RIPEstat responded with HTTP error", duration_ms=int((time.perf_counter() - started) * 1000), cached=False, http_status=exc.response.status_code, error_type=type(exc).__name__
)
except ValueError as exc:
return {"error": str(exc), "endpoint": endpoint, "params": params}, make_source_diagnostic(
name=f"RIPEstat {endpoint}", endpoint=endpoint, status=PARSE_ERROR, message="RIPEstat response could not be parsed as JSON", duration_ms=int((time.perf_counter() - started) * 1000), cached=False, error_type=type(exc).__name__
)
except Exception as exc:
return {"error": str(exc), "endpoint": endpoint, "params": params}
return {"error": str(exc), "endpoint": endpoint, "params": params}, make_source_diagnostic(
name=f"RIPEstat {endpoint}", endpoint=endpoint, status=ERROR, message="RIPEstat request failed", duration_ms=int((time.perf_counter() - started) * 1000), cached=False, error_type=type(exc).__name__
)

if not data:
return data, make_source_diagnostic(
name=f"RIPEstat {endpoint}", endpoint=endpoint, status=EMPTY_RESPONSE, message="RIPEstat returned an empty response", duration_ms=int((time.perf_counter() - started) * 1000), cached=False
)
set_cached(self.db, endpoint, params, data)
return data
return data, make_source_diagnostic(
name=f"RIPEstat {endpoint}", endpoint=endpoint, status=OK, message="RIPEstat response received", duration_ms=int((time.perf_counter() - started) * 1000), cached=False
)

def _get_demo_data(self, endpoint: str, params: dict) -> dict:
resource = str(params.get("resource", "")).upper()
Expand Down
16 changes: 8 additions & 8 deletions backend/app/services/routing_visibility_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@ def check(self, prefix: str, origin_as: str | None, routing_payload: dict | None

if not visible_origins:
return {
"status": CheckStatus.WARNING.value,
"summary": "Prefix visibility unclear",
"explanation": "Es wurden Routingdaten gefunden, aber kein klares sichtbares Origin-AS extrahiert.",
"risk": "Das Prefix könnte nicht sichtbar sein oder die Datenstruktur wurde nicht eindeutig erkannt.",
"status": CheckStatus.UNKNOWN.value,
"summary": "Routing visibility could not be determined",
"explanation": "RIPEstat routing-status returned data, but RouteForge could not extract visible Origin-AS information from the response.",
"risk": "Die Datenstruktur war nicht eindeutig auswertbar.",
"recommendations": [
"Prüfe das Prefix zusätzlich über ein Looking Glass.",
"Prüfe, ob das Prefix aktuell announced werden soll.",
],
"raw": raw,
"raw": {**raw, "structure_unknown": True},
}

if not expected_origin:
Expand All @@ -41,7 +41,7 @@ def check(self, prefix: str, origin_as: str | None, routing_payload: dict | None
"explanation": "Für das Prefix wurden sichtbare Origin-ASNs gefunden. Ohne erwartetes Origin-AS erfolgt kein Konsistenzabgleich.",
"risk": "Die Sichtbarkeit ist grundsätzlich erkennbar, aber die erwartete Origin-Zuordnung wurde nicht geprüft.",
"recommendations": ["Gib ein erwartetes Origin-AS an, um die Sichtbarkeit vollständig zu bewerten."],
"raw": raw,
"raw": {**raw, "structure_unknown": True},
}

if expected_origin in visible_origins:
Expand All @@ -51,7 +51,7 @@ def check(self, prefix: str, origin_as: str | None, routing_payload: dict | None
"explanation": "Das Prefix wird mit dem erwarteten Origin-AS im Routing sichtbar.",
"risk": "Keine offensichtliche Routing-Visibility-Inkonsistenz erkannt.",
"recommendations": ["Routing-Sichtbarkeit weiter überwachen."],
"raw": raw,
"raw": {**raw, "structure_unknown": True},
}

return {
Expand All @@ -71,7 +71,7 @@ def _result_unknown(self, payload: dict | None) -> dict:
return {
"status": CheckStatus.UNKNOWN.value,
"summary": "Routing visibility could not be determined",
"explanation": "Die Sichtbarkeit des Prefixes im globalen Routing konnte aus den verfügbaren Daten nicht zuverlässig bestimmt werden.",
"explanation": "RIPEstat routing-status did not respond before the configured timeout or returned no usable payload.",
"risk": "Die Bewertung ist unvollständig.",
"recommendations": [
"Prüfe die Rohdaten.",
Expand Down
3 changes: 3 additions & 0 deletions backend/app/templates/report.html.j2
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
<li><strong>Risiko:</strong> {{ report.checks.routing_visibility.risk if report.checks and report.checks.routing_visibility else '-' }}</li>
</ul>

<h2>Data Source Diagnostics</h2>
{% if report.details and report.details.source_diagnostics %}<table border="1" cellpadding="6" cellspacing="0"><tr><th>Quelle</th><th>Endpoint</th><th>Status</th><th>Dauer</th><th>Cache</th><th>Message</th></tr>{% for d in report.details.source_diagnostics %}<tr><td>{{ d.name or '-' }}</td><td>{{ d.endpoint or '-' }}</td><td>{{ d.status or '-' }}</td><td>{{ d.duration_ms if d.duration_ms is not none else '-' }}</td><td>{{ 'HIT' if d.cached is sameas true else ('MISS' if d.cached is sameas false else '-') }}</td><td>{{ d.message or '-' }}{% if d.details %}<details><summary>Details</summary><pre>{{ d.details | tojson(indent=2) }}</pre></details>{% endif %}</td></tr>{% endfor %}</table>{% else %}<p>No source diagnostics available.</p>{% endif %}

<h2>Rohdaten</h2>
<h3>RPKI Rohdaten</h3>
<pre>{{ (report.checks.rpki.raw if report.checks and report.checks.rpki else {}) | tojson(indent=2) }}</pre>
Expand Down
Loading
Loading