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: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 8 additions & 2 deletions backend/app/api/routes_change_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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})
Expand All @@ -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})
Expand Down Expand Up @@ -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"}
8 changes: 7 additions & 1 deletion backend/app/core/system_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
15 changes: 15 additions & 0 deletions backend/app/templates/report.html.j2
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@
<li><strong>Explanation:</strong> {{ report.checks.routing_visibility.explanation if report.checks and report.checks.routing_visibility else '-' }}</li>
<li><strong>Risk:</strong> {{ report.checks.routing_visibility.risk if report.checks and report.checks.routing_visibility else '-' }}</li>
</ul>
<h2>Extended Routing/Validation Details</h2>
<ul>
<li><strong>RPKI Provider:</strong> {{ report.details.provider if report.details and report.details.provider else '-' }}</li>
<li><strong>RPKI Validation Status:</strong> {{ report.details.raw_status if report.details and report.details.raw_status is not none else '-' }}</li>
<li><strong>RPKI Fallback Used:</strong> {{ report.details.fallback_used if report.details else '-' }}</li>
<li><strong>RPKI Fallback Reason:</strong> {{ report.details.fallback_reason if report.details and report.details.fallback_reason else '-' }}</li>
<li><strong>RPKI Provider Disagreement:</strong> {{ report.details.provider_disagreement if report.details else '-' }}</li>
<li><strong>Matched ROAs:</strong> {{ (report.details.matched_roas | length) if report.details and report.details.matched_roas else 0 }}</li>
<li><strong>BGP Source Agreement:</strong> {{ report.details.source_agreement if report.details and report.details.source_agreement is not none else '-' }}</li>
<li><strong>BGP Confidence Score:</strong> {{ report.details.confidence_score if report.details and report.details.confidence_score is not none else '-' }}</li>
<li><strong>Visible Origins by Source:</strong> <pre>{{ report.details.visible_origins_by_source | tojson(indent=2) if report.details and report.details.visible_origins_by_source else '-' }}</pre></li>
<li><strong>Conflicting Origins:</strong> {{ report.details.conflicting_origins | tojson if report.details and report.details.conflicting_origins else '[]' }}</li>
<li><strong>Required Actions:</strong> {{ report.details.required_actions | tojson if report.details and report.details.required_actions else '[]' }}</li>
<li><strong>Post-Change Verification Status:</strong> {{ report.details.post_change_status if report.details and report.details.post_change_status 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>Freshness</th><th>Attempts</th><th>Retries</th><th>Fallback</th><th>Stale Cache</th><th>Dauer</th><th>Cache</th><th>Cache Age</th><th>TTL</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.freshness or 'UNKNOWN' }}</td><td>{{ d.attempts if d.attempts is not none else '-' }}</td><td>{{ d.retry_count if d.retry_count is not none else '-' }}</td><td>{{ 'yes' if d.fallback_used is sameas true else ('no' if d.fallback_used is sameas false else '-') }}</td><td>{{ 'yes' if d.stale_cache_used is sameas true else ('no' if d.stale_cache_used is sameas false else '-') }}</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.cache_age_seconds if d.cache_age_seconds is not none else 'Unknown' }}</td><td>{{ d.cache_ttl_seconds if d.cache_ttl_seconds is not none else 'Unknown' }}</td><td>{{ d.message or '-' }}{% if d.fallback_used is sameas true %}<div><em>Live request failed. Cached data was used.</em></div>{% endif %}{% 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 %}
Expand Down
14 changes: 14 additions & 0 deletions backend/app/templates/report.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
25 changes: 25 additions & 0 deletions backend/tests/test_api_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
14 changes: 14 additions & 0 deletions docs/architecture/providers.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions docs/operations/bgp-visibility-providers.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions docs/operations/change-case-workflow.md
Original file line number Diff line number Diff line change
@@ -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`.
8 changes: 8 additions & 0 deletions docs/operations/rpki-provider.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions docs/operations/watch-alerts.md
Original file line number Diff line number Diff line change
@@ -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=<hex>` over `timestamp.body`.
- Additional headers:
- `X-RouteForge-Event`
- `X-RouteForge-Timestamp`
- Payload includes watch identifiers, previous/current status, summary, report ID and timestamp.
- Watch run records expose `alert_delivery_status`, `alert_delivered_at`, and truncated `alert_error_message`.
- Secrets are never returned in status payloads, reports, or UI.
2 changes: 2 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ export const runAsnCheck = (asn: string, change_case_id?: number) => requestJson
export const deleteChangeCase = (id: number) => requestJson<{ ok: boolean; detached_checks: number }>(apiUrl(`/api/change-cases/${id}`), { method: 'DELETE' })
export const runPrefixCheck = (prefix: string, origin_as?: string, change_case_id?: number) => checkPrefix(prefix, origin_as, change_case_id)
export const runPreflightCheck = (prefix: string, planned_origin_as: string, change_case_id?: number) => checkPreflight(prefix, planned_origin_as, change_case_id)
export const runChangeCasePreflight = (id: number) => requestJson<{ change_case_id:number; decision:string; required_actions:string[]; risk_summary:string }>(apiUrl(`/api/change-cases/${id}/run-preflight`), { method: 'POST' })
export const runChangeCasePostChangeVerification = (id: number) => requestJson<{ change_case_id:number; post_change_status:string; verification_summary:string; detected_issues:string[] }>(apiUrl(`/api/change-cases/${id}/run-post-change-verification`), { method: 'POST' })

export const runBgpVisibilityCheck = (prefix: string, expected_origin_as?: string, change_case_id?: number) => requestJson<CheckResponse>(apiUrl('/api/check/bgp-visibility'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prefix, expected_origin_as: expected_origin_as || null, change_case_id: change_case_id ?? null }) })

Expand Down
10 changes: 9 additions & 1 deletion frontend/src/components/ChangeCasesView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { ApiError, createChangeCase, deleteChangeCase, getChangeCaseReports, getReportHtml, getReportMarkdown, getReportSummary, listChangeCases, runAsnCheck, runBgpVisibilityCheck, runPrefixCheck, runPreflightCheck, runRoaPreflightCheck, updateChangeCase } from '../api'
import { ApiError, createChangeCase, deleteChangeCase, getChangeCaseReports, getReportHtml, getReportMarkdown, getReportSummary, listChangeCases, runAsnCheck, runBgpVisibilityCheck, runChangeCasePostChangeVerification, runChangeCasePreflight, runPrefixCheck, runPreflightCheck, runRoaPreflightCheck, updateChangeCase } from '../api'
import type { ChangeCaseItem, UserRole } from '../types'
import { StatusBadge } from './StatusBadge'

Expand Down Expand Up @@ -74,6 +74,12 @@ export function ChangeCasesView({ role }: { role: UserRole }) {
{canEdit && <button className='rf-btn-secondary' onClick={()=>setEditing(v=>!v)}>{editing ? 'Cancel' : 'Edit'}</button>}
</div>
<p>{selected.description || '—'}</p>
<div className='rounded border bg-slate-50 p-3 text-sm space-y-1'>
<div><b>Preflight Decision:</b> {selected.decision || 'UNKNOWN'}</div>
<div><b>Risk Summary:</b> {selected.risk_summary || 'Not available yet.'}</div>
<div><b>Post-Change Verification:</b> {selected.post_change_status || 'Not run yet.'}</div>
<div><b>Required Actions:</b> {(selected.required_actions && selected.required_actions.length > 0) ? selected.required_actions.join(' · ') : 'None recorded.'}</div>
</div>

{canEdit && editing && <div className='space-y-2'>
<input className='rf-input' value={editTitle} onChange={e=>setEditTitle(e.target.value)} />
Expand All @@ -83,6 +89,8 @@ export function ChangeCasesView({ role }: { role: UserRole }) {
</div>}

{canEdit && <div className='flex flex-wrap gap-2'>
<button className='rf-btn-primary' onClick={()=>runAction(async()=>{await runChangeCasePreflight(selected.id); await load();}, 'Change case preflight completed.')}>Run Case Preflight</button>
<button className='rf-btn-primary' onClick={()=>runAction(async()=>{await runChangeCasePostChangeVerification(selected.id); await load();}, 'Post-change verification completed.')}>Run Post-Change Verification</button>
{(statusActions[selected.status] || []).map((action)=><button key={action.to} className='rf-btn-secondary' onClick={()=>runAction(async()=>{await updateChangeCase(selected.id,{status:action.to}); await load();}, `Status changed to ${action.to}.`)}>{action.label}</button>)}
{selected.status === 'closed' && <p className='text-sm text-slate-500'>Case is closed (read-only workflow state).</p>}
</div>}
Expand Down
Loading
Loading