- Add TYPE_CHECKING guards for runtime-expensive imports (aiohttp, aiosqlite) - Reorganize imports to follow PEP 8 conventions - Convert TypeAlias to modern PEP 695 type syntax (where appropriate) - Use Sequence/Mapping from collections.abc for type hints (covariant) - Replace string literals with cast() for improved type inference - Fix casting of Fail2BanResponse and TypedDict patterns - Add IpLookupResult TypedDict for precise return type annotation - Reformat overlong lines for readability (120 char limit) - Add asyncio_mode and filterwarnings to pytest config - Update test fixtures with improved type hints This improves mypy type checking and makes type relationships explicit.
232 lines
7.3 KiB
Python
232 lines
7.3 KiB
Python
"""Server-level settings service.
|
|
|
|
Provides methods to read and update fail2ban server-level settings
|
|
(log level, log target, database configuration) via the Unix domain socket.
|
|
Also exposes the ``flushlogs`` command for use after log rotation.
|
|
|
|
Architecture note: this module is a pure service — it contains **no**
|
|
HTTP/FastAPI concerns.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import cast
|
|
|
|
import structlog
|
|
|
|
from app.models.server import ServerSettings, ServerSettingsResponse, ServerSettingsUpdate
|
|
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanCommand, Fail2BanResponse
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Types
|
|
# ---------------------------------------------------------------------------
|
|
|
|
type Fail2BanSettingValue = str | int | bool
|
|
"""Allowed values for server settings commands."""
|
|
|
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
|
|
|
_SOCKET_TIMEOUT: float = 10.0
|
|
|
|
|
|
def _to_int(value: object | None, default: int) -> int:
|
|
"""Convert a raw value to an int, falling back to a default.
|
|
|
|
The fail2ban control socket can return either int or str values for some
|
|
settings, so we normalise them here in a type-safe way.
|
|
"""
|
|
if isinstance(value, int):
|
|
return value
|
|
if isinstance(value, float):
|
|
return int(value)
|
|
if isinstance(value, str):
|
|
try:
|
|
return int(value)
|
|
except ValueError:
|
|
return default
|
|
return default
|
|
|
|
|
|
def _to_str(value: object | None, default: str) -> str:
|
|
"""Convert a raw value to a string, falling back to a default."""
|
|
if value is None:
|
|
return default
|
|
return str(value)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Custom exceptions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class ServerOperationError(Exception):
|
|
"""Raised when a server-level set command fails."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _ok(response: Fail2BanResponse) -> object:
|
|
"""Extract payload from a fail2ban ``(code, data)`` response.
|
|
|
|
Args:
|
|
response: Raw value returned by :meth:`~Fail2BanClient.send`.
|
|
|
|
Returns:
|
|
The payload ``data`` portion of the response.
|
|
|
|
Raises:
|
|
ValueError: If the return code indicates an error.
|
|
"""
|
|
try:
|
|
code, data = response
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(f"Unexpected response shape: {response!r}") from exc
|
|
if code != 0:
|
|
raise ValueError(f"fail2ban error {code}: {data!r}")
|
|
return data
|
|
|
|
|
|
async def _safe_get(
|
|
client: Fail2BanClient,
|
|
command: Fail2BanCommand,
|
|
default: object | None = None,
|
|
) -> object | None:
|
|
"""Send a command and silently return *default* on any error.
|
|
|
|
Args:
|
|
client: The :class:`~app.utils.fail2ban_client.Fail2BanClient` to use.
|
|
command: Command list to send.
|
|
default: Fallback value.
|
|
|
|
Returns:
|
|
The successful response, or *default*.
|
|
"""
|
|
try:
|
|
response = await client.send(command)
|
|
return _ok(cast("Fail2BanResponse", response))
|
|
except Exception:
|
|
return default
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def get_settings(socket_path: str) -> ServerSettingsResponse:
|
|
"""Return current fail2ban server-level settings.
|
|
|
|
Fetches log level, log target, syslog socket, database file path, purge
|
|
age, and max matches in a single round-trip batch.
|
|
|
|
Args:
|
|
socket_path: Path to the fail2ban Unix domain socket.
|
|
|
|
Returns:
|
|
:class:`~app.models.server.ServerSettingsResponse`.
|
|
|
|
Raises:
|
|
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
|
"""
|
|
import asyncio
|
|
|
|
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
|
|
|
(
|
|
log_level_raw,
|
|
log_target_raw,
|
|
syslog_socket_raw,
|
|
db_path_raw,
|
|
db_purge_age_raw,
|
|
db_max_matches_raw,
|
|
) = await asyncio.gather(
|
|
_safe_get(client, ["get", "loglevel"], "INFO"),
|
|
_safe_get(client, ["get", "logtarget"], "STDOUT"),
|
|
_safe_get(client, ["get", "syslogsocket"], None),
|
|
_safe_get(client, ["get", "dbfile"], "/var/lib/fail2ban/fail2ban.sqlite3"),
|
|
_safe_get(client, ["get", "dbpurgeage"], 86400),
|
|
_safe_get(client, ["get", "dbmaxmatches"], 10),
|
|
)
|
|
|
|
log_level = _to_str(log_level_raw, "INFO").upper()
|
|
log_target = _to_str(log_target_raw, "STDOUT")
|
|
syslog_socket = _to_str(syslog_socket_raw, "") or None
|
|
db_path = _to_str(db_path_raw, "/var/lib/fail2ban/fail2ban.sqlite3")
|
|
db_purge_age = _to_int(db_purge_age_raw, 86400)
|
|
db_max_matches = _to_int(db_max_matches_raw, 10)
|
|
|
|
settings = ServerSettings(
|
|
log_level=log_level,
|
|
log_target=log_target,
|
|
syslog_socket=syslog_socket,
|
|
db_path=db_path,
|
|
db_purge_age=db_purge_age,
|
|
db_max_matches=db_max_matches,
|
|
)
|
|
|
|
log.info("server_settings_fetched")
|
|
return ServerSettingsResponse(settings=settings)
|
|
|
|
|
|
async def update_settings(socket_path: str, update: ServerSettingsUpdate) -> None:
|
|
"""Apply *update* to fail2ban server-level settings.
|
|
|
|
Only non-None fields in *update* are sent.
|
|
|
|
Args:
|
|
socket_path: Path to the fail2ban Unix domain socket.
|
|
update: Partial update payload.
|
|
|
|
Raises:
|
|
ServerOperationError: If any ``set`` command is rejected.
|
|
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
|
"""
|
|
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
|
|
|
async def _set(key: str, value: Fail2BanSettingValue) -> None:
|
|
try:
|
|
response = await client.send(["set", key, value])
|
|
_ok(cast("Fail2BanResponse", response))
|
|
except ValueError as exc:
|
|
raise ServerOperationError(f"Failed to set {key!r} = {value!r}: {exc}") from exc
|
|
|
|
if update.log_level is not None:
|
|
await _set("loglevel", update.log_level.upper())
|
|
if update.log_target is not None:
|
|
await _set("logtarget", update.log_target)
|
|
if update.db_purge_age is not None:
|
|
await _set("dbpurgeage", update.db_purge_age)
|
|
if update.db_max_matches is not None:
|
|
await _set("dbmaxmatches", update.db_max_matches)
|
|
|
|
log.info("server_settings_updated")
|
|
|
|
|
|
async def flush_logs(socket_path: str) -> str:
|
|
"""Flush and re-open fail2ban log files.
|
|
|
|
Useful after log rotation so the daemon starts writing to the newly
|
|
created file rather than the old rotated one.
|
|
|
|
Args:
|
|
socket_path: Path to the fail2ban Unix domain socket.
|
|
|
|
Returns:
|
|
The response message from fail2ban (e.g. ``"OK"``) as a string.
|
|
|
|
Raises:
|
|
ServerOperationError: If the command is rejected.
|
|
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
|
|
"""
|
|
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
|
try:
|
|
response = await client.send(["flushlogs"])
|
|
result = _ok(cast("Fail2BanResponse", response))
|
|
log.info("logs_flushed", result=result)
|
|
return str(result)
|
|
except ValueError as exc:
|
|
raise ServerOperationError(f"flushlogs failed: {exc}") from exc
|