RouteForge v0.7.2-beta
Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.
diff --git a/README.md b/README.md
index 07df3f5..667418d 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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.
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 876c89e..da62d14 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -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.
diff --git a/ROADMAP.md b/ROADMAP.md
index db71ec3..c1522f5 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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
diff --git a/backend/alembic/versions/0004_watch_mode.py b/backend/alembic/versions/0004_watch_mode.py
new file mode 100644
index 0000000..7152621
--- /dev/null
+++ b/backend/alembic/versions/0004_watch_mode.py
@@ -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')
diff --git a/backend/app/api/routes_watch.py b/backend/app/api/routes_watch.py
new file mode 100644
index 0000000..7721114
--- /dev/null
+++ b/backend/app/api/routes_watch.py
@@ -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}
diff --git a/backend/app/main.py b/backend/app/main.py
index 002bbb0..c222244 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -12,6 +12,7 @@
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
@@ -19,7 +20,7 @@
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,
@@ -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)
diff --git a/backend/app/models.py b/backend/app/models.py
index 3409fc5..512606c 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -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)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 2394182..e99d592 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -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
diff --git a/backend/app/services/watch_service.py b/backend/app/services/watch_service.py
new file mode 100644
index 0000000..ac77da1
--- /dev/null
+++ b/backend/app/services/watch_service.py
@@ -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")
diff --git a/backend/tests/test_watch_mode.py b/backend/tests/test_watch_mode.py
new file mode 100644
index 0000000..66088b3
--- /dev/null
+++ b/backend/tests/test_watch_mode.py
@@ -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
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 6a1bed3..475434f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -13,8 +13,9 @@ import { StatusBadge } from './components/StatusBadge'
import type { ReportListItem, SystemInfo, SystemStatus, User, UserRole } from './types'
import { UsersView } from './components/UsersView'
import { ChangeCasesView } from './components/ChangeCasesView'
+import { WatchModeView } from './components/WatchModeView'
-type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'roa-planner' | 'bgp-visibility' | 'reports' | 'change-cases' | 'system' | 'users' | 'audit' | 'about'
+type NavKey = 'dashboard' | 'asn' | 'prefix' | 'preflight' | 'roa-planner' | 'bgp-visibility' | 'reports' | 'watch-mode' | 'change-cases' | 'system' | 'users' | 'audit' | 'about'
type AuthMode = 'loading' | 'setup' | 'login' | 'app' | 'error'
export default function App() {
@@ -73,8 +74,8 @@ export default function App() {
if (authMode === 'login') return
Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.
Modernes read-only Operator-Tool für Preflight Checks von ASN, Prefix, RPKI und Registry/IRR.
| {r.summary} |
Version: v0.7.2-beta
Version: v0.8.0-beta
| {r.created_at} | {r.previous_status} | {r.status} | {String(r.changed)} |