feat: Stage 1 — backend and frontend scaffolding

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
This commit is contained in:
2026-02-28 21:15:01 +01:00
parent 460d877339
commit 7392c930d6
59 changed files with 7601 additions and 17 deletions

View File

@@ -0,0 +1 @@
"""Shared utilities, helpers, and constants package."""

View File

@@ -0,0 +1,78 @@
"""Application-wide constants.
All magic numbers, default paths, and limit values live here.
Import from this module rather than hard-coding values in business logic.
"""
from typing import Final
# ---------------------------------------------------------------------------
# fail2ban integration
# ---------------------------------------------------------------------------
DEFAULT_FAIL2BAN_SOCKET: Final[str] = "/var/run/fail2ban/fail2ban.sock"
"""Default path to the fail2ban Unix domain socket."""
FAIL2BAN_SOCKET_TIMEOUT_SECONDS: Final[float] = 5.0
"""Maximum seconds to wait for a response from the fail2ban socket."""
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
DEFAULT_DATABASE_PATH: Final[str] = "bangui.db"
"""Default filename for the BanGUI application SQLite database."""
# ---------------------------------------------------------------------------
# Authentication
# ---------------------------------------------------------------------------
DEFAULT_SESSION_DURATION_MINUTES: Final[int] = 60
"""Default session lifetime in minutes."""
SESSION_TOKEN_BYTES: Final[int] = 64
"""Number of random bytes used when generating a session token."""
# ---------------------------------------------------------------------------
# Time-range presets (used by dashboard and history endpoints)
# ---------------------------------------------------------------------------
TIME_RANGE_24H: Final[str] = "24h"
TIME_RANGE_7D: Final[str] = "7d"
TIME_RANGE_30D: Final[str] = "30d"
TIME_RANGE_365D: Final[str] = "365d"
VALID_TIME_RANGES: Final[frozenset[str]] = frozenset(
{TIME_RANGE_24H, TIME_RANGE_7D, TIME_RANGE_30D, TIME_RANGE_365D}
)
TIME_RANGE_HOURS: Final[dict[str, int]] = {
TIME_RANGE_24H: 24,
TIME_RANGE_7D: 7 * 24,
TIME_RANGE_30D: 30 * 24,
TIME_RANGE_365D: 365 * 24,
}
# ---------------------------------------------------------------------------
# Pagination
# ---------------------------------------------------------------------------
DEFAULT_PAGE_SIZE: Final[int] = 50
MAX_PAGE_SIZE: Final[int] = 500
# ---------------------------------------------------------------------------
# Blocklist import
# ---------------------------------------------------------------------------
BLOCKLIST_IMPORT_DEFAULT_HOUR: Final[int] = 3
"""Default hour (UTC) for the nightly blocklist import job."""
BLOCKLIST_PREVIEW_MAX_LINES: Final[int] = 100
"""Maximum number of IP lines returned by the blocklist preview endpoint."""
# ---------------------------------------------------------------------------
# Health check
# ---------------------------------------------------------------------------
HEALTH_CHECK_INTERVAL_SECONDS: Final[int] = 30
"""How often the background health-check task polls fail2ban."""

View File

@@ -0,0 +1,247 @@
"""Async wrapper around the fail2ban Unix domain socket protocol.
fail2ban uses a proprietary binary protocol over a Unix domain socket:
commands are transmitted as pickle-serialised Python lists and responses
are returned the same way. The protocol constants (``END``, ``CLOSE``)
come from ``fail2ban.protocol.CSPROTO``.
Because the underlying socket is blocking, all I/O is dispatched to a
thread-pool executor so the FastAPI event loop is never blocked.
Usage::
async with Fail2BanClient(socket_path="/var/run/fail2ban/fail2ban.sock") as client:
status = await client.send(["status"])
"""
from __future__ import annotations
import asyncio
import contextlib
import socket
from pickle import HIGHEST_PROTOCOL, dumps, loads
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from types import TracebackType
import structlog
log: structlog.stdlib.BoundLogger = structlog.get_logger()
# fail2ban protocol constants — inline to avoid a hard import dependency
# at module load time (the fail2ban-master path may not be on sys.path yet
# in some test environments).
_PROTO_END: bytes = b"<F2B_END_COMMAND>"
_PROTO_CLOSE: bytes = b"<F2B_CLOSE_COMMAND>"
_PROTO_EMPTY: bytes = b""
# Default receive buffer size (doubles on each iteration up to max).
_RECV_BUFSIZE_START: int = 1024
_RECV_BUFSIZE_MAX: int = 32768
class Fail2BanConnectionError(Exception):
"""Raised when the fail2ban socket is unreachable or returns an error."""
def __init__(self, message: str, socket_path: str) -> None:
"""Initialise with a human-readable message and the socket path.
Args:
message: Description of the connection problem.
socket_path: The fail2ban socket path that was targeted.
"""
self.socket_path: str = socket_path
super().__init__(f"{message} (socket: {socket_path})")
class Fail2BanProtocolError(Exception):
"""Raised when the response from fail2ban cannot be parsed."""
def _send_command_sync(
socket_path: str,
command: list[Any],
timeout: float,
) -> Any:
"""Send a command to fail2ban and return the parsed response.
This is a **synchronous** function intended to be called from within
:func:`asyncio.get_event_loop().run_in_executor` so that the event loop
is not blocked.
Args:
socket_path: Path to the fail2ban Unix domain socket.
command: List of command tokens, e.g. ``["status", "sshd"]``.
timeout: Socket timeout in seconds.
Returns:
The deserialized Python object returned by fail2ban.
Raises:
Fail2BanConnectionError: If the socket cannot be reached.
Fail2BanProtocolError: If the response cannot be unpickled.
"""
sock: socket.socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.settimeout(timeout)
sock.connect(socket_path)
# Serialise and send the command.
payload: bytes = dumps(
list(map(_coerce_command_token, command)),
HIGHEST_PROTOCOL,
)
sock.sendall(payload)
sock.sendall(_PROTO_END)
# Receive until we see the end marker.
raw: bytes = _PROTO_EMPTY
bufsize: int = _RECV_BUFSIZE_START
while raw.rfind(_PROTO_END, -32) == -1:
chunk: bytes = sock.recv(bufsize)
if not chunk:
raise Fail2BanConnectionError(
"Connection closed unexpectedly by fail2ban",
socket_path,
)
if chunk == _PROTO_END:
break
raw += chunk
if bufsize < _RECV_BUFSIZE_MAX:
bufsize <<= 1
try:
return loads(raw)
except Exception as exc:
raise Fail2BanProtocolError(
f"Failed to unpickle fail2ban response: {exc}"
) from exc
except OSError as exc:
raise Fail2BanConnectionError(str(exc), socket_path) from exc
finally:
with contextlib.suppress(OSError):
sock.sendall(_PROTO_CLOSE + _PROTO_END)
with contextlib.suppress(OSError):
sock.shutdown(socket.SHUT_RDWR)
sock.close()
def _coerce_command_token(token: Any) -> Any:
"""Coerce a command token to a type that fail2ban understands.
fail2ban's ``CSocket.convert`` accepts ``str``, ``bool``, ``int``,
``float``, ``list``, ``dict``, and ``set``. Any other type is
stringified.
Args:
token: A single token from the command list.
Returns:
The token in a type safe for pickle transmission to fail2ban.
"""
if isinstance(token, (str, bool, int, float, list, dict, set)):
return token
return str(token)
class Fail2BanClient:
"""Async client for communicating with the fail2ban daemon via its socket.
All blocking socket I/O is offloaded to the default thread-pool executor
so the asyncio event loop remains unblocked.
The client can be used as an async context manager::
async with Fail2BanClient(socket_path) as client:
result = await client.send(["status"])
Or instantiated directly and closed manually::
client = Fail2BanClient(socket_path)
result = await client.send(["status"])
"""
def __init__(
self,
socket_path: str,
timeout: float = 5.0,
) -> None:
"""Initialise the client.
Args:
socket_path: Path to the fail2ban Unix domain socket.
timeout: Socket I/O timeout in seconds.
"""
self.socket_path: str = socket_path
self.timeout: float = timeout
async def send(self, command: list[Any]) -> Any:
"""Send a command to fail2ban and return the response.
The command is serialised as a pickle list, sent to the socket, and
the response is deserialised before being returned.
Args:
command: A list of command tokens, e.g. ``["status", "sshd"]``.
Returns:
The Python object returned by fail2ban (typically a list or dict).
Raises:
Fail2BanConnectionError: If the socket cannot be reached or the
connection is unexpectedly closed.
Fail2BanProtocolError: If the response cannot be decoded.
"""
log.debug("fail2ban_sending_command", command=command)
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
try:
response: Any = await loop.run_in_executor(
None,
_send_command_sync,
self.socket_path,
command,
self.timeout,
)
except Fail2BanConnectionError:
log.warning(
"fail2ban_connection_error",
socket_path=self.socket_path,
command=command,
)
raise
except Fail2BanProtocolError:
log.error(
"fail2ban_protocol_error",
socket_path=self.socket_path,
command=command,
)
raise
log.debug("fail2ban_received_response", command=command)
return response
async def ping(self) -> bool:
"""Return ``True`` if the fail2ban daemon is reachable.
Sends a ``ping`` command and checks for a ``pong`` response.
Returns:
``True`` when the daemon responds correctly, ``False`` otherwise.
"""
try:
response: Any = await self.send(["ping"])
return bool(response == 1) # fail2ban returns 1 on successful ping
except (Fail2BanConnectionError, Fail2BanProtocolError):
return False
async def __aenter__(self) -> Fail2BanClient:
"""Return self when used as an async context manager."""
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
"""No-op exit — each command opens and closes its own socket."""

View File

@@ -0,0 +1,101 @@
"""IP address and CIDR range validation and normalisation utilities.
All IP handling in BanGUI goes through these helpers to enforce consistency
and prevent malformed addresses from reaching fail2ban.
"""
import ipaddress
def is_valid_ip(address: str) -> bool:
"""Return ``True`` if *address* is a valid IPv4 or IPv6 address.
Args:
address: The string to validate.
Returns:
``True`` if the string represents a valid IP address, ``False`` otherwise.
"""
try:
ipaddress.ip_address(address)
return True
except ValueError:
return False
def is_valid_network(cidr: str) -> bool:
"""Return ``True`` if *cidr* is a valid IPv4 or IPv6 network in CIDR notation.
Args:
cidr: The string to validate, e.g. ``"192.168.0.0/24"``.
Returns:
``True`` if the string is a valid CIDR network, ``False`` otherwise.
"""
try:
ipaddress.ip_network(cidr, strict=False)
return True
except ValueError:
return False
def is_valid_ip_or_network(value: str) -> bool:
"""Return ``True`` if *value* is a valid IP address or CIDR network.
Args:
value: The string to validate.
Returns:
``True`` if the string is a valid IP address or CIDR range.
"""
return is_valid_ip(value) or is_valid_network(value)
def normalise_ip(address: str) -> str:
"""Return a normalised string representation of an IP address.
IPv6 addresses are compressed to their canonical short form.
IPv4 addresses are returned unchanged.
Args:
address: A valid IP address string.
Returns:
Normalised IP address string.
Raises:
ValueError: If *address* is not a valid IP address.
"""
return str(ipaddress.ip_address(address))
def normalise_network(cidr: str) -> str:
"""Return a normalised string representation of a CIDR network.
Host bits are masked to produce the network address.
Args:
cidr: A valid CIDR network string, e.g. ``"192.168.1.5/24"``.
Returns:
Normalised network string, e.g. ``"192.168.1.0/24"``.
Raises:
ValueError: If *cidr* is not a valid network.
"""
return str(ipaddress.ip_network(cidr, strict=False))
def ip_version(address: str) -> int:
"""Return 4 or 6 depending on the IP version of *address*.
Args:
address: A valid IP address string.
Returns:
``4`` for IPv4, ``6`` for IPv6.
Raises:
ValueError: If *address* is not a valid IP address.
"""
return ipaddress.ip_address(address).version

View File

@@ -0,0 +1,67 @@
"""Timezone-aware datetime helpers.
All datetimes in BanGUI are stored and transmitted in UTC.
Conversion to the user's display timezone happens only at the presentation
layer (frontend). These utilities provide a consistent, safe foundation
for working with time throughout the backend.
"""
import datetime
def utc_now() -> datetime.datetime:
"""Return the current UTC time as a timezone-aware :class:`datetime.datetime`.
Returns:
Current UTC datetime with ``tzinfo=datetime.UTC``.
"""
return datetime.datetime.now(datetime.UTC)
def utc_from_timestamp(ts: float) -> datetime.datetime:
"""Convert a POSIX timestamp to a timezone-aware UTC datetime.
Args:
ts: POSIX timestamp (seconds since Unix epoch).
Returns:
Timezone-aware UTC :class:`datetime.datetime`.
"""
return datetime.datetime.fromtimestamp(ts, tz=datetime.UTC)
def add_minutes(dt: datetime.datetime, minutes: int) -> datetime.datetime:
"""Return a new datetime that is *minutes* ahead of *dt*.
Args:
dt: The source datetime (must be timezone-aware).
minutes: Number of minutes to add. May be negative.
Returns:
A new timezone-aware :class:`datetime.datetime`.
"""
return dt + datetime.timedelta(minutes=minutes)
def is_expired(expires_at: datetime.datetime) -> bool:
"""Return ``True`` if *expires_at* is in the past relative to UTC now.
Args:
expires_at: The expiry timestamp to check (must be timezone-aware).
Returns:
``True`` when the timestamp is past, ``False`` otherwise.
"""
return utc_now() >= expires_at
def hours_ago(hours: int) -> datetime.datetime:
"""Return a timezone-aware UTC datetime *hours* before now.
Args:
hours: Number of hours to subtract from the current time.
Returns:
Timezone-aware UTC :class:`datetime.datetime`.
"""
return utc_now() - datetime.timedelta(hours=hours)