"""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.exceptions import ( ActionAlreadyExistsError, ActionNameError, ActionNotFoundError, ActionReadonlyError, ConfigDirError, ConfigFileExistsError, ConfigFileNameError, ConfigFileNotFoundError, ConfigFileWriteError, ConfigOperationError, ConfigValidationError, ConfigWriteError, Fail2BanConnectionError, Fail2BanProtocolError, FilterAlreadyExistsError, FilterInvalidRegexError, FilterNameError, FilterNotFoundError, FilterReadonlyError, JailAlreadyActiveError, JailAlreadyInactiveError, JailNameError, JailNotFoundError, JailNotFoundInConfigError, JailOperationError, ServerOperationError, ) from app.middleware.csrf import CsrfMiddleware 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.rate_limiter import RateLimiter from app.utils.runtime_state import ApplicationState, RuntimeState from app.utils.session_cache import InMemorySessionCache, NoOpSessionCache 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 # --------------------------------------------------------------------------- def _update_session_cache(app: FastAPI, settings: Settings) -> None: """Update the session cache backend based on settings. Replaces the current cache with InMemorySessionCache or NoOpSessionCache depending on whether session caching is enabled and configured with a positive TTL. Args: app: The :class:`fastapi.FastAPI` instance. settings: The effective application settings. """ cache_enabled = settings.session_cache_enabled and settings.session_cache_ttl_seconds > 0.0 app.state.session_cache = ( InMemorySessionCache() if cache_enabled else NoOpSessionCache() ) @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 # Ensure session cache is initialized based on effective settings. # This cache is process-local and not cluster-safe. In multi-worker # deployments, it should be replaced with a shared backend. _update_session_cache(app, settings) # Initialize the login rate limiter (5 attempts per 60 seconds per IP). # This is process-local and not cluster-safe. In multi-worker deployments, # each worker has independent counters, limiting the blast radius of attacks. app.state.login_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60) 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.exceptions.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": "Cannot reach the fail2ban service. Check the server status page."}, ) 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.exceptions.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": "Cannot reach the fail2ban service. Check the server status page."}, ) async def _not_found_handler( request: Request, exc: Exception, ) -> JSONResponse: """Return a ``404 Not Found`` response for missing domain entities. Args: request: The incoming FastAPI request. exc: The not-found exception. Returns: A :class:`fastapi.responses.JSONResponse` with status 404. """ log.warning( "domain_not_found", path=request.url.path, method=request.method, error=str(exc), ) return JSONResponse( status_code=status.HTTP_404_NOT_FOUND, content={"detail": str(exc)}, ) async def _bad_request_handler( request: Request, exc: Exception, ) -> JSONResponse: """Return a ``400 Bad Request`` response for validation and domain contract errors. Args: request: The incoming FastAPI request. exc: The validation exception. Returns: A :class:`fastapi.responses.JSONResponse` with status 400. """ log.warning( "domain_bad_request", path=request.url.path, method=request.method, error=str(exc), ) return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, content={"detail": str(exc)}, ) async def _conflict_handler( request: Request, exc: Exception, ) -> JSONResponse: """Return a ``409 Conflict`` response for domain state conflicts.""" log.warning( "domain_conflict", path=request.url.path, method=request.method, error=str(exc), ) return JSONResponse( status_code=status.HTTP_409_CONFLICT, content={"detail": str(exc)}, ) async def _domain_error_handler( request: Request, exc: Exception, ) -> JSONResponse: """Return a ``500 Internal Server Error`` response for domain write failures.""" log.error( "domain_internal_error", path=request.url.path, method=request.method, error=str(exc), exc_info=exc, ) return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"detail": str(exc)}, ) async def _value_error_handler( request: Request, exc: ValueError, ) -> JSONResponse: """Return a ``400 Bad Request`` response for validation and value errors. Args: request: The incoming FastAPI request. exc: The :class:`ValueError`. Returns: A :class:`fastapi.responses.JSONResponse` with status 400. """ log.warning( "value_error", path=request.url.path, method=request.method, error=str(exc), ) return JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, content={"detail": str(exc)}, ) async def _service_unavailable_handler( request: Request, exc: Exception, ) -> JSONResponse: """Return a ``503 Service Unavailable`` response for infrastructure errors. Args: request: The incoming FastAPI request. exc: The infrastructure exception (e.g., ConfigDirError). Returns: A :class:`fastapi.responses.JSONResponse` with status 503. """ log.warning( "service_unavailable", path=request.url.path, method=request.method, error=str(exc), ) return JSONResponse( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content={"detail": str(exc)}, ) # --------------------------------------------------------------------------- # Setup-redirect middleware # --------------------------------------------------------------------------- # Paths that are always reachable, even before setup is complete. _ALWAYS_ALLOWED: frozenset[str] = frozenset( {"/api/setup", "/api/health", "/api/docs", "/api/redoc", "/api/openapi.json"}, ) 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() # Configure API docs based on enable_docs setting. # In production, docs are disabled (None). In development, docs are served at /api/*. docs_url = "/api/docs" if resolved_settings.enable_docs else None redoc_url = "/api/redoc" if resolved_settings.enable_docs else None openapi_url = "/api/openapi.json" if resolved_settings.enable_docs else None app: FastAPI = FastAPI( title="BanGUI", description="Web interface for monitoring, managing, and configuring fail2ban.", version=__version__, lifespan=_lifespan, docs_url=docs_url, redoc_url=redoc_url, openapi_url=openapi_url, ) # 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 app.state.session_cache = ( InMemorySessionCache() if resolved_settings.session_cache_enabled and resolved_settings.session_cache_ttl_seconds > 0.0 else NoOpSessionCache() ) # Initialize the login rate limiter (5 attempts per 60 seconds per IP). # This is also re-initialized in the lifespan, but must be present here # for tests that bypass the lifespan via ASGITransport. app.state.login_rate_limiter = RateLimiter(max_attempts=5, window_seconds=60) 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* CSRF, so it is added last. # CSRF middleware protects cookie-authenticated state-mutating requests. app.add_middleware(SetupRedirectMiddleware) app.add_middleware(CsrfMiddleware) # --- 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(JailNotFoundError, _not_found_handler) # type: ignore[arg-type] app.add_exception_handler(JailNotFoundInConfigError, _not_found_handler) # type: ignore[arg-type] app.add_exception_handler(FilterNotFoundError, _not_found_handler) # type: ignore[arg-type] app.add_exception_handler(ActionNotFoundError, _not_found_handler) # type: ignore[arg-type] app.add_exception_handler(ConfigFileNotFoundError, _not_found_handler) # type: ignore[arg-type] app.add_exception_handler(ConfigValidationError, _bad_request_handler) # type: ignore[arg-type] app.add_exception_handler(ConfigFileNameError, _bad_request_handler) # type: ignore[arg-type] app.add_exception_handler(ConfigOperationError, _bad_request_handler) # type: ignore[arg-type] app.add_exception_handler(ServerOperationError, _bad_request_handler) # type: ignore[arg-type] app.add_exception_handler(ActionNameError, _bad_request_handler) # type: ignore[arg-type] app.add_exception_handler(FilterNameError, _bad_request_handler) # type: ignore[arg-type] app.add_exception_handler(JailNameError, _bad_request_handler) # type: ignore[arg-type] app.add_exception_handler(FilterInvalidRegexError, _bad_request_handler) # type: ignore[arg-type] app.add_exception_handler(ValueError, _value_error_handler) # type: ignore[arg-type] app.add_exception_handler(JailOperationError, _conflict_handler) # type: ignore[arg-type] app.add_exception_handler(JailAlreadyActiveError, _conflict_handler) # type: ignore[arg-type] app.add_exception_handler(JailAlreadyInactiveError, _conflict_handler) # type: ignore[arg-type] app.add_exception_handler(FilterAlreadyExistsError, _conflict_handler) # type: ignore[arg-type] app.add_exception_handler(ActionAlreadyExistsError, _conflict_handler) # type: ignore[arg-type] app.add_exception_handler(FilterReadonlyError, _conflict_handler) # type: ignore[arg-type] app.add_exception_handler(ActionReadonlyError, _conflict_handler) # type: ignore[arg-type] app.add_exception_handler(ConfigFileExistsError, _conflict_handler) # type: ignore[arg-type] app.add_exception_handler(ConfigWriteError, _domain_error_handler) # type: ignore[arg-type] app.add_exception_handler(ConfigDirError, _service_unavailable_handler) # type: ignore[arg-type] app.add_exception_handler(ConfigFileWriteError, _bad_request_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