All routers now let domain exceptions propagate to the global handlers in main.py instead of catching and converting them to HTTPException. This eliminates: - Duplicate exception-to-HTTP-status mappings across 8 routers - Duplicate helper functions (_bad_gateway, _not_found, _conflict, etc.) - Inconsistent error response formats Changes: - Removed all try/except blocks from routers that catch domain exceptions - Removed duplicate helper functions from all routers - Added missing exception handlers to main.py for: * ActionNameError * FilterNameError * JailNameError * JailNotFoundInConfigError * FilterInvalidRegexError - Removed unused imports from affected routers All domain exceptions now propagate to the single authoritative mapping in main.py, ensuring consistent error codes, messages, and logging across the API. Affected routers: - action_config.py: Removed _action_not_found, _bad_request, _not_found helpers - bans.py: Removed try/except in ban/unban endpoints - config_misc.py: Removed try/except blocks - file_config.py: Removed 6 try/except blocks and _service_unavailable helper - filter_config.py: Removed try/except blocks - geo.py: Removed try/except in lookup_ip endpoint - jail_config.py: Removed try/except blocks - jails.py: Removed try/except blocks - server.py: Removed try/except blocks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
528 lines
18 KiB
Python
528 lines
18 KiB
Python
"""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.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.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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@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.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": 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.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": f"fail2ban protocol error: {exc}"},
|
|
)
|
|
|
|
|
|
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"},
|
|
)
|
|
|
|
|
|
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
|
|
app.state.session_cache = (
|
|
InMemorySessionCache()
|
|
if resolved_settings.session_cache_enabled and resolved_settings.session_cache_ttl_seconds > 0.0
|
|
else NoOpSessionCache()
|
|
)
|
|
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(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
|