Files
BanGUI/backend/app/main.py
Lukas ea35695221 Add better jail configuration: file CRUD, enable/disable, log paths
Task 4 (Better Jail Configuration) implementation:
- Add fail2ban_config_dir setting to app/config.py
- New file_config_service: list/view/edit/create jail.d, filter.d, action.d files
  with path-traversal prevention and 512 KB content size limit
- New file_config router: GET/PUT/POST endpoints for jail files, filter files,
  and action files; PUT .../enabled for toggle on/off
- Extend config_service with delete_log_path() and add_log_path()
- Add DELETE /api/config/jails/{name}/logpath and POST /api/config/jails/{name}/logpath
- Extend geo router with re-resolve endpoint; add geo_re_resolve background task
- Update blocklist_service with revised scheduling helpers
- Update Docker compose files with BANGUI_FAIL2BAN_CONFIG_DIR env var and
  rw volume mount for the fail2ban config directory
- Frontend: new Jail Files, Filters, Actions tabs in ConfigPage; file editor
  with accordion-per-file, editable textarea, save/create; add/delete log paths
- Frontend: types in types/config.ts; API calls in api/config.ts and api/endpoints.ts
- 63 new backend tests (test_file_config_service, test_file_config, test_geo_re_resolve)
- 6 new frontend tests in ConfigPageLogPath.test.tsx
- ruff, mypy --strict, tsc --noEmit, eslint: all clean; 617 backend tests pass
2026-03-12 20:08:33 +01:00

409 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
# ---------------------------------------------------------------------------
# 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)
# --- 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
# --- 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
):
db: aiosqlite.Connection | None = getattr(request.app.state, "db", None)
if db is not None:
from app.services import setup_service # noqa: PLC0415
if await setup_service.is_setup_complete(db):
request.app.state._setup_complete_cached = True
else:
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="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