"""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.exceptions import ServerOperationError from app.exceptions import ServerOperationError 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) # --------------------------------------------------------------------------- # 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