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
248 lines
7.9 KiB
Python
248 lines
7.9 KiB
Python
"""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."""
|