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
25 changes: 25 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,28 @@ SECRET_KEY=change-me
SESSION_COOKIE_NAME=routeforge_session
SESSION_EXPIRE_HOURS=12
COOKIE_SECURE=false

# RPKI Provider
RPKI_PROVIDER=ripestat
RPKI_ROUTINATOR_URL=http://routinator:8323
RPKI_LOCAL_JSON_PATH=
RPKI_PROVIDER_TIMEOUT_SECONDS=5
RPKI_FALLBACK_TO_RIPESTAT=true

# BGP Multi-source
BGP_VISIBILITY_PROVIDERS=ripestat
BGP_GENERIC_URL_TEMPLATE=
BGP_PROVIDER_TIMEOUT_SECONDS=5
BGP_VISIBILITY_REQUIRE_SOURCE_AGREEMENT=false
BGP_VISIBILITY_MIN_CONFIDENCE=60

# Change workflow
POST_CHANGE_DEFAULT_RECHECK_MINUTES=15,30,60

# Watch alerts
ALERT_WEBHOOK_ENABLED=false
ALERT_WEBHOOK_URL=
ALERT_WEBHOOK_SECRET=
ALERT_ON_STATUS_CHANGE_ONLY=true
ALERT_WEBHOOK_TIMEOUT_SECONDS=5
ALERT_WEBHOOK_MAX_RETRIES=1
35 changes: 35 additions & 0 deletions backend/alembic/versions/0005_providers_and_changecase_workflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""providers and changecase workflow

Revision ID: 0005_providers_and_changecase_workflow
Revises: 0004_watch_mode
"""
from alembic import op
import sqlalchemy as sa

revision = '0005_providers_and_changecase_workflow'
down_revision = '0004_watch_mode'
branch_labels = None
depends_on = None

def upgrade() -> None:
op.add_column('change_cases', sa.Column('planned_start', sa.DateTime(), nullable=True))
op.add_column('change_cases', sa.Column('planned_end', sa.DateTime(), nullable=True))
op.add_column('change_cases', sa.Column('change_type', sa.String(length=40), nullable=True))
op.add_column('change_cases', sa.Column('affected_prefixes', sa.JSON(), nullable=True))
op.add_column('change_cases', sa.Column('planned_origin_asns', sa.JSON(), nullable=True))
op.add_column('change_cases', sa.Column('risk_summary', sa.Text(), nullable=True))
op.add_column('change_cases', sa.Column('decision', sa.String(length=20), nullable=True))
op.add_column('change_cases', sa.Column('required_actions', sa.JSON(), nullable=True))
op.add_column('change_cases', sa.Column('post_change_status', sa.String(length=20), nullable=True))
op.add_column('change_cases', sa.Column('last_preflight_at', sa.DateTime(), nullable=True))
op.add_column('change_cases', sa.Column('last_verification_at', sa.DateTime(), nullable=True))
op.add_column('watch_runs', sa.Column('alert_delivery_status', sa.String(length=30), nullable=True))
op.add_column('watch_runs', sa.Column('alert_delivered_at', sa.DateTime(), nullable=True))
op.add_column('watch_runs', sa.Column('alert_error_message', sa.Text(), nullable=True))

def downgrade() -> None:
op.drop_column('watch_runs', 'alert_error_message')
op.drop_column('watch_runs', 'alert_delivered_at')
op.drop_column('watch_runs', 'alert_delivery_status')
for c in ['last_verification_at','last_preflight_at','post_change_status','required_actions','decision','risk_summary','planned_origin_asns','affected_prefixes','change_type','planned_end','planned_start']:
op.drop_column('change_cases', c)
36 changes: 36 additions & 0 deletions backend/app/api/routes_change_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,42 @@ def patch_change_case(change_case_id: int, payload: ChangeCaseUpdate, db: Sessio
write_audit_log(db, user_id=user.id, action='change_case_status_changed', target_type='change_case', target_id=str(cc.id), details_json={'from': old_status, 'to': cc.status})
return cc


from datetime import datetime
from app.services.preflight_checker import PreflightChecker
from app.services.ripe_stat_client import RipeStatClient

@router.post('/{change_case_id}/run-preflight')
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 []
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:
for origin in origins:
result=PreflightChecker(RipeStatClient(db)).check(pfx, origin)
decisions.append(result.get('status'))
if result.get('status') in {'WARNING','CRITICAL','UNKNOWN'}:
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'
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})
return {'change_case_id': cc.id, 'decision': cc.decision, 'required_actions': cc.required_actions, 'risk_summary': cc.risk_summary}

@router.post('/{change_case_id}/run-post-change-verification')
def run_post_change_verification(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')
status='VERIFIED' if cc.decision=='GO' else 'PARTIAL' if cc.decision=='CAUTION' else 'FAILED' if cc.decision=='NO-GO' else '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})
return {'change_case_id': cc.id, 'post_change_status': status, 'verification_summary': f'Post-change verification status: {status}', 'detected_issues': cc.required_actions or []}

@router.delete('/{change_case_id}')
def delete_change_case(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()
Expand Down
18 changes: 18 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ class Settings(BaseSettings):
cookie_samesite: str = Field(default="lax", validation_alias="COOKIE_SAMESITE")
allow_sqlite_create_all: bool = Field(default=True, validation_alias="ALLOW_SQLITE_CREATE_ALL")

rpki_provider: str = Field(default="ripestat", validation_alias="RPKI_PROVIDER")
rpki_routinator_url: str = Field(default="http://routinator:8323", validation_alias="RPKI_ROUTINATOR_URL")
rpki_local_json_path: str = Field(default="", validation_alias="RPKI_LOCAL_JSON_PATH")
rpki_provider_timeout_seconds: float = Field(default=5, validation_alias="RPKI_PROVIDER_TIMEOUT_SECONDS")
rpki_fallback_to_ripestat: bool = Field(default=True, validation_alias="RPKI_FALLBACK_TO_RIPESTAT")
bgp_visibility_providers: str = Field(default="ripestat", validation_alias="BGP_VISIBILITY_PROVIDERS")
bgp_generic_url_template: str = Field(default="", validation_alias="BGP_GENERIC_URL_TEMPLATE")
bgp_provider_timeout_seconds: float = Field(default=5, validation_alias="BGP_PROVIDER_TIMEOUT_SECONDS")
bgp_visibility_require_source_agreement: bool = Field(default=False, validation_alias="BGP_VISIBILITY_REQUIRE_SOURCE_AGREEMENT")
bgp_visibility_min_confidence: int = Field(default=60, validation_alias="BGP_VISIBILITY_MIN_CONFIDENCE")
post_change_default_recheck_minutes: str = Field(default="15,30,60", validation_alias="POST_CHANGE_DEFAULT_RECHECK_MINUTES")
alert_webhook_enabled: bool = Field(default=False, validation_alias="ALERT_WEBHOOK_ENABLED")
alert_webhook_url: str = Field(default="", validation_alias="ALERT_WEBHOOK_URL")
alert_webhook_secret: str = Field(default="", validation_alias="ALERT_WEBHOOK_SECRET")
alert_on_status_change_only: bool = Field(default=True, validation_alias="ALERT_ON_STATUS_CHANGE_ONLY")
alert_webhook_timeout_seconds: float = Field(default=5, validation_alias="ALERT_WEBHOOK_TIMEOUT_SECONDS")
alert_webhook_max_retries: int = Field(default=1, validation_alias="ALERT_WEBHOOK_MAX_RETRIES")

model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")


Expand Down
3 changes: 3 additions & 0 deletions backend/app/core/system_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ def build_system_status(engine: Engine | None) -> dict:
"demo_mode": settings.demo_mode,
"database": database,
"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},
"ripestat": {
"cache_ttl_seconds": settings.cache_ttl_seconds,
"timeout_seconds": settings.ripestat_timeout_seconds,
Expand Down
14 changes: 14 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ class User(Base):
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(20), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
alert_delivery_status: Mapped[str | None] = mapped_column(String(30), nullable=True)
alert_delivered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
alert_error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
Expand All @@ -28,6 +31,17 @@ class ChangeCase(Base):
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
planned_start: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
planned_end: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
change_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
affected_prefixes: Mapped[list | None] = mapped_column(JSON, nullable=True)
planned_origin_asns: Mapped[list | None] = mapped_column(JSON, nullable=True)
risk_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
decision: Mapped[str | None] = mapped_column(String(20), nullable=True)
required_actions: Mapped[list | None] = mapped_column(JSON, nullable=True)
post_change_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
last_preflight_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_verification_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
checks: Mapped[list["Check"]] = relationship(back_populates="change_case")


Expand Down
21 changes: 21 additions & 0 deletions backend/app/services/alerting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import httpx
import hmac, hashlib, json
from datetime import datetime, timezone
from app.config import settings

def send_watch_webhook(payload: dict) -> tuple[str,str|None]:
if not settings.alert_webhook_enabled:
return 'skipped_disabled', None
if not settings.alert_webhook_url:
return 'skipped_no_url', 'Webhook enabled but URL missing'
ts=datetime.now(timezone.utc).isoformat()
headers={'X-RouteForge-Event':payload.get('event','watch_status_changed'),'X-RouteForge-Timestamp':ts}
body=json.dumps(payload, separators=(',',':'))
if settings.alert_webhook_secret:
sig=hmac.new(settings.alert_webhook_secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
headers['X-RouteForge-Signature']=f'sha256={sig}'
try:
httpx.post(settings.alert_webhook_url,data=body,headers={**headers,'Content-Type':'application/json'},timeout=settings.alert_webhook_timeout_seconds)
return 'sent', None
except Exception as exc:
return 'failed', str(exc)
Loading
Loading