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:
1
backend/app/utils/__init__.py
Normal file
1
backend/app/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Shared utilities, helpers, and constants package."""
|
||||
78
backend/app/utils/constants.py
Normal file
78
backend/app/utils/constants.py
Normal 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."""
|
||||
247
backend/app/utils/fail2ban_client.py
Normal file
247
backend/app/utils/fail2ban_client.py
Normal 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."""
|
||||
101
backend/app/utils/ip_utils.py
Normal file
101
backend/app/utils/ip_utils.py
Normal 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
|
||||
67
backend/app/utils/time_utils.py
Normal file
67
backend/app/utils/time_utils.py
Normal 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)
|
||||
Reference in New Issue
Block a user