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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<img src="frontend/public/routeforge.png" alt="RouteForge Logo" width="420">
</p>
<p align="center">
<img src="https://img.shields.io/badge/version-v0.7.0--beta-blue" alt="Version">
<img src="https://img.shields.io/badge/version-v0.7.1--beta-blue" alt="Version">
<img src="https://img.shields.io/badge/license-AGPL--3.0--or--later-orange" alt="License">
<img src="https://img.shields.io/badge/status-beta-yellow" alt="Status">
<img src="https://img.shields.io/badge/selfhosted-ready-success" alt="Selfhosted">
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
28 changes: 28 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
17 changes: 16 additions & 1 deletion backend/app/api/routes_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
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 @@ -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'],
Expand Down
2 changes: 1 addition & 1 deletion backend/app/core/system_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions backend/app/services/bgp_visibility_service.py
Original file line number Diff line number Diff line change
@@ -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"],
}
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
49 changes: 48 additions & 1 deletion backend/tests/test_api_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading