On startup BanGUI now verifies that the four fail2ban jail config files required by its two custom jails (manual-Jail and blocklist-import) are present in `$fail2ban_config_dir/jail.d`. Any missing file is created with the correct default content; existing files are never overwritten. Files managed: - manual-Jail.conf (enabled=false template) - manual-Jail.local (enabled=true override) - blocklist-import.conf (enabled=false template) - blocklist-import.local (enabled=true override) The check runs in the lifespan hook immediately after logging is configured, before the database is opened.
414 lines
14 KiB
Python
414 lines
14 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 pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
|
|
from starlette.responses import Response as StarletteResponse
|
|
|
|
import aiohttp
|
|
import aiosqlite
|
|
import structlog
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[import-untyped]
|
|
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.config import Settings, get_settings
|
|
from app.db import init_db
|
|
from app.routers import (
|
|
auth,
|
|
bans,
|
|
blocklist,
|
|
config,
|
|
dashboard,
|
|
file_config,
|
|
geo,
|
|
health,
|
|
history,
|
|
jails,
|
|
server,
|
|
setup,
|
|
)
|
|
from app.tasks import blocklist_import, geo_cache_flush, geo_re_resolve, health_check
|
|
from app.utils.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError
|
|
from app.utils.jail_config import ensure_jail_configs
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ensure the bundled fail2ban package is importable from fail2ban-master/
|
|
#
|
|
# The directory layout differs between local dev and the Docker image:
|
|
# Local: <repo-root>/backend/app/main.py → fail2ban-master at parents[2]
|
|
# Docker: /app/app/main.py → fail2ban-master at parents[1]
|
|
# Walk up from this file until we find a "fail2ban-master" sibling directory
|
|
# so the path resolution is environment-agnostic.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _find_fail2ban_master() -> Path | None:
|
|
"""Return the first ``fail2ban-master`` directory found while walking up.
|
|
|
|
Returns:
|
|
Absolute :class:`~pathlib.Path` to the ``fail2ban-master`` directory,
|
|
or ``None`` if no such directory exists among the ancestors.
|
|
"""
|
|
here = Path(__file__).resolve()
|
|
for ancestor in here.parents:
|
|
candidate = ancestor / "fail2ban-master"
|
|
if candidate.is_dir():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
_fail2ban_master: Path | None = _find_fail2ban_master()
|
|
if _fail2ban_master is not None and 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)
|
|
|
|
# --- Ensure required jail config files are present ---
|
|
ensure_jail_configs(Path(settings.fail2ban_config_dir) / "jail.d")
|
|
|
|
# --- Application database ---
|
|
db_path: Path = Path(settings.database_path)
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
log.debug("database_directory_ensured", directory=str(db_path.parent))
|
|
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
|
|
|
|
# --- Pre-warm geo cache from the persistent store ---
|
|
from app.services import geo_service # noqa: PLC0415
|
|
|
|
geo_service.init_geoip(settings.geoip_db_path)
|
|
await geo_service.load_cache_from_db(db)
|
|
|
|
# Log unresolved geo entries so the operator can see the scope of the issue.
|
|
async with db.execute(
|
|
"SELECT COUNT(*) FROM geo_cache WHERE country_code IS NULL"
|
|
) as cur:
|
|
row = await cur.fetchone()
|
|
unresolved_count: int = int(row[0]) if row else 0
|
|
if unresolved_count > 0:
|
|
log.warning("geo_cache_unresolved_ips", unresolved=unresolved_count)
|
|
|
|
# --- Background task scheduler ---
|
|
scheduler: AsyncIOScheduler = AsyncIOScheduler(timezone="UTC")
|
|
scheduler.start()
|
|
app.state.scheduler = scheduler
|
|
|
|
# --- Health-check background probe ---
|
|
health_check.register(app)
|
|
|
|
# --- Blocklist import scheduled task ---
|
|
blocklist_import.register(app)
|
|
|
|
# --- Periodic geo cache flush to SQLite ---
|
|
geo_cache_flush.register(app)
|
|
|
|
# --- Periodic re-resolve of NULL-country geo entries ---
|
|
geo_re_resolve.register(app)
|
|
|
|
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."},
|
|
)
|
|
|
|
|
|
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.
|
|
# Fast path: setup completion is a one-way transition. Once it is
|
|
# True it is cached on app.state so all subsequent requests skip the
|
|
# DB query entirely. The flag is reset only when the app restarts.
|
|
if path.startswith("/api") and not getattr(
|
|
request.app.state, "_setup_complete_cached", False
|
|
):
|
|
from app.services import setup_service # noqa: PLC0415
|
|
|
|
db: aiosqlite.Connection | None = getattr(request.app.state, "db", None)
|
|
if db is None or not await setup_service.is_setup_complete(db):
|
|
return RedirectResponse(
|
|
url="/api/setup",
|
|
status_code=status.HTTP_307_TEMPORARY_REDIRECT,
|
|
)
|
|
request.app.state._setup_complete_cached = True
|
|
|
|
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="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=["*"],
|
|
)
|
|
|
|
# --- 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
|