Backend (tasks 1.1, 1.5–1.8): - pyproject.toml with FastAPI, Pydantic v2, aiosqlite, APScheduler 3.x, structlog, bcrypt; ruff + mypy strict configured - Pydantic Settings (BANGUI_ prefix env vars, fail-fast validation) - SQLite schema: settings, sessions, blocklist_sources, import_log; WAL mode + foreign keys; idempotent init_db() - FastAPI app factory with lifespan (DB, aiohttp session, scheduler), CORS, unhandled-exception handler, GET /api/health - Fail2BanClient: async Unix-socket wrapper using run_in_executor, custom error types, async context manager - Utility modules: ip_utils, time_utils, constants - 47 tests; ruff 0 errors; mypy --strict 0 errors Frontend (tasks 1.2–1.4): - Vite + React 18 + TypeScript strict; Fluent UI v9; ESLint + Prettier - Custom brand theme (#0F6CBD, WCAG AA contrast) with light/dark variants - Typed fetch API client (ApiError, get/post/put/del) + endpoints constants - tsc --noEmit 0 errors
209 lines
6.8 KiB
Python
209 lines
6.8 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
|
|
|
|
import aiohttp
|
|
import aiosqlite
|
|
import structlog
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler # type: ignore[import-untyped]
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from app.config import Settings, get_settings
|
|
from app.db import init_db
|
|
from app.routers import health
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ensure the bundled fail2ban package is importable from fail2ban-master/
|
|
# ---------------------------------------------------------------------------
|
|
_FAIL2BAN_MASTER: Path = Path(__file__).resolve().parents[2] / "fail2ban-master"
|
|
if 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
|
|
|
|
# --- Background task scheduler ---
|
|
scheduler: AsyncIOScheduler = AsyncIOScheduler(timezone="UTC")
|
|
scheduler.start()
|
|
app.state.scheduler = scheduler
|
|
|
|
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."},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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=["*"],
|
|
)
|
|
|
|
# --- Exception handlers ---
|
|
app.add_exception_handler(Exception, _unhandled_exception_handler)
|
|
|
|
# --- Routers ---
|
|
app.include_router(health.router)
|
|
|
|
return app
|