RouteForge v0.7.0-beta
Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.
From 5f977f2f594a5b6bd23e80de8cdad1f78c8eaf6c Mon Sep 17 00:00:00 2001
From: DeepZone
-
+
@@ -43,7 +43,7 @@ Routing changes often require fast but traceable checks across multiple external
## Current Alpha Status
-RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.7.0-beta**.
+RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.7.1-beta**.
## Quickstart with Docker Compose
@@ -274,3 +274,8 @@ In the standard Docker setup, API calls are same-origin via frontend nginx (`/ap
- User management is **admin-only**.
- Viewers cannot execute checks.
- Keep `SECRET_KEY` stable; changing it invalidates existing sessions.
+
+
+## BGP Visibility Details (v0.7.1-beta)
+- Read-only BGP visibility validation for prefix and optional expected origin AS.
+- Uses external RIPEstat visibility data; results are momentary snapshots and do not replace continuous monitoring.
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index c86cb20..46c1803 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,3 +1,31 @@
+## v0.7.1-beta: BGP Visibility Details
+
+### Motivation
+Improve prefix visibility checks with explicit BGP origin visibility details while preserving RouteForge's strict read-only model.
+
+### Implemented Changes
+- Added dedicated backend service and API endpoint `POST /api/check/bgp-visibility`.
+- Added frontend BGP Visibility page and Change Case integration action.
+- Added audit event `bgp_visibility_checked` plus existing case attachment events when linked to a Change Case.
+- Extended report output to include structured BGP visibility details via generic detail rendering.
+
+### BGP Visibility Logic
+- `OK`: prefix visible and expected origin (if provided) is seen.
+- `WARNING`: prefix visible with multiple or unexpected origins (without strict expectation).
+- `CRITICAL`: prefix not visible with expected origin requirement, or expected origin not visible.
+- `UNKNOWN`: no reliable source data.
+
+### Security Notes
+BGP visibility checks remain read-only and do not modify RIPE DB, RPKI objects, or routers. Results are external visibility snapshots.
+
+### Testing
+- backend: `pytest -q`
+- frontend: `npm run build`
+
+### Known Limitations
+- Visibility depends on external RIPEstat data quality and timing.
+- Results are point-in-time and not a substitute for continuous monitoring.
+
## v0.7.0-beta
- Added Projects / Change Cases lifecycle (draft, in_review, approved, closed).
- Added Change Case API, UI navigation and detail workflow.
diff --git a/ROADMAP.md b/ROADMAP.md
index 44f53cf..a3c7b03 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,9 +1,9 @@
# RouteForge Roadmap
## Current Status
-v0.7.0-beta, audit log UI/API and session hardening completed, read-only
+v0.7.1-beta, BGP Visibility Details completed, read-only
-## v0.7.0-beta
+## v0.7.1-beta
- projects/change cases
- grouped preflight reports
diff --git a/backend/app/api/routes_checks.py b/backend/app/api/routes_checks.py
index f700168..18a7055 100644
--- a/backend/app/api/routes_checks.py
+++ b/backend/app/api/routes_checks.py
@@ -6,8 +6,9 @@
from app.core.audit import write_audit_log
from app.database import get_db
from app.models import ChangeCase, Check, Report
-from app.schemas import AsnCheckRequest, AsnRpkiBatchRequest, CheckResponse, PrefixCheckRequest, PreflightCheckRequest
+from app.schemas import AsnCheckRequest, AsnRpkiBatchRequest, BgpVisibilityCheckRequest, CheckResponse, PrefixCheckRequest, PreflightCheckRequest
from app.services.asn_checker import AsnChecker
+from app.services.bgp_visibility_service import BgpVisibilityService
from app.services.prefix_checker import PrefixChecker
from app.services.preflight_checker import PreflightChecker
from app.services.report_renderer import render_report
@@ -50,6 +51,20 @@ def check_prefix(payload: PrefixCheckRequest, db: Session = Depends(get_db), use
+
+
+@router.post('/bgp-visibility', response_model=CheckResponse)
+def check_bgp_visibility(payload: BgpVisibilityCheckRequest, db: Session = Depends(get_db), user=Depends(require_operator_or_admin)) -> CheckResponse:
+ try:
+ result = BgpVisibilityService(RipeStatClient(db)).check(payload.prefix, payload.expected_origin_as)
+ response = _store_and_respond(db, "bgp-visibility", payload.prefix, payload.expected_origin_as, result, user.id, payload.change_case_id)
+ write_audit_log(db, user_id=user.id, action='bgp_visibility_checked', target_type='check', target_id=str(response.report_id), details_json={'prefix': payload.prefix, 'expected_origin_as': payload.expected_origin_as, 'change_case_id': payload.change_case_id})
+ return response
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=f"BGP-Visibility-Prüfung fehlgeschlagen: {exc}") from exc
+
@router.post('/preflight', response_model=CheckResponse)
def check_preflight(payload: PreflightCheckRequest, db: Session = Depends(get_db), user=Depends(require_operator_or_admin)) -> CheckResponse:
try:
diff --git a/backend/app/api/routes_system.py b/backend/app/api/routes_system.py
index 5dd6e8c..60c65f6 100644
--- a/backend/app/api/routes_system.py
+++ b/backend/app/api/routes_system.py
@@ -12,7 +12,7 @@
def system_info():
return {
'name': 'RouteForge',
- 'version': 'v0.7.0-beta',
+ 'version': 'v0.7.1-beta',
'demo_mode': settings.demo_mode,
'read_only': True,
'data_sources': ['RIPEstat', 'RIPEstat Whois/Registry'],
diff --git a/backend/app/core/system_status.py b/backend/app/core/system_status.py
index 4fb5d83..0f8a9fa 100644
--- a/backend/app/core/system_status.py
+++ b/backend/app/core/system_status.py
@@ -139,7 +139,7 @@ def build_system_status(engine: Engine | None) -> dict:
return {
"status": "ok",
"name": settings.app_name,
- "version": "v0.7.0-beta",
+ "version": "v0.7.1-beta",
"read_only": True,
"mode": "demo" if settings.demo_mode else "live",
"demo_mode": settings.demo_mode,
diff --git a/backend/app/main.py b/backend/app/main.py
index 2379f29..002bbb0 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -19,7 +19,7 @@
logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
logger = logging.getLogger("routeforge")
-app = FastAPI(title="RouteForge", version="0.7.0")
+app = FastAPI(title="RouteForge", version="0.7.1")
app.add_middleware(
CORSMiddleware,
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 62717e2..f5da84d 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -90,6 +90,26 @@ def valid_planned_origin(cls, v: str) -> str:
return v
+
+class BgpVisibilityCheckRequest(BaseModel):
+ change_case_id: int | None = None
+ prefix: str
+ expected_origin_as: str | None = None
+
+ @field_validator("prefix")
+ @classmethod
+ def valid_prefix(cls, v: str) -> str:
+ validate_prefix(v)
+ return v
+
+ @field_validator("expected_origin_as")
+ @classmethod
+ def valid_expected_origin(cls, v: str | None) -> str | None:
+ if v is not None:
+ normalize_asn(v)
+ return v
+
+
class CheckResponse(BaseModel):
report_id: int
status: str
diff --git a/backend/app/services/bgp_visibility_service.py b/backend/app/services/bgp_visibility_service.py
new file mode 100644
index 0000000..ba7e1af
--- /dev/null
+++ b/backend/app/services/bgp_visibility_service.py
@@ -0,0 +1,78 @@
+from app.core.normalize import format_asn, normalize_asn, validate_prefix
+from app.core.status import CheckStatus
+from app.services.ripe_stat_client import RipeStatClient
+
+
+class BgpVisibilityService:
+ def __init__(self, client: RipeStatClient):
+ self.client = client
+
+ def check(self, prefix: str, expected_origin_as: str | None) -> dict:
+ normalized_prefix = validate_prefix(prefix)
+ normalized_expected = format_asn(normalize_asn(expected_origin_as)) if expected_origin_as else None
+
+ routing_payload, routing_diag = self.client.get_with_diagnostics("routing-status", {"resource": normalized_prefix})
+ bgp_state_payload, bgp_state_diag = self.client.get_with_diagnostics("bgp-state", {"resource": normalized_prefix})
+ routing_payload = routing_payload or {}
+ bgp_state_payload = bgp_state_payload or {}
+
+ routing_data = routing_payload.get("data", {}) if isinstance(routing_payload, dict) else {}
+ bgp_data = bgp_state_payload.get("data", {}) if isinstance(bgp_state_payload, dict) else {}
+
+ origins = sorted({str(item.get("origin", "")).upper() for item in (routing_data.get("routes") or []) if isinstance(item, dict) and item.get("origin")})
+ visible = bool(origins or routing_data.get("visibility") or bgp_data.get("bgp_state"))
+ expected_seen = normalized_expected in origins if normalized_expected else None
+ multiple_origins = len(origins) > 1
+ peer_count = routing_data.get("num_peers_seeing") or bgp_data.get("num_peers_seeing")
+ more_specifics = routing_data.get("more_specifics") or bgp_data.get("more_specifics") or []
+ less_specifics = routing_data.get("less_specifics") or bgp_data.get("less_specifics") or []
+
+ data_unreliable = (not routing_data and not bgp_data) or (routing_payload.get("error") and bgp_state_payload.get("error"))
+
+ if data_unreliable:
+ status = CheckStatus.UNKNOWN.value
+ summary = "Keine belastbaren BGP-Sichtbarkeitsdaten für das Prefix verfügbar."
+ recommendations = ["Später erneut prüfen und ein externes Monitoring zur Gegenprüfung verwenden."]
+ elif not visible:
+ status = CheckStatus.CRITICAL.value if normalized_expected else CheckStatus.WARNING.value
+ summary = "Das Prefix ist aktuell nicht sichtbar."
+ recommendations = ["Ankündigungspfad und Upstream-Policy prüfen.", "Route-Propagation in mehreren Looking-Glasses validieren."]
+ elif normalized_expected and not expected_seen:
+ status = CheckStatus.CRITICAL.value
+ summary = f"Das erwartete Origin {normalized_expected} ist für {normalized_prefix} nicht sichtbar."
+ recommendations = ["Origin-AS Konfiguration prüfen.", "Mögliche Route-Leaks/Hijacks gegenprüfen."]
+ elif multiple_origins:
+ status = CheckStatus.WARNING.value
+ summary = "Prefix sichtbar, aber mit mehreren Origin-ASNs (MOAS)."
+ recommendations = ["Mehrfach-Origin fachlich bestätigen oder unbeabsichtigte Ankündigung beheben."]
+ else:
+ status = CheckStatus.OK.value
+ summary = "Prefix sichtbar und erwartete Origin-AS (falls angegeben) wird gesehen."
+ recommendations = ["Weiter beobachten; Ergebnis ist eine Momentaufnahme externer Sichtbarkeitsdaten."]
+
+ return {
+ "status": status,
+ "summary": summary,
+ "explanation": "BGP Visibility basiert auf RIPEstat-Daten und ist read-only.",
+ "risk": "Externe Sichtbarkeitsdaten können zeitversetzt oder unvollständig sein.",
+ "recommendations": recommendations,
+ "input": {"prefix": normalized_prefix, "expected_origin_as": normalized_expected},
+ "checks": None,
+ "details": {
+ "prefix": normalized_prefix,
+ "visible": visible,
+ "origins": origins,
+ "expected_origin_as": normalized_expected,
+ "expected_origin_seen": expected_seen,
+ "multiple_origins": multiple_origins,
+ "peer_count": peer_count,
+ "more_specifics": more_specifics,
+ "less_specifics": less_specifics,
+ "source_diagnostics": [d for d in [routing_diag, bgp_state_diag] if isinstance(d, dict)],
+ "source_errors": {
+ "routing_status": routing_payload.get("error") if isinstance(routing_payload, dict) else None,
+ "bgp_state": bgp_state_payload.get("error") if isinstance(bgp_state_payload, dict) else None,
+ },
+ },
+ "sources": ["RIPEstat routing-status", "RIPEstat bgp-state"],
+ }
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 13fb7c6..c7d986b 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "routeforge-backend"
-version = "0.6.6"
+version = "0.7.1"
description = "RouteForge backend"
license = "AGPL-3.0-or-later"
requires-python = ">=3.12"
diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py
index 0622218..9be4f5a 100644
--- a/backend/tests/test_api_smoke.py
+++ b/backend/tests/test_api_smoke.py
@@ -177,7 +177,7 @@ def test_system_status_endpoint() -> None:
response = client.get('/api/system/status')
assert response.status_code == 200
payload = response.json()
- assert payload.get('version') == 'v0.7.0-beta'
+ assert payload.get('version') == 'v0.7.1-beta'
assert payload.get('read_only') is True
assert payload.get('database', {}).get('status')
assert payload.get('ripestat', {}).get('cache_ttl_seconds') is not None
@@ -455,3 +455,50 @@ def test_change_case_delete_operator_allowed_viewer_forbidden() -> None:
client.post('/api/auth/logout')
assert client.post('/api/auth/login', json={'username': 'vw4', 'password': 'ViewerPass123!'}).status_code == 200
assert client.delete(f'/api/change-cases/{cid2v}').status_code == 403
+
+def test_bgp_visibility_roles_status_change_case_and_audit(monkeypatch) -> None:
+ client = _client()
+ _setup_and_login(client)
+
+ from app.services import bgp_visibility_service as svc
+
+ def fake_ok(self, prefix: str, expected_origin_as: str | None):
+ return {
+ 'status': 'OK', 'summary': 'ok', 'explanation': 'x', 'risk': 'r', 'recommendations': ['a'],
+ 'input': {'prefix': prefix, 'expected_origin_as': expected_origin_as}, 'checks': None,
+ 'details': {'prefix': prefix, 'visible': True, 'origins': ['AS3320'], 'expected_origin_as': expected_origin_as, 'expected_origin_seen': True, 'multiple_origins': False, 'peer_count': 10, 'more_specifics': [], 'less_specifics': [], 'source_diagnostics': []}
+ }
+
+ monkeypatch.setattr(svc.BgpVisibilityService, 'check', fake_ok)
+
+ cid = client.post('/api/change-cases', json={'title': 'BGP Case', 'description': ''}).json()['id']
+ ok = client.post('/api/check/bgp-visibility', json={'prefix': '192.0.2.0/24', 'expected_origin_as': 'AS3320', 'change_case_id': cid})
+ assert ok.status_code == 200
+ assert ok.json().get('status') == 'OK'
+
+ missing = client.post('/api/check/bgp-visibility', json={'prefix': '192.0.2.0/24', 'change_case_id': 999999})
+ assert missing.status_code == 404
+
+ client.post('/api/users', json={'username': 'opbgp', 'email': 'opbgp@example.org', 'password': 'OperatorPass123!', 'role': 'operator'})
+ client.post('/api/users', json={'username': 'vwbgp', 'email': 'vwbgp@example.org', 'password': 'ViewerPass123!', 'role': 'viewer'})
+
+ client.post('/api/auth/logout')
+ assert client.post('/api/auth/login', json={'username': 'opbgp', 'password': 'OperatorPass123!'}).status_code == 200
+ assert client.post('/api/check/bgp-visibility', json={'prefix': '192.0.2.0/24'}).status_code == 200
+
+ client.post('/api/auth/logout')
+ assert client.post('/api/auth/login', json={'username': 'vwbgp', 'password': 'ViewerPass123!'}).status_code == 200
+ assert client.post('/api/check/bgp-visibility', json={'prefix': '192.0.2.0/24'}).status_code == 403
+
+
+def test_bgp_visibility_status_mapping():
+ from app.services.bgp_visibility_service import BgpVisibilityService
+
+ class C:
+ def __init__(self, responses): self.responses = responses
+ def get_with_diagnostics(self, endpoint, _): return self.responses.get(endpoint, ({}, {}))
+
+ svc = BgpVisibilityService(C({'routing-status': ({'data': {'routes': [{'origin': 'AS64496'}]}}, {}), 'bgp-state': ({'data': {}}, {})}))
+ assert svc.check('198.51.100.0/24', 'AS3320')['status'] == 'CRITICAL'
+ svc2 = BgpVisibilityService(C({'routing-status': ({'data': {'routes': [{'origin': 'AS3320'}, {'origin': 'AS64496'}]}}, {}), 'bgp-state': ({'data': {}}, {})}))
+ assert svc2.check('198.51.100.0/24', None)['status'] == 'WARNING'
diff --git a/frontend/package.json b/frontend/package.json
index 3c354dc..9e46031 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "routeforge-frontend",
- "version": "0.7.0",
+ "version": "0.7.1",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 90042b6..e3e7945 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -6,13 +6,14 @@ import { Layout } from './components/Layout'
import { LoginView } from './components/LoginView'
import { PrefixCheckForm } from './components/PrefixCheckForm'
import { PreflightCheckForm } from './components/PreflightCheckForm'
+import { BgpVisibilityForm } from './components/BgpVisibilityForm'
import { SetupView } from './components/SetupView'
import { StatusBadge } from './components/StatusBadge'
import type { ReportListItem, SystemInfo, SystemStatus, User, UserRole } from './types'
import { UsersView } from './components/UsersView'
import { ChangeCasesView } from './components/ChangeCasesView'
-type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'reports' | 'change-cases' | 'system' | 'users' | 'audit' | 'about'
+type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'bgp-visibility' | 'reports' | 'change-cases' | 'system' | 'users' | 'audit' | 'about'
type AuthMode = 'loading' | 'setup' | 'login' | 'app' | 'error'
export default function App() {
@@ -71,8 +72,8 @@ export default function App() {
if (authMode === 'login') return
Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.
Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.
| {r.summary} |
Version: v0.7.0-beta
Version: v0.7.1-beta
Viewer role: Seite ist sichtbar, aber Checks dürfen nicht ausgeführt werden.
} + setPrefix(e.target.value)} /> + setExpectedOriginAs(e.target.value)} /> + + {error &&{error.message}
} + {result &&{JSON.stringify(details, null, 2)}