"""BanGUI FastAPI application factory. Call :func:`create_app` to obtain a configured :class:`fastapi.FastAPI` instance suitable for direct use with an ASGI server (e.g. ``uvicorn``) or in tests via ``httpx.AsyncClient``. The lifespan handler manages all shared resources — database connection, HTTP session, and scheduler — so every component can rely on them being available on ``app.state`` throughout the request lifecycle. """ from __future__ import annotations import logging import sys from contextlib import asynccontextmanager from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import AsyncGenerator import aiohttp import aiosqlite import structlog from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[import-untyped] from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from app.config import Settings, get_settings from app.db import init_db from app.routers import health # --------------------------------------------------------------------------- # Ensure the bundled fail2ban package is importable from fail2ban-master/ # --------------------------------------------------------------------------- _FAIL2BAN_MASTER: Path = Path(__file__).resolve().parents[2] / "fail2ban-master" if str(_FAIL2BAN_MASTER) not in sys.path: sys.path.insert(0, str(_FAIL2BAN_MASTER)) log: structlog.stdlib.BoundLogger = structlog.get_logger() # --------------------------------------------------------------------------- # Logging configuration # --------------------------------------------------------------------------- def _configure_logging(log_level: str) -> None: """Configure structlog for production JSON output. Args: log_level: One of ``debug``, ``info``, ``warning``, ``error``, ``critical``. """ level: int = logging.getLevelName(log_level.upper()) logging.basicConfig(level=level, stream=sys.stdout, format="%(message)s") structlog.configure( processors=[ structlog.contextvars.merge_contextvars, structlog.stdlib.filter_by_level, structlog.processors.TimeStamper(fmt="iso"), structlog.stdlib.add_logger_name, structlog.stdlib.add_log_level, structlog.stdlib.PositionalArgumentsFormatter(), structlog.processors.StackInfoRenderer(), structlog.processors.format_exc_info, structlog.processors.UnicodeDecoder(), structlog.processors.JSONRenderer(), ], wrapper_class=structlog.stdlib.BoundLogger, context_class=dict, logger_factory=structlog.stdlib.LoggerFactory(), cache_logger_on_first_use=True, ) # --------------------------------------------------------------------------- # Lifespan # --------------------------------------------------------------------------- @asynccontextmanager async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Manage the lifetime of all shared application resources. Resources are initialised in order on startup and released in reverse order on shutdown. They are stored on ``app.state`` so they are accessible to dependency providers and tests. Args: app: The :class:`fastapi.FastAPI` instance being started. """ settings: Settings = app.state.settings _configure_logging(settings.log_level) log.info("bangui_starting_up", database_path=settings.database_path) # --- Application database --- db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path) db.row_factory = aiosqlite.Row await init_db(db) app.state.db = db # --- Shared HTTP client session --- http_session: aiohttp.ClientSession = aiohttp.ClientSession() app.state.http_session = http_session # --- Background task scheduler --- scheduler: AsyncIOScheduler = AsyncIOScheduler(timezone="UTC") scheduler.start() app.state.scheduler = scheduler log.info("bangui_started") try: yield finally: log.info("bangui_shutting_down") scheduler.shutdown(wait=False) await http_session.close() await db.close() log.info("bangui_shut_down") # --------------------------------------------------------------------------- # Exception handlers # --------------------------------------------------------------------------- async def _unhandled_exception_handler( request: Request, exc: Exception, ) -> JSONResponse: """Return a sanitised 500 JSON response for any unhandled exception. The exception is logged with full context before the response is sent. No stack trace is leaked to the client. Args: request: The incoming FastAPI request. exc: The unhandled exception. Returns: A :class:`fastapi.responses.JSONResponse` with status 500. """ log.error( "unhandled_exception", path=request.url.path, method=request.method, exc_info=exc, ) return JSONResponse( status_code=500, content={"detail": "An unexpected error occurred. Please try again later."}, ) # --------------------------------------------------------------------------- # Application factory # --------------------------------------------------------------------------- def create_app(settings: Settings | None = None) -> FastAPI: """Create and configure the BanGUI FastAPI application. This factory is the single entry point for creating the application. Tests can pass a custom ``settings`` object to override defaults without touching environment variables. Args: settings: Optional pre-built :class:`~app.config.Settings` instance. If ``None``, settings are loaded from the environment via :func:`~app.config.get_settings`. Returns: A fully configured :class:`fastapi.FastAPI` application ready for use. """ resolved_settings: Settings = settings if settings is not None else get_settings() app: FastAPI = FastAPI( title="BanGUI", description="Web interface for monitoring, managing, and configuring fail2ban.", version="0.1.0", lifespan=_lifespan, ) # Store settings on app.state so the lifespan handler can access them. app.state.settings = resolved_settings # --- CORS --- # In production the frontend is served by the same origin. # CORS is intentionally permissive only in development. app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173"], # Vite dev server allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # --- Exception handlers --- app.add_exception_handler(Exception, _unhandled_exception_handler) # --- Routers --- app.include_router(health.router) return app