import asyncio import logging from contextlib import asynccontextmanager, suppress from pathlib import Path from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from sqlalchemy import select from app.api.v1.router import api_router from app.auth.api_key import hash_api_key from app.config import get_settings from app.database import SessionLocal from app.middleware.ingest_body_limit import IngestBodySizeLimitMiddleware from app.models import ApiKey from app.security_bootstrap import validate_security_settings, warn_relaxed_security from app.services.user_auth import bootstrap_admin_user from app.version import APP_VERSION, APP_VERSION_LABEL ROOT = Path(__file__).resolve().parents[2] logger = logging.getLogger("sac") def bootstrap_api_key() -> None: settings = get_settings() if not settings.sac_bootstrap_api_key: return db = SessionLocal() try: key_hash = hash_api_key(settings.sac_bootstrap_api_key) exists = db.scalar(select(ApiKey).where(ApiKey.key_hash == key_hash)) if exists: return prefix = settings.sac_bootstrap_api_key[:12] db.add( ApiKey( name="bootstrap", key_prefix=prefix, key_hash=key_hash, is_active=True, ) ) db.commit() finally: db.close() def bootstrap_users() -> None: db = SessionLocal() try: bootstrap_admin_user(db) finally: db.close() def bootstrap_stale_remote_actions() -> None: db = SessionLocal() try: from app.services.host_remote_actions import clear_stale_running_remote_actions cleared = clear_stale_running_remote_actions(db) for hostname in cleared: logger.info("Stale remote action reset on startup: %s", hostname) finally: db.close() @asynccontextmanager async def lifespan(_app: FastAPI): settings = get_settings() validate_security_settings(settings) warn_relaxed_security(settings) logger.info("%s — application startup (version %s)", APP_VERSION_LABEL, APP_VERSION) bootstrap_api_key() bootstrap_users() bootstrap_stale_remote_actions() stop_scan = asyncio.Event() scan_task: asyncio.Task | None = None if settings.sac_host_silence_scan_enabled: from app.jobs.host_silence_background import host_silence_scan_loop scan_task = asyncio.create_task(host_silence_scan_loop(stop_scan)) yield if scan_task is not None: stop_scan.set() scan_task.cancel() with suppress(asyncio.CancelledError): await scan_task logger.info("%s — application shutdown", APP_VERSION_LABEL) def configure_logging() -> None: if logging.getLogger().handlers: return logging.basicConfig( level=logging.INFO, format="%(levelname)s: %(message)s", ) def create_app() -> FastAPI: configure_logging() settings = get_settings() app = FastAPI( title="Security Alert Center", version=APP_VERSION, lifespan=lifespan, ) origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()] wildcard = origins == ["*"] app.add_middleware( CORSMiddleware, allow_origins=origins if not wildcard else ["*"], allow_credentials=not wildcard, allow_methods=["*"], allow_headers=["*"], ) app.add_middleware( IngestBodySizeLimitMiddleware, max_bytes=settings.sac_ingest_max_body_bytes, ) from app.api.v1.health import router as health_router app.include_router(api_router, prefix="/api/v1") app.include_router(health_router) frontend_dist = ROOT / "frontend" / "dist" if frontend_dist.is_dir(): assets_dir = frontend_dist / "assets" if assets_dir.is_dir(): app.mount( "/assets", StaticFiles(directory=str(assets_dir)), name="ui-assets", ) @app.get("/{spa_path:path}") def spa_fallback(spa_path: str) -> FileResponse: if spa_path.startswith("api/"): raise HTTPException(status_code=404, detail="Not Found") dist_root = frontend_dist.resolve() if spa_path: candidate = (frontend_dist / spa_path).resolve() try: candidate.relative_to(dist_root) except ValueError: raise HTTPException(status_code=404, detail="Not Found") from None if candidate.is_file(): return FileResponse(candidate) index = frontend_dist / "index.html" if not index.is_file(): raise HTTPException(status_code=404, detail="UI not built") return FileResponse(index) return app app = create_app()