"""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 typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import AsyncGenerator, Awaitable, Callable from starlette.responses import Response as StarletteResponse import structlog from fastapi import FastAPI, Request, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, RedirectResponse from starlette.middleware.base import BaseHTTPMiddleware from app import __version__ from app.config import Settings, get_settings from app.routers import ( auth, bans, blocklist, config, dashboard, file_config, geo, health, history, jails, server, setup, ) from app.startup import startup_shared_resources from app.utils.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError from app.utils.runtime_state import ApplicationState, RuntimeState from app.utils.setup_state import is_setup_complete_cached, set_setup_complete_cache 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) http_session, scheduler = await startup_shared_resources(app, settings) app.state.http_session = http_session app.state.scheduler = scheduler log.info("bangui_started") try: yield finally: log.info("bangui_shutting_down") scheduler.shutdown(wait=False) await http_session.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."}, ) async def _fail2ban_connection_handler( request: Request, exc: Fail2BanConnectionError, ) -> JSONResponse: """Return a ``502 Bad Gateway`` response when fail2ban is unreachable. Args: request: The incoming FastAPI request. exc: The :class:`~app.utils.fail2ban_client.Fail2BanConnectionError`. Returns: A :class:`fastapi.responses.JSONResponse` with status 502. """ log.warning( "fail2ban_connection_error", path=request.url.path, method=request.method, error=str(exc), ) return JSONResponse( status_code=502, content={"detail": f"Cannot reach fail2ban: {exc}"}, ) async def _fail2ban_protocol_handler( request: Request, exc: Fail2BanProtocolError, ) -> JSONResponse: """Return a ``502 Bad Gateway`` response for fail2ban protocol errors. Args: request: The incoming FastAPI request. exc: The :class:`~app.utils.fail2ban_client.Fail2BanProtocolError`. Returns: A :class:`fastapi.responses.JSONResponse` with status 502. """ log.warning( "fail2ban_protocol_error", path=request.url.path, method=request.method, error=str(exc), ) return JSONResponse( status_code=502, content={"detail": f"fail2ban protocol error: {exc}"}, ) # --------------------------------------------------------------------------- # Setup-redirect middleware # --------------------------------------------------------------------------- # Paths that are always reachable, even before setup is complete. _ALWAYS_ALLOWED: frozenset[str] = frozenset( {"/api/setup", "/api/health"}, ) class SetupRedirectMiddleware(BaseHTTPMiddleware): """Redirect all API requests to ``/api/setup`` until setup is done. Once setup is complete this middleware is a no-op. Paths listed in :data:`_ALWAYS_ALLOWED` are exempt so the setup endpoint itself is always reachable. """ async def dispatch( self, request: Request, call_next: Callable[[Request], Awaitable[StarletteResponse]], ) -> StarletteResponse: """Intercept requests before they reach the router. Args: request: The incoming HTTP request. call_next: The next middleware / router handler. Returns: Either a ``307 Temporary Redirect`` to ``/api/setup`` or the normal router response. """ path: str = request.url.path.rstrip("/") or "/" # Allow requests that don't need setup guard. if any(path.startswith(allowed) for allowed in _ALWAYS_ALLOWED): return await call_next(request) # If setup is not complete, block all other API requests. # The setup completion state is resolved at startup and stored in # ``app.state.setup_complete_cached`` so this middleware does not # perform any database queries during normal request handling. if path.startswith("/api") and not is_setup_complete_cached(request.app): return RedirectResponse( url="/api/setup", status_code=status.HTTP_307_TEMPORARY_REDIRECT, ) return await call_next(request) # --------------------------------------------------------------------------- # 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=__version__, lifespan=_lifespan, ) # Store immutable configuration and the dedicated runtime state manager on # app.state. Runtime state values are proxied through the wrapper so the # shared Starlette state bag itself does not hold mutable business state. app.state = ApplicationState(RuntimeState()) app.state.settings = resolved_settings set_setup_complete_cache(app, False) # --- CORS --- # Allow origins configured by the runtime environment. In production, # this should be explicitly set to the frontend origin(s) or left empty # when the UI is served from the same origin as the API. if resolved_settings.cors_allowed_origins: app.add_middleware( CORSMiddleware, allow_origins=resolved_settings.cors_allowed_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # --- Middleware --- # Note: middleware is applied in reverse order of registration. # The setup-redirect must run *after* CORS, so it is added last. app.add_middleware(SetupRedirectMiddleware) # --- Exception handlers --- # Ordered from most specific to least specific. FastAPI evaluates handlers # in the order they were registered, so fail2ban network errors get a 502 # rather than falling through to the generic 500 handler. app.add_exception_handler(Fail2BanConnectionError, _fail2ban_connection_handler) # type: ignore[arg-type] app.add_exception_handler(Fail2BanProtocolError, _fail2ban_protocol_handler) # type: ignore[arg-type] app.add_exception_handler(Exception, _unhandled_exception_handler) # --- Routers --- app.include_router(health.router) app.include_router(setup.router) app.include_router(auth.router) app.include_router(dashboard.router) app.include_router(jails.router) app.include_router(bans.router) app.include_router(geo.router) app.include_router(config.router) app.include_router(file_config.router) app.include_router(server.router) app.include_router(history.router) app.include_router(blocklist.router) return app