{title}
{item.summary || '-'}
diff --git a/.env.example b/.env.example index 04e0ad5..b52d5b0 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,7 @@ ALERT_WEBHOOK_SECRET= ALERT_ON_STATUS_CHANGE_ONLY=true ALERT_WEBHOOK_TIMEOUT_SECONDS=5 ALERT_WEBHOOK_MAX_RETRIES=1 + +# Watch alerting +ALERT_WEBHOOK_MAX_RETRIES=2 +ALERT_WEBHOOK_TIMEOUT_SECONDS=5 diff --git a/README.md b/README.md index 8c99426..34971c4 100644 --- a/README.md +++ b/README.md @@ -304,3 +304,11 @@ In the standard Docker setup, API calls are same-origin via frontend nginx (`/ap ## BGP Visibility Details - 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. + + +## Operational docs for provider/workflow extensions +- docs/operations/rpki-provider.md +- docs/operations/bgp-visibility-providers.md +- docs/operations/change-case-workflow.md +- docs/operations/watch-alerts.md +- docs/architecture/providers.md diff --git a/backend/app/api/routes_change_cases.py b/backend/app/api/routes_change_cases.py index 37584a8..b5e7904 100644 --- a/backend/app/api/routes_change_cases.py +++ b/backend/app/api/routes_change_cases.py @@ -60,8 +60,8 @@ def patch_change_case(change_case_id: int, payload: ChangeCaseUpdate, db: Sessio def run_change_case_preflight(change_case_id: int, db: Session = Depends(get_db), user=Depends(require_role('operator','admin'))): cc = db.query(ChangeCase).filter(ChangeCase.id == change_case_id).first() if not cc: raise HTTPException(status_code=404, detail='Change Case not found') - prefixes = cc.affected_prefixes or [] - origins = cc.planned_origin_asns or [] + prefixes = [str(p).strip() for p in (cc.affected_prefixes or []) if str(p).strip()] + origins = [str(o).strip() for o in (cc.planned_origin_asns or []) if str(o).strip()] if not prefixes or not origins: raise HTTPException(status_code=400, detail='Change case requires affected_prefixes and planned_origin_asns') decisions=[]; actions=[] for pfx in prefixes: @@ -72,6 +72,8 @@ def run_change_case_preflight(change_case_id: int, db: Session = Depends(get_db) actions.append(f'Review preflight findings for {pfx} {origin}') cc.last_preflight_at=datetime.utcnow(); cc.required_actions=sorted(set(actions)) cc.decision='NO-GO' if 'CRITICAL' in decisions else 'CAUTION' if 'WARNING' in decisions else 'UNKNOWN' if all(d=='UNKNOWN' for d in decisions) else 'GO' + if cc.decision not in ALLOWED_DECISIONS: + cc.decision = "UNKNOWN" cc.risk_summary=f'Automated preflight decision: {cc.decision}' db.commit(); db.refresh(cc) write_audit_log(db, user_id=user.id, action='change_case_preflight_completed', target_type='change_case', target_id=str(cc.id), details_json={'decision': cc.decision}) @@ -82,6 +84,8 @@ def run_post_change_verification(change_case_id: int, db: Session = Depends(get_ cc = db.query(ChangeCase).filter(ChangeCase.id == change_case_id).first() if not cc: raise HTTPException(status_code=404, detail='Change Case not found') status='VERIFIED' if cc.decision=='GO' else 'PARTIAL' if cc.decision=='CAUTION' else 'FAILED' if cc.decision=='NO-GO' else 'UNKNOWN' + if status not in ALLOWED_POST_CHANGE_STATUSES: + status = "UNKNOWN" cc.post_change_status=status; cc.last_verification_at=datetime.utcnow() db.commit(); db.refresh(cc) write_audit_log(db, user_id=user.id, action='post_change_verification_completed', target_type='change_case', target_id=str(cc.id), details_json={'post_change_status': status}) @@ -112,3 +116,5 @@ def list_change_case_reports(change_case_id: int, db: Session = Depends(get_db), .all() ) return [{'report_id': r.id, 'check_id': c.id, 'check_type': c.check_type, 'summary': c.summary, 'status': c.status, 'created_at': r.created_at.isoformat()} for r, c in rows] +ALLOWED_DECISIONS = {"GO", "CAUTION", "NO-GO", "UNKNOWN"} +ALLOWED_POST_CHANGE_STATUSES = {"VERIFIED", "PARTIAL", "FAILED", "UNKNOWN"} diff --git a/backend/app/core/system_status.py b/backend/app/core/system_status.py index 92cb196..30d5a19 100644 --- a/backend/app/core/system_status.py +++ b/backend/app/core/system_status.py @@ -161,7 +161,13 @@ def build_system_status(engine: Engine | None) -> dict: "api_proxy": {"status": "ok", "mode": "same-origin", "frontend_proxy_expected": True}, "rpki": {"provider": settings.rpki_provider, "fallback_to_ripestat": settings.rpki_fallback_to_ripestat, "routinator_url": settings.rpki_routinator_url, "local_json_path": settings.rpki_local_json_path, "timeout_seconds": settings.rpki_provider_timeout_seconds}, "bgp_visibility": {"providers": [x.strip() for x in settings.bgp_visibility_providers.split(",") if x.strip()], "require_source_agreement": settings.bgp_visibility_require_source_agreement, "min_confidence": settings.bgp_visibility_min_confidence}, - "alerts": {"webhook_enabled": settings.alert_webhook_enabled, "webhook_url_configured": bool(settings.alert_webhook_url), "on_status_change_only": settings.alert_on_status_change_only}, + "alerts": { + "webhook_enabled": settings.alert_webhook_enabled, + "webhook_url_configured": bool(settings.alert_webhook_url), + "on_status_change_only": settings.alert_on_status_change_only, + "max_retries": settings.alert_webhook_max_retries, + "timeout_seconds": settings.alert_webhook_timeout_seconds, + }, "ripestat": { "cache_ttl_seconds": settings.cache_ttl_seconds, "timeout_seconds": settings.ripestat_timeout_seconds, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e99d592..55367c5 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -25,6 +25,17 @@ class ChangeCaseRead(BaseModel): created_by_user_id: int | None created_at: datetime updated_at: datetime + affected_prefixes: list[str] | None = None + planned_origin_asns: list[str] | None = None + risk_summary: str | None = None + decision: str | None = None + required_actions: list[str] | None = None + post_change_status: str | None = None + last_preflight_at: datetime | None = None + last_verification_at: datetime | None = None + + class Config: + from_attributes = True class AsnCheckRequest(BaseModel): @@ -207,6 +218,9 @@ class WatchRunRead(BaseModel): status: str changed: bool summary: str + alert_delivery_status: str | None = None + alert_delivered_at: datetime | None = None + alert_error_message: str | None = None created_at: datetime class Config: from_attributes = True diff --git a/backend/app/templates/report.html.j2 b/backend/app/templates/report.html.j2 index c7d32ad..8e8b5ba 100644 --- a/backend/app/templates/report.html.j2 +++ b/backend/app/templates/report.html.j2 @@ -48,6 +48,21 @@
{{ report.details.visible_origins_by_source | tojson(indent=2) if report.details and report.details.visible_origins_by_source else '-' }}| Quelle | Endpoint | Status | Freshness | Attempts | Retries | Fallback | Stale Cache | Dauer | Cache | Cache Age | TTL | Message |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ d.name or '-' }} | {{ d.endpoint or '-' }} | {{ d.status or '-' }} | {{ d.freshness or 'UNKNOWN' }} | {{ d.attempts if d.attempts is not none else '-' }} | {{ d.retry_count if d.retry_count is not none else '-' }} | {{ 'yes' if d.fallback_used is sameas true else ('no' if d.fallback_used is sameas false else '-') }} | {{ 'yes' if d.stale_cache_used is sameas true else ('no' if d.stale_cache_used is sameas false else '-') }} | {{ d.duration_ms if d.duration_ms is not none else '-' }} | {{ 'HIT' if d.cached is sameas true else ('MISS' if d.cached is sameas false else '-') }} | {{ d.cache_age_seconds if d.cache_age_seconds is not none else 'Unknown' }} | {{ d.cache_ttl_seconds if d.cache_ttl_seconds is not none else 'Unknown' }} | {{ d.message or '-' }}{% if d.fallback_used is sameas true %} Live request failed. Cached data was used. {% endif %}{% if d.details %}Details{{ d.details | tojson(indent=2) }} |
No source diagnostics available.
{% endif %} diff --git a/backend/app/templates/report.md.j2 b/backend/app/templates/report.md.j2 index 4ff4cee..3137c70 100644 --- a/backend/app/templates/report.md.j2 +++ b/backend/app/templates/report.md.j2 @@ -41,6 +41,20 @@ - Explanation: {{ report.checks.routing_visibility.explanation if report.checks and report.checks.routing_visibility else '-' }} - Risk: {{ report.checks.routing_visibility.risk if report.checks and report.checks.routing_visibility else '-' }} +## Extended Routing/Validation Details +- RPKI Provider: {{ report.details.provider if report.details and report.details.provider else '-' }} +- RPKI Validation Status: {{ report.details.raw_status if report.details and report.details.raw_status is not none else '-' }} +- RPKI Fallback Used: {{ report.details.fallback_used if report.details else '-' }} +- RPKI Fallback Reason: {{ report.details.fallback_reason if report.details and report.details.fallback_reason else '-' }} +- RPKI Provider Disagreement: {{ report.details.provider_disagreement if report.details else '-' }} +- Matched ROAs: {{ (report.details.matched_roas | length) if report.details and report.details.matched_roas else 0 }} +- BGP Source Agreement: {{ report.details.source_agreement if report.details and report.details.source_agreement is not none else '-' }} +- BGP Confidence Score: {{ report.details.confidence_score if report.details and report.details.confidence_score is not none else '-' }} +- Visible Origins by Source: {{ report.details.visible_origins_by_source | tojson if report.details and report.details.visible_origins_by_source else '-' }} +- Conflicting Origins: {{ report.details.conflicting_origins | tojson if report.details and report.details.conflicting_origins else '[]' }} +- Required Actions: {{ report.details.required_actions | tojson if report.details and report.details.required_actions else '[]' }} +- Post-Change Verification Status: {{ report.details.post_change_status if report.details and report.details.post_change_status else '-' }} + ## Data Source Diagnostics {% if report.details and report.details.source_diagnostics %} | Quelle | Endpoint | Status | Freshness | Attempts | Retries | Fallback | Stale Cache | Dauer | Cache | Cache Age | TTL | Message | diff --git a/backend/tests/test_api_smoke.py b/backend/tests/test_api_smoke.py index a57f4af..52475a4 100644 --- a/backend/tests/test_api_smoke.py +++ b/backend/tests/test_api_smoke.py @@ -563,3 +563,28 @@ def test_roa_preflight_suggested_roa_and_invalid_max_length() -> None: critical = client.post('/api/check/roa-preflight', json={'prefix':'203.0.113.0/24','origin_as':'AS3320','max_length':23}) assert critical.status_code == 200 assert critical.json()['status'] == 'CRITICAL' + +def test_change_case_workflow_endpoints_and_watch_alert_fields() -> None: + client = _client() + _setup_and_login(client) + created = client.post('/api/change-cases', json={'title': 'Workflow', 'description': 'x'}) + cid = created.json()['id'] + client.patch(f'/api/change-cases/{cid}', json={'description': 'with preflight data'}) + + # missing validation data + bad = client.post(f'/api/change-cases/{cid}/run-preflight') + assert bad.status_code == 400 + + +def test_system_status_alerts_no_secret_fields() -> None: + client = _client() + _setup_and_login(client) + resp = client.get('/api/system/status') + assert resp.status_code == 200 + payload = resp.json() + alerts = payload.get('alerts', {}) + assert 'webhook_enabled' in alerts + assert 'on_status_change_only' in alerts + assert 'max_retries' in alerts + assert 'timeout_seconds' in alerts + assert 'secret' not in ''.join(alerts.keys()).lower() diff --git a/docs/architecture/providers.md b/docs/architecture/providers.md new file mode 100644 index 0000000..90d17f6 --- /dev/null +++ b/docs/architecture/providers.md @@ -0,0 +1,14 @@ +# Provider Architecture (Read-Only) + +RouteForge provider integrations are read-only and only fetch/aggregate external routing and registry data. + +## RPKI +- Provider abstraction: RIPEstat, Routinator, local JSON, auto fallback. +- Diagnostics: fallback usage, disagreement flags, source diagnostics. + +## BGP Visibility +- Multi-source aggregation with provider-level diagnostics and confidence scoring. + +## Security Model +- No write operations against routers, RIPE DB, IRR, or ROA systems. +- No secrets in frontend, reports, logs, or audit details. diff --git a/docs/operations/bgp-visibility-providers.md b/docs/operations/bgp-visibility-providers.md new file mode 100644 index 0000000..266ebc8 --- /dev/null +++ b/docs/operations/bgp-visibility-providers.md @@ -0,0 +1,13 @@ +# BGP Visibility Providers + +- `BGP_VISIBILITY_PROVIDERS` supports comma-separated providers (currently `ripestat,generic-http`). +- `generic-http` uses `BGP_GENERIC_URL_TEMPLATE` with `{prefix}` interpolation. +- Multi-source output includes: + - provider/source list + - visible origins by source + - all visible origins + - source agreement + - confidence score + - failed provider count + - source diagnostics +- Conflicting origins and missing expected origin are exposed in reports/UI. diff --git a/docs/operations/change-case-workflow.md b/docs/operations/change-case-workflow.md new file mode 100644 index 0000000..b41dec3 --- /dev/null +++ b/docs/operations/change-case-workflow.md @@ -0,0 +1,11 @@ +# Change Case Workflow + +1. Create change case. +2. Populate `affected_prefixes` and `planned_origin_asns`. +3. Run `POST /api/change-cases/{id}/run-preflight`. +4. Review `decision`, `risk_summary`, and `required_actions`. +5. Execute routing change outside RouteForge (RouteForge is read-only). +6. Run `POST /api/change-cases/{id}/run-post-change-verification`. +7. Review `post_change_status` (`VERIFIED`, `PARTIAL`, `FAILED`, `UNKNOWN`) and detected issues. + +Decision values: `GO`, `CAUTION`, `NO-GO`, `UNKNOWN`. diff --git a/docs/operations/rpki-provider.md b/docs/operations/rpki-provider.md new file mode 100644 index 0000000..2062212 --- /dev/null +++ b/docs/operations/rpki-provider.md @@ -0,0 +1,8 @@ +# RPKI Provider Operations + +- `RPKI_PROVIDER`: `ripestat`, `routinator`, `local-json`, or `auto`. +- `auto` tries local provider first (`routinator`) and can fallback to RIPEstat if `RPKI_FALLBACK_TO_RIPESTAT=true`. +- `RPKI_ROUTINATOR_URL` points to a read-only Routinator API endpoint. +- `RPKI_LOCAL_JSON_PATH` is used by `local-json` mode and must contain ROA-like JSON entries. +- Runtime output includes provider, fallback flags, disagreement indicators, and matched ROAs. +- RouteForge remains read-only: no ROA creation/writes. diff --git a/docs/operations/watch-alerts.md b/docs/operations/watch-alerts.md new file mode 100644 index 0000000..b69d34e --- /dev/null +++ b/docs/operations/watch-alerts.md @@ -0,0 +1,15 @@ +# Watch Webhook Alerts + +- Alerts are controlled by: + - `ALERT_WEBHOOK_ENABLED` + - `ALERT_WEBHOOK_URL` + - `ALERT_ON_STATUS_CHANGE_ONLY` + - `ALERT_WEBHOOK_MAX_RETRIES` + - `ALERT_WEBHOOK_TIMEOUT_SECONDS` +- HMAC header (optional): `X-RouteForge-Signature: sha256={selected.description || '—'}
+Case is closed (read-only workflow state).
}{item.summary || '-'}
| Source | Endpoint | Status | Freshness | Attempts | Retries | Fallback | Stale Cache | Duration | Cache | Age | TTL | Message |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {d.name || '-'} | {d.endpoint || '-'} | {d.freshness || 'UNKNOWN'} | {typeof d.attempts === 'number' ? d.attempts : '-'} | {typeof d.retry_count === 'number' ? d.retry_count : '-'} | {d.fallback_used === true ? 'yes' : d.fallback_used === false ? 'no' : '-'} | {d.stale_cache_used === true ? 'yes' : d.stale_cache_used === false ? 'no' : '-'} | {typeof d.duration_ms === 'number' ? `${d.duration_ms} ms` : '-'} | {d.cached === true ? 'HIT' : d.cached === false ? 'MISS' : '-'} | {typeof d.cache_age_seconds === 'number' ? formatDurationSeconds(d.cache_age_seconds) : 'Unknown'} | {typeof d.cache_ttl_seconds === 'number' ? formatDurationSeconds(d.cache_ttl_seconds) : 'Unknown'} | {d.message || '-'} {d.fallback_used === true && Live request failed. RouteForge used cached data. The result may be outdated. }{d.status === 'RATE_LIMITED' && Rate limited by data source. } |
| created_at | previous_status | status | changed | summary | report_id |
|---|---|---|---|---|---|
| {r.created_at} | {r.previous_status ?? 'n/a'} | {r.status} | {String(r.changed)} | {r.summary} | {r.report_id ? {r.report_id} : 'n/a'} |
| created_at | previous_status | status | changed | summary | alert_delivery | alert_delivered_at | alert_error | report_id |
|---|---|---|---|---|---|---|---|---|
| {r.created_at} | {r.previous_status ?? 'n/a'} | {r.status} | {String(r.changed)} | {r.summary} | {r.alert_delivery_status ?? 'n/a'} | {r.alert_delivered_at ?? 'n/a'} | {(r.alert_error_message ?? 'n/a').slice(0, 120)} | {r.report_id ? {r.report_id} : 'n/a'} |