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: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
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.2-beta**.
RouteForge is a **functional beta** release with production-like workflows for read-only validation and demo usage. Current release target: **v0.8.0-beta**.

## Quickstart with Docker Compose

Expand Down Expand Up @@ -276,6 +276,6 @@ In the standard Docker setup, API calls are same-origin via frontend nginx (`/ap
- Keep `SECRET_KEY` stable; changing it invalidates existing sessions.


## BGP Visibility Details (v0.7.2-beta)
## BGP Visibility Details (v0.8.0-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.
2 changes: 1 addition & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## v0.7.2-beta: BGP Visibility Details
## v0.8.0-beta: BGP Visibility Details

### Motivation
Improve prefix visibility checks with explicit BGP origin visibility details while preserving RouteForge's strict read-only model.
Expand Down
8 changes: 4 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
# RouteForge Roadmap

## Current Status
v0.7.2-beta, BGP Visibility Details completed, read-only
v0.8.0-beta, BGP Visibility Details completed, read-only

## v0.7.2-beta
## v0.8.0-beta
- projects/change cases
- grouped preflight reports

## v0.7.2-beta
## v0.8.0-beta
- bgp visibility details

## v0.7.2-beta
## v0.8.0-beta
- roa planner / roa preflight

## v0.8.0-beta
Expand Down
55 changes: 55 additions & 0 deletions backend/alembic/versions/0004_watch_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""watch mode

Revision ID: 0004_watch_mode
Revises: 0003_change_cases
Create Date: 2026-05-20
"""
from alembic import op
import sqlalchemy as sa

revision = '0004_watch_mode'
down_revision = '0003_change_cases'
branch_labels = None
depends_on = None

def upgrade() -> None:
op.create_table(
'watch_targets',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('watch_type', sa.String(length=30), nullable=False),
sa.Column('prefix', sa.String(length=120), nullable=True),
sa.Column('asn', sa.String(length=20), nullable=True),
sa.Column('origin_as', sa.String(length=20), nullable=True),
sa.Column('expected_origin_as', sa.String(length=20), nullable=True),
sa.Column('max_length', sa.Integer(), nullable=True),
sa.Column('interval_minutes', sa.Integer(), nullable=False, server_default='60'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
sa.Column('change_case_id', sa.Integer(), sa.ForeignKey('change_cases.id'), nullable=True),
sa.Column('created_by_user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
sa.Column('last_run_at', sa.DateTime(), nullable=True),
sa.Column('next_run_at', sa.DateTime(), nullable=True),
sa.Column('last_status', sa.String(length=20), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
)
op.create_index(op.f('ix_watch_targets_id'), 'watch_targets', ['id'])

op.create_table(
'watch_runs',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('watch_target_id', sa.Integer(), sa.ForeignKey('watch_targets.id'), nullable=False),
sa.Column('report_id', sa.Integer(), sa.ForeignKey('reports.id'), nullable=True),
sa.Column('previous_status', sa.String(length=20), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('changed', sa.Boolean(), nullable=False, server_default=sa.text('0')),
sa.Column('summary', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
)
op.create_index(op.f('ix_watch_runs_id'), 'watch_runs', ['id'])

def downgrade() -> None:
op.drop_index(op.f('ix_watch_runs_id'), table_name='watch_runs')
op.drop_table('watch_runs')
op.drop_index(op.f('ix_watch_targets_id'), table_name='watch_targets')
op.drop_table('watch_targets')
77 changes: 77 additions & 0 deletions backend/app/api/routes_watch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from datetime import datetime

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session

from app.core.auth import require_authenticated_user, require_operator_or_admin
from app.core.audit import write_audit_log
from app.database import get_db
from app.models import ChangeCase, WatchRun, WatchTarget
from app.schemas import WatchRunRead, WatchTargetCreate, WatchTargetRead, WatchTargetUpdate
from app.services.watch_service import WatchService

router = APIRouter(prefix='/api/watch-targets', tags=['watch'])

@router.get('', response_model=list[WatchTargetRead])
def list_targets(db: Session = Depends(get_db), user=Depends(require_authenticated_user)):
return db.query(WatchTarget).order_by(WatchTarget.created_at.desc()).all()

@router.post('', response_model=WatchTargetRead)
def create_target(payload: WatchTargetCreate, db: Session = Depends(get_db), user=Depends(require_operator_or_admin)):
if payload.change_case_id is not None and not db.query(ChangeCase).filter(ChangeCase.id == payload.change_case_id).first():
raise HTTPException(status_code=404, detail='Change Case not found')
target = WatchTarget(**payload.model_dump(), created_by_user_id=user.id)
db.add(target); db.commit(); db.refresh(target)
write_audit_log(db, user_id=user.id, action='watch_target_created', target_type='watch_target', target_id=str(target.id), details_json={'watch_type': target.watch_type})
return target

@router.get('/{target_id}', response_model=WatchTargetRead)
def get_target(target_id:int, db:Session=Depends(get_db), user=Depends(require_authenticated_user)):
t=db.query(WatchTarget).filter(WatchTarget.id==target_id).first()
if not t: raise HTTPException(status_code=404, detail='Watch target not found')
return t

@router.patch('/{target_id}', response_model=WatchTargetRead)
def patch_target(target_id:int,payload:WatchTargetUpdate,db:Session=Depends(get_db),user=Depends(require_operator_or_admin)):
t=db.query(WatchTarget).filter(WatchTarget.id==target_id).first()
if not t: raise HTTPException(status_code=404, detail='Watch target not found')
for k,v in payload.model_dump(exclude_unset=True).items(): setattr(t,k,v)
db.commit(); db.refresh(t)
write_audit_log(db, user_id=user.id, action='watch_target_updated', target_type='watch_target', target_id=str(t.id), details_json={})
return t

@router.delete('/{target_id}')
def delete_target(target_id:int, db:Session=Depends(get_db), user=Depends(require_operator_or_admin)):
t=db.query(WatchTarget).filter(WatchTarget.id==target_id).first()
if not t: raise HTTPException(status_code=404, detail='Watch target not found')
db.delete(t); db.commit()
write_audit_log(db, user_id=user.id, action='watch_target_deleted', target_type='watch_target', target_id=str(target_id), details_json={})
return {'ok': True}

@router.get('/{target_id}/runs', response_model=list[WatchRunRead])
def list_runs(target_id:int, db:Session=Depends(get_db), user=Depends(require_authenticated_user)):
return db.query(WatchRun).filter(WatchRun.watch_target_id==target_id).order_by(WatchRun.created_at.desc()).all()

@router.post('/{target_id}/run', response_model=WatchRunRead)
def run_target(target_id:int, db:Session=Depends(get_db), user=Depends(require_operator_or_admin)):
t=db.query(WatchTarget).filter(WatchTarget.id==target_id).first()
if not t: raise HTTPException(status_code=404, detail='Watch target not found')
run=WatchService(db).run_target(t, user.id)
write_audit_log(db, user_id=user.id, action='watch_target_run', target_type='watch_target', target_id=str(target_id), details_json={'run_id': run.id})
if run.changed: write_audit_log(db, user_id=user.id, action='watch_target_status_changed', target_type='watch_target', target_id=str(target_id), details_json={'previous_status': run.previous_status, 'status': run.status})
return run

@router.post('/run-due')
def run_due(db:Session=Depends(get_db), user=Depends(require_operator_or_admin)):
now=datetime.utcnow(); targets=db.query(WatchTarget).filter(WatchTarget.is_active==True).all()
due=[t for t in targets if t.next_run_at is None or t.next_run_at <= now]
results=[]; changed=0; failed=0
for t in due:
try:
run=WatchService(db).run_target(t, user.id)
results.append({'watch_target_id': t.id, 'run_id': run.id, 'status': run.status, 'changed': run.changed})
if run.changed: changed += 1
except Exception as exc:
failed += 1; results.append({'watch_target_id': t.id, 'error': str(exc)})
write_audit_log(db, user_id=user.id, action='watch_targets_run_due', target_type='watch_target', target_id=None, details_json={'executed': len(due), 'changed': changed, 'failed': failed})
return {'executed': len(due), 'changed': changed, 'failed': failed, 'results': results}
5 changes: 4 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@
from app.api.routes_reports import router as reports_router
from app.api.routes_system import router as system_router
from app.api.routes_users import router as users_router
from app.api.routes_watch import router as watch_router
from app.config import settings
from app.core.system_status import database_type_from_url
from app.database import Base, engine

logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
logger = logging.getLogger("routeforge")

app = FastAPI(title="RouteForge", version="0.7.1")
app = FastAPI(title="RouteForge", version="0.8.0")

app.add_middleware(
CORSMiddleware,
Expand Down Expand Up @@ -56,3 +57,5 @@ def startup() -> None:
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(audit_router)

app.include_router(watch_router)
33 changes: 33 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,36 @@ class AuditLog(Base):
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
details_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)


class WatchTarget(Base):
__tablename__ = "watch_targets"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
watch_type: Mapped[str] = mapped_column(String(30), nullable=False)
prefix: Mapped[str | None] = mapped_column(String(120), nullable=True)
asn: Mapped[str | None] = mapped_column(String(20), nullable=True)
origin_as: Mapped[str | None] = mapped_column(String(20), nullable=True)
expected_origin_as: Mapped[str | None] = mapped_column(String(20), nullable=True)
max_length: Mapped[int | None] = mapped_column(Integer, nullable=True)
interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=60)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
change_case_id: Mapped[int | None] = mapped_column(ForeignKey("change_cases.id"), nullable=True)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
next_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_status: Mapped[str | None] = mapped_column(String(20), 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)


class WatchRun(Base):
__tablename__ = "watch_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
watch_target_id: Mapped[int] = mapped_column(ForeignKey("watch_targets.id"), nullable=False)
report_id: Mapped[int | None] = mapped_column(ForeignKey("reports.id"), nullable=True)
previous_status: Mapped[str | None] = mapped_column(String(20), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False)
changed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
summary: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
57 changes: 57 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,60 @@ class ReportRead(BaseModel):

class Config:
from_attributes = True


class WatchTargetCreate(BaseModel):
name: str
watch_type: str
prefix: str | None = None
asn: str | None = None
origin_as: str | None = None
expected_origin_as: str | None = None
max_length: int | None = None
interval_minutes: int = Field(default=60, ge=1)
is_active: bool = True
change_case_id: int | None = None

class WatchTargetUpdate(BaseModel):
name: str | None = None
prefix: str | None = None
asn: str | None = None
origin_as: str | None = None
expected_origin_as: str | None = None
max_length: int | None = None
interval_minutes: int | None = Field(default=None, ge=1)
is_active: bool | None = None
change_case_id: int | None = None

class WatchTargetRead(BaseModel):
id: int
name: str
watch_type: str
prefix: str | None
asn: str | None
origin_as: str | None
expected_origin_as: str | None
max_length: int | None
interval_minutes: int
is_active: bool
change_case_id: int | None
created_by_user_id: int | None
last_run_at: datetime | None
next_run_at: datetime | None
last_status: str | None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

class WatchRunRead(BaseModel):
id: int
watch_target_id: int
report_id: int | None
previous_status: str | None
status: str
changed: bool
summary: str
created_at: datetime
class Config:
from_attributes = True
52 changes: 52 additions & 0 deletions backend/app/services/watch_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from datetime import datetime, timedelta

from fastapi import HTTPException
from sqlalchemy.orm import Session

from app.models import ChangeCase, Check, Report, WatchRun, WatchTarget
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.report_renderer import render_report
from app.services.roa_planner_service import RoaPlannerService
from app.services.ripe_stat_client import RipeStatClient


class WatchService:
def __init__(self, db: Session):
self.db = db

def run_target(self, target: WatchTarget, user_id: int | None = None) -> WatchRun:
result, ctype, resource, origin = self._execute_check(target)
check = Check(check_type=ctype, input_resource=resource, origin_as=origin, status=result["status"], summary=result["summary"], created_by_user_id=user_id, change_case_id=target.change_case_id)
self.db.add(check)
self.db.commit()
self.db.refresh(check)
report_json, md, html = render_report({"check_id": check.id, "input_check_type": ctype, **result})
report = Report(check_id=check.id, created_by_user_id=user_id, json_data=report_json, markdown=md, html=html)
self.db.add(report)
self.db.commit()
self.db.refresh(report)

prev = target.last_status
changed = prev is not None and prev != result["status"]
run = WatchRun(watch_target_id=target.id, report_id=report.id, previous_status=prev, status=result["status"], changed=changed, summary=result["summary"])
self.db.add(run)
target.last_status = result["status"]
target.last_run_at = datetime.utcnow()
target.next_run_at = target.last_run_at + timedelta(minutes=target.interval_minutes)
self.db.commit()
self.db.refresh(run)
return run

def _execute_check(self, target: WatchTarget):
client = RipeStatClient(self.db)
if target.watch_type == "bgp_visibility":
return BgpVisibilityService(client).check(target.prefix or "", target.expected_origin_as), "bgp-visibility", target.prefix or "", target.expected_origin_as
if target.watch_type == "roa_preflight":
return RoaPlannerService(client).check(target.prefix or "", target.origin_as or "", target.max_length), "roa-preflight", target.prefix or "", target.origin_as
if target.watch_type == "prefix":
return PrefixChecker(client).check(target.prefix or "", target.origin_as), "prefix", target.prefix or "", target.origin_as
if target.watch_type == "asn":
return AsnChecker(client).check(target.asn or ""), "asn", target.asn or "", None
raise HTTPException(status_code=400, detail="Unsupported watch_type")
35 changes: 35 additions & 0 deletions backend/tests/test_watch_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import importlib
from fastapi.testclient import TestClient
import app.config as config


def _client() -> TestClient:
config.settings = config.Settings(_env_file=None, ROUTEFORGE_DEMO_MODE=True)
import app.main as main_module
import app.database as database
importlib.reload(main_module)
database.Base.metadata.drop_all(bind=database.engine)
database.Base.metadata.create_all(bind=database.engine)
return TestClient(main_module.app)

def _setup(client, u='admin', p='AdminPass123!'):
r=client.post('/api/auth/setup', json={'username':u,'email':'a@b.c','password':p,'password_confirm':p})
if r.status_code==403:
assert client.post('/api/auth/login', json={'username':u,'password':p}).status_code==200

def test_watch_create_and_run_and_run_due():
c=_client(); _setup(c)
resp=c.post('/api/watch-targets', json={'name':'t1','watch_type':'prefix','prefix':'192.0.2.0/24','interval_minutes':60,'is_active':True})
assert resp.status_code==200
tid=resp.json()['id']
assert c.get('/api/watch-targets').status_code==200
run=c.post(f'/api/watch-targets/{tid}/run'); assert run.status_code==200
assert run.json()['changed'] is False
due=c.post('/api/watch-targets/run-due'); assert due.status_code==200

def test_viewer_readonly_watch():
c=_client(); _setup(c)
assert c.post('/api/users', json={'username':'viewer','email':'v@e.c','password':'ViewerPass123!','role':'viewer'}).status_code==200
c.post('/api/auth/logout'); assert c.post('/api/auth/login', json={'username':'viewer','password':'ViewerPass123!'}).status_code==200
assert c.get('/api/watch-targets').status_code==200
assert c.post('/api/watch-targets', json={'name':'x','watch_type':'asn','asn':'AS3320'}).status_code==403
Loading
Loading