"""Configuration inspection and editing service. Provides methods to read and update fail2ban jail configuration and global server settings via the Unix domain socket. Regex validation is performed locally with Python's :mod:`re` module before any write is sent to the daemon so that invalid patterns are rejected early. Architecture note: this module is a pure service — it contains **no** HTTP/FastAPI concerns. All results are returned as Pydantic models so routers can serialise them directly. """ from __future__ import annotations import asyncio import contextlib import re from pathlib import Path from typing import TYPE_CHECKING, Any import structlog if TYPE_CHECKING: import aiosqlite from app.models.config import ( AddLogPathRequest, GlobalConfigResponse, GlobalConfigUpdate, JailConfig, JailConfigListResponse, JailConfigResponse, JailConfigUpdate, LogPreviewLine, LogPreviewRequest, LogPreviewResponse, MapColorThresholdsResponse, MapColorThresholdsUpdate, RegexTestRequest, RegexTestResponse, ) from app.services import setup_service from app.utils.fail2ban_client import Fail2BanClient log: structlog.stdlib.BoundLogger = structlog.get_logger() _SOCKET_TIMEOUT: float = 10.0 # --------------------------------------------------------------------------- # Custom exceptions # --------------------------------------------------------------------------- class JailNotFoundError(Exception): """Raised when a requested jail name does not exist in fail2ban.""" def __init__(self, name: str) -> None: """Initialise with the jail name that was not found. Args: name: The jail name that could not be located. """ self.name: str = name super().__init__(f"Jail not found: {name!r}") class ConfigValidationError(Exception): """Raised when a configuration value fails validation before writing.""" class ConfigOperationError(Exception): """Raised when a configuration write command fails.""" # --------------------------------------------------------------------------- # Internal helpers (mirrored from jail_service for isolation) # --------------------------------------------------------------------------- def _ok(response: Any) -> Any: """Extract payload from a fail2ban ``(return_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 fail2ban response shape: {response!r}") from exc if code != 0: raise ValueError(f"fail2ban returned error code {code}: {data!r}") return data def _to_dict(pairs: Any) -> dict[str, Any]: """Convert a list of ``(key, value)`` pairs to a plain dict.""" if not isinstance(pairs, (list, tuple)): return {} result: dict[str, Any] = {} for item in pairs: try: k, v = item result[str(k)] = v except (TypeError, ValueError): pass return result def _ensure_list(value: Any) -> list[str]: """Coerce a fail2ban ``get`` result to a list of strings.""" if value is None: return [] if isinstance(value, str): return [value] if value.strip() else [] if isinstance(value, (list, tuple)): return [str(v) for v in value if v is not None] return [str(value)] async def _safe_get( client: Fail2BanClient, command: list[Any], default: Any = None, ) -> Any: """Send a command and return *default* if it fails.""" try: return _ok(await client.send(command)) except Exception: return default def _is_not_found_error(exc: Exception) -> bool: """Return ``True`` if *exc* signals an unknown jail.""" msg = str(exc).lower() return any( phrase in msg for phrase in ("unknown jail", "no jail", "does not exist", "not found") ) def _validate_regex(pattern: str) -> str | None: """Try to compile *pattern* and return an error message if invalid. Args: pattern: A regex pattern string to validate. Returns: ``None`` if valid, or an error message string if the pattern is broken. """ try: re.compile(pattern) return None except re.error as exc: return str(exc) # --------------------------------------------------------------------------- # Public API — read jail configuration # --------------------------------------------------------------------------- async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse: """Return the editable configuration for a single jail. Args: socket_path: Path to the fail2ban Unix domain socket. name: Jail name. Returns: :class:`~app.models.config.JailConfigResponse`. Raises: JailNotFoundError: If *name* is not a known jail. ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. """ client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) # Verify existence. try: _ok(await client.send(["status", name, "short"])) except ValueError as exc: if _is_not_found_error(exc): raise JailNotFoundError(name) from exc raise ( bantime_raw, findtime_raw, maxretry_raw, failregex_raw, ignoreregex_raw, logpath_raw, datepattern_raw, logencoding_raw, backend_raw, actions_raw, ) = await asyncio.gather( _safe_get(client, ["get", name, "bantime"], 600), _safe_get(client, ["get", name, "findtime"], 600), _safe_get(client, ["get", name, "maxretry"], 5), _safe_get(client, ["get", name, "failregex"], []), _safe_get(client, ["get", name, "ignoreregex"], []), _safe_get(client, ["get", name, "logpath"], []), _safe_get(client, ["get", name, "datepattern"], None), _safe_get(client, ["get", name, "logencoding"], "UTF-8"), _safe_get(client, ["get", name, "backend"], "polling"), _safe_get(client, ["get", name, "actions"], []), ) jail_cfg = JailConfig( name=name, ban_time=int(bantime_raw or 600), find_time=int(findtime_raw or 600), max_retry=int(maxretry_raw or 5), fail_regex=_ensure_list(failregex_raw), ignore_regex=_ensure_list(ignoreregex_raw), log_paths=_ensure_list(logpath_raw), date_pattern=str(datepattern_raw) if datepattern_raw else None, log_encoding=str(logencoding_raw or "UTF-8"), backend=str(backend_raw or "polling"), actions=_ensure_list(actions_raw), ) log.info("jail_config_fetched", jail=name) return JailConfigResponse(jail=jail_cfg) async def list_jail_configs(socket_path: str) -> JailConfigListResponse: """Return configuration for all active jails. Args: socket_path: Path to the fail2ban Unix domain socket. Returns: :class:`~app.models.config.JailConfigListResponse`. Raises: ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. """ client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) global_status = _to_dict(_ok(await client.send(["status"]))) jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip() jail_names: list[str] = ( [j.strip() for j in jail_list_raw.split(",") if j.strip()] if jail_list_raw else [] ) if not jail_names: return JailConfigListResponse(jails=[], total=0) responses: list[JailConfigResponse] = await asyncio.gather( *[get_jail_config(socket_path, name) for name in jail_names], return_exceptions=False, ) jails = [r.jail for r in responses] log.info("jail_configs_listed", count=len(jails)) return JailConfigListResponse(jails=jails, total=len(jails)) # --------------------------------------------------------------------------- # Public API — write jail configuration # --------------------------------------------------------------------------- async def update_jail_config( socket_path: str, name: str, update: JailConfigUpdate, ) -> None: """Apply *update* to the configuration of a running jail. Each non-None field in *update* is sent as a separate ``set`` command. Regex patterns are validated locally before any write is sent. Args: socket_path: Path to the fail2ban Unix domain socket. name: Jail name. update: Partial update payload. Raises: JailNotFoundError: If *name* is not a known jail. ConfigValidationError: If a regex pattern fails to compile. ConfigOperationError: If a ``set`` command is rejected by fail2ban. ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. """ # Validate all regex patterns before touching the daemon. for pattern_list, field in [ (update.fail_regex, "fail_regex"), (update.ignore_regex, "ignore_regex"), ]: if pattern_list is None: continue for pattern in pattern_list: err = _validate_regex(pattern) if err: raise ConfigValidationError(f"Invalid regex in {field!r}: {err!r} (pattern: {pattern!r})") client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) # Verify existence. try: _ok(await client.send(["status", name, "short"])) except ValueError as exc: if _is_not_found_error(exc): raise JailNotFoundError(name) from exc raise async def _set(key: str, value: Any) -> None: try: _ok(await client.send(["set", name, key, value])) except ValueError as exc: raise ConfigOperationError(f"Failed to set {key!r} = {value!r}: {exc}") from exc if update.ban_time is not None: await _set("bantime", update.ban_time) if update.find_time is not None: await _set("findtime", update.find_time) if update.max_retry is not None: await _set("maxretry", update.max_retry) if update.date_pattern is not None: await _set("datepattern", update.date_pattern) if update.dns_mode is not None: await _set("usedns", update.dns_mode) if update.enabled is not None: await _set("idle", "off" if update.enabled else "on") # Replacing regex lists requires deleting old entries then adding new ones. if update.fail_regex is not None: await _replace_regex_list(client, name, "failregex", update.fail_regex) if update.ignore_regex is not None: await _replace_regex_list(client, name, "ignoreregex", update.ignore_regex) log.info("jail_config_updated", jail=name) async def _replace_regex_list( client: Fail2BanClient, jail: str, field: str, new_patterns: list[str], ) -> None: """Replace the full regex list for *field* in *jail*. Deletes all existing entries (highest index first to preserve ordering) then inserts all *new_patterns* in order. Args: client: Shared :class:`~app.utils.fail2ban_client.Fail2BanClient`. jail: Jail name. field: Either ``"failregex"`` or ``"ignoreregex"``. new_patterns: Replacement list (may be empty to clear). """ # Determine current count. current_raw = await _safe_get(client, ["get", jail, field], []) current: list[str] = _ensure_list(current_raw) del_cmd = f"del{field}" add_cmd = f"add{field}" # Delete in reverse order so indices stay stable. for idx in range(len(current) - 1, -1, -1): with contextlib.suppress(ValueError): _ok(await client.send(["set", jail, del_cmd, idx])) # Add new patterns. for pattern in new_patterns: err = _validate_regex(pattern) if err: raise ConfigValidationError(f"Invalid regex: {err!r} (pattern: {pattern!r})") try: _ok(await client.send(["set", jail, add_cmd, pattern])) except ValueError as exc: raise ConfigOperationError(f"Failed to add {field} pattern: {exc}") from exc # --------------------------------------------------------------------------- # Public API — global configuration # --------------------------------------------------------------------------- async def get_global_config(socket_path: str) -> GlobalConfigResponse: """Return fail2ban global configuration settings. Args: socket_path: Path to the fail2ban Unix domain socket. Returns: :class:`~app.models.config.GlobalConfigResponse`. Raises: ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. """ client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) ( log_level_raw, log_target_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", "dbpurgeage"], 86400), _safe_get(client, ["get", "dbmaxmatches"], 10), ) return GlobalConfigResponse( log_level=str(log_level_raw or "INFO").upper(), log_target=str(log_target_raw or "STDOUT"), db_purge_age=int(db_purge_age_raw or 86400), db_max_matches=int(db_max_matches_raw or 10), ) async def update_global_config(socket_path: str, update: GlobalConfigUpdate) -> None: """Apply *update* to fail2ban global settings. Args: socket_path: Path to the fail2ban Unix domain socket. update: Partial update payload. Raises: ConfigOperationError: If a ``set`` command is rejected. ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. """ client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) async def _set_global(key: str, value: Any) -> None: try: _ok(await client.send(["set", key, value])) except ValueError as exc: raise ConfigOperationError(f"Failed to set global {key!r} = {value!r}: {exc}") from exc if update.log_level is not None: await _set_global("loglevel", update.log_level.upper()) if update.log_target is not None: await _set_global("logtarget", update.log_target) if update.db_purge_age is not None: await _set_global("dbpurgeage", update.db_purge_age) if update.db_max_matches is not None: await _set_global("dbmaxmatches", update.db_max_matches) log.info("global_config_updated") # --------------------------------------------------------------------------- # Public API — regex tester (stateless, no socket) # --------------------------------------------------------------------------- def test_regex(request: RegexTestRequest) -> RegexTestResponse: """Test a regex pattern against a sample log line. This is a pure in-process operation — no socket communication occurs. Args: request: The :class:`~app.models.config.RegexTestRequest` payload. Returns: :class:`~app.models.config.RegexTestResponse` with match result. """ try: compiled = re.compile(request.fail_regex) except re.error as exc: return RegexTestResponse(matched=False, groups=[], error=str(exc)) match = compiled.search(request.log_line) if match is None: return RegexTestResponse(matched=False) groups: list[str] = list(match.groups() or []) return RegexTestResponse(matched=True, groups=[str(g) for g in groups if g is not None]) # --------------------------------------------------------------------------- # Public API — log observation # --------------------------------------------------------------------------- async def add_log_path( socket_path: str, jail: str, req: AddLogPathRequest, ) -> None: """Add a log path to an existing jail. Args: socket_path: Path to the fail2ban Unix domain socket. jail: Jail name to which the log path should be added. req: :class:`~app.models.config.AddLogPathRequest` with the path to add. Raises: JailNotFoundError: If *jail* is not a known jail. ConfigOperationError: If the command is rejected by fail2ban. ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. """ client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) try: _ok(await client.send(["status", jail, "short"])) except ValueError as exc: if _is_not_found_error(exc): raise JailNotFoundError(jail) from exc raise tail_flag = "tail" if req.tail else "head" try: _ok(await client.send(["set", jail, "addlogpath", req.log_path, tail_flag])) log.info("log_path_added", jail=jail, path=req.log_path) except ValueError as exc: raise ConfigOperationError(f"Failed to add log path {req.log_path!r}: {exc}") from exc async def delete_log_path( socket_path: str, jail: str, log_path: str, ) -> None: """Remove a monitored log path from an existing jail. Uses ``set dellogpath `` to remove the path at runtime without requiring a daemon restart. Args: socket_path: Path to the fail2ban Unix domain socket. jail: Jail name from which the log path should be removed. log_path: Absolute path of the log file to stop monitoring. Raises: JailNotFoundError: If *jail* is not a known jail. ConfigOperationError: If the command is rejected by fail2ban. ~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable. """ client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) try: _ok(await client.send(["status", jail, "short"])) except ValueError as exc: if _is_not_found_error(exc): raise JailNotFoundError(jail) from exc raise try: _ok(await client.send(["set", jail, "dellogpath", log_path])) log.info("log_path_deleted", jail=jail, path=log_path) except ValueError as exc: raise ConfigOperationError(f"Failed to delete log path {log_path!r}: {exc}") from exc async def preview_log(req: LogPreviewRequest) -> LogPreviewResponse: """Read the last *num_lines* of a log file and test *fail_regex* against each. This operation reads from the local filesystem — no socket is used. Args: req: :class:`~app.models.config.LogPreviewRequest`. Returns: :class:`~app.models.config.LogPreviewResponse` with line-by-line results. """ # Validate the regex first. try: compiled = re.compile(req.fail_regex) except re.error as exc: return LogPreviewResponse( lines=[], total_lines=0, matched_count=0, regex_error=str(exc), ) path = Path(req.log_path) if not path.is_file(): return LogPreviewResponse( lines=[], total_lines=0, matched_count=0, regex_error=f"File not found: {req.log_path!r}", ) # Read the last num_lines lines efficiently. try: raw_lines = await asyncio.get_event_loop().run_in_executor( None, _read_tail_lines, str(path), req.num_lines, ) except OSError as exc: return LogPreviewResponse( lines=[], total_lines=0, matched_count=0, regex_error=f"Cannot read file: {exc}", ) result_lines: list[LogPreviewLine] = [] matched_count = 0 for line in raw_lines: m = compiled.search(line) groups = [str(g) for g in (m.groups() or []) if g is not None] if m else [] result_lines.append(LogPreviewLine(line=line, matched=(m is not None), groups=groups)) if m: matched_count += 1 return LogPreviewResponse( lines=result_lines, total_lines=len(result_lines), matched_count=matched_count, ) def _read_tail_lines(file_path: str, num_lines: int) -> list[str]: """Read the last *num_lines* from *file_path* synchronously. Uses a memory-efficient approach that seeks from the end of the file. Args: file_path: Absolute path to the log file. num_lines: Number of lines to return. Returns: A list of stripped line strings. """ chunk_size = 8192 raw_lines: list[bytes] = [] with open(file_path, "rb") as fh: fh.seek(0, 2) # seek to end end_pos = fh.tell() if end_pos == 0: return [] buf = b"" pos = end_pos while len(raw_lines) <= num_lines and pos > 0: read_size = min(chunk_size, pos) pos -= read_size fh.seek(pos) chunk = fh.read(read_size) buf = chunk + buf raw_lines = buf.split(b"\n") # Strip incomplete leading line unless we've read the whole file. if pos > 0 and len(raw_lines) > 1: raw_lines = raw_lines[1:] return [ln.decode("utf-8", errors="replace").rstrip() for ln in raw_lines[-num_lines:] if ln.strip()] # --------------------------------------------------------------------------- # Map color thresholds # --------------------------------------------------------------------------- async def get_map_color_thresholds(db: aiosqlite.Connection) -> MapColorThresholdsResponse: """Retrieve the current map color threshold configuration. Args: db: Active aiosqlite connection to the application database. Returns: A :class:`MapColorThresholdsResponse` containing the three threshold values. """ high, medium, low = await setup_service.get_map_color_thresholds(db) return MapColorThresholdsResponse( threshold_high=high, threshold_medium=medium, threshold_low=low, ) async def update_map_color_thresholds( db: aiosqlite.Connection, update: MapColorThresholdsUpdate, ) -> None: """Update the map color threshold configuration. Args: db: Active aiosqlite connection to the application database. update: The new threshold values. Raises: ValueError: If validation fails (thresholds must satisfy high > medium > low). """ await setup_service.set_map_color_thresholds( db, threshold_high=update.threshold_high, threshold_medium=update.threshold_medium, threshold_low=update.threshold_low, )