"""Jail management service. Provides methods to list, inspect, and control fail2ban jails via the Unix domain socket. All socket I/O is performed through the async :class:`~app.utils.fail2ban_client.Fail2BanClient` wrapper. 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. Mutable state (backend capability detection cache) is stored in the JailServiceState object passed to functions that need it. This allows for proper synchronization and test isolation. """ from __future__ import annotations import asyncio import contextlib import ipaddress from typing import TYPE_CHECKING, cast import structlog from app.exceptions import JailNotFoundError, JailOperationError from app.models.ban_domain import DomainActiveBan from app.models.config import BantimeEscalation from app.models.geo import GeoDetail, IpLookupResponse from app.models.jail_domain import ( DomainJailBannedIps, DomainBantimeEscalation, DomainJail, DomainJailDetail, DomainJailList, DomainJailStatus, DomainJailSummary, ) from app.utils.config_file_utils import start_daemon, wait_for_fail2ban from app.utils.constants import FAIL2BAN_SOCKET_TIMEOUT from app.utils.fail2ban_client import ( Fail2BanClient, Fail2BanCommand, Fail2BanConnectionError, Fail2BanResponse, ) from app.utils.fail2ban_response import ( ensure_list, is_not_found_error, ok, to_dict, ) from app.utils.jail_socket import reload_all from app.utils.pagination import create_pagination_metadata from app.utils.runtime_state import JailServiceState # noqa: TC001 if TYPE_CHECKING: from collections.abc import Awaitable import aiohttp import aiosqlite from app.models.geo import GeoEnricher, GeoInfo from app.services.geo_cache import GeoCache log: structlog.stdlib.BoundLogger = structlog.get_logger() __all__ = ["reload_all"] # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- #: Maximum page size for paginated ban results. _MAX_PAGE_SIZE: int = 100 # --------------------------------------------------------------------------- # Custom exceptions # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- async def _resolve_geo_info( ip: str, *, http_session: aiohttp.ClientSession | None = None, geo_enricher: GeoEnricher | None = None, ) -> GeoInfo | None: """Resolve geolocation using a custom enricher only. Note: Direct HTTP lookups are no longer supported here. Callers should provide an explicit geo_enricher or handle geo lookups via dependency injection at a higher layer. """ if geo_enricher is not None: return await geo_enricher(ip) return None async def _safe_get( client: Fail2BanClient, command: Fail2BanCommand, default: object | None = None, ) -> object | None: """Send a ``get`` command and return ``default`` on error. Errors during optional detail queries (logpath, regex, etc.) should not abort the whole request — this helper swallows them gracefully. Args: client: The :class:`~app.utils.fail2ban_client.Fail2BanClient` to use. command: The command list to send. default: Value to return when the command fails. Returns: The response payload, or *default* on any error. """ try: response = await client.send(command) return ok(cast("Fail2BanResponse", response)) except (ValueError, TypeError, Exception): return default async def _check_backend_cmd_supported( client: Fail2BanClient, jail_name: str, state: JailServiceState, ) -> bool: """Detect whether the fail2ban daemon supports optional ``get ... backend`` command. Some fail2ban versions (e.g. LinuxServer.io container) do not implement the optional ``get backend`` and ``get idle`` transmitter sub-commands. This helper probes the daemon once and caches the result to avoid repeated "Invalid command" errors in the fail2ban log. Uses double-check locking to minimize lock contention in concurrent polls. Args: client: The :class:`~app.utils.fail2ban_client.Fail2BanClient` to use. jail_name: Name of any jail to use for the probe command. state: The jail service state holder for capability cache. Returns: ``True`` if the command is supported, ``False`` otherwise. Once determined, the result is cached and reused for all jails. """ # Fast path: return cached result if already determined. if state.backend_cmd_supported is not None: return state.backend_cmd_supported # Slow path: acquire lock and probe the command once. async with state.get_backend_cmd_lock(): # Double-check idiom: another coroutine may have probed while we waited. if state.backend_cmd_supported is not None: return state.backend_cmd_supported # Probe: send the command and catch any exception. try: ok(await client.send(["get", jail_name, "backend"])) state.backend_cmd_supported = True log.debug("backend_cmd_supported_detected") except Exception: state.backend_cmd_supported = False log.debug("backend_cmd_unsupported_detected") return state.backend_cmd_supported # --------------------------------------------------------------------------- # Public API — Jail listing & detail # --------------------------------------------------------------------------- async def list_jails(socket_path: str, state: JailServiceState) -> DomainJailList: """Return a summary list of all active fail2ban jails. Queries the daemon for the global jail list and then fetches status and key configuration for each jail in parallel. Args: socket_path: Path to the fail2ban Unix domain socket. state: The jail service state holder for capability cache. Returns: :class:`~app.models.jail_domain.DomainJailList` with all active jails. Raises: ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket cannot be reached. """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) # 1. Fetch global status to get jail names. 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 [] ) log.info("jail_list_fetched", count=len(jail_names)) if not jail_names: return DomainJailList(items=[], total=0) # 2. Fetch summary data for every jail in parallel. summaries: list[DomainJailSummary] = await asyncio.gather( *[_fetch_jail_summary(client, name, state) for name in jail_names], return_exceptions=False, ) return DomainJailList(items=list(summaries), total=len(summaries)) async def _fetch_jail_summary( client: Fail2BanClient, name: str, state: JailServiceState, ) -> DomainJailSummary: """Fetch and build a :class:`~app.models.jail_domain.DomainJailSummary` for one jail. Sends the ``status``, ``get ... bantime``, ``findtime``, ``maxretry``, ``backend``, and ``idle`` commands in parallel (if supported). The ``backend`` and ``idle`` commands are optional and not supported in all fail2ban versions. If not supported, this function will not send them to avoid spamming the fail2ban log with "Invalid command" errors. Args: client: Shared :class:`~app.utils.fail2ban_client.Fail2BanClient`. name: Jail name. state: The jail service state holder for capability cache. Returns: A :class:`~app.models.jail_domain.DomainJailSummary` populated from the responses. """ # Check whether optional backend/idle commands are supported. # This probe happens once per session and is cached to avoid repeated # "Invalid command" errors in the fail2ban log. backend_cmd_is_supported = await _check_backend_cmd_supported(client, name, state) # Build the gather list based on command support. gather_list: list[Awaitable[object]] = [ client.send(["status", name, "short"]), client.send(["get", name, "bantime"]), client.send(["get", name, "findtime"]), client.send(["get", name, "maxretry"]), ] if backend_cmd_is_supported: # Commands are supported; send them for real values. gather_list.extend([ client.send(["get", name, "backend"]), client.send(["get", name, "idle"]), ]) else: # Commands not supported; return default values without sending. async def _return_default(value: object | None) -> Fail2BanResponse: return (0, value) gather_list.extend([ _return_default("polling"), # backend default _return_default(False), # idle default ]) _r = await asyncio.gather(*gather_list, return_exceptions=True) status_raw: object | Exception = _r[0] bantime_raw: object | Exception = _r[1] findtime_raw: object | Exception = _r[2] maxretry_raw: object | Exception = _r[3] backend_raw: object | Exception = _r[4] idle_raw: object | Exception = _r[5] # Parse jail status (filter + actions). jail_status: DomainJailStatus | None = None if not isinstance(status_raw, Exception): try: raw = to_dict(ok(status_raw)) filter_stats = to_dict(raw.get("Filter") or []) action_stats = to_dict(raw.get("Actions") or []) jail_status = DomainJailStatus( currently_banned=int(str(action_stats.get("Currently banned", 0) or 0)), total_banned=int(str(action_stats.get("Total banned", 0) or 0)), currently_failed=int(str(filter_stats.get("Currently failed", 0) or 0)), total_failed=int(str(filter_stats.get("Total failed", 0) or 0)), ) except (ValueError, TypeError) as exc: log.warning("jail_status_parse_error", jail=name, error=str(exc)) def _safe_int(raw: object | Exception, fallback: int) -> int: if isinstance(raw, Exception): return fallback try: return int(str(ok(cast("Fail2BanResponse", raw)))) except (ValueError, TypeError): return fallback def _safe_str(raw: object | Exception, fallback: str) -> str: if isinstance(raw, Exception): return fallback try: return str(ok(cast("Fail2BanResponse", raw))) except (ValueError, TypeError): return fallback def _safe_bool(raw: object | Exception, fallback: bool = False) -> bool: if isinstance(raw, Exception): return fallback try: return bool(ok(cast("Fail2BanResponse", raw))) except (ValueError, TypeError): return fallback return DomainJailSummary( name=name, enabled=True, running=True, idle=_safe_bool(idle_raw), backend=_safe_str(backend_raw, "polling"), find_time=_safe_int(findtime_raw, 600), ban_time=_safe_int(bantime_raw, 600), max_retry=_safe_int(maxretry_raw, 5), status=jail_status, ) async def get_jail(socket_path: str, name: str) -> DomainJailDetail: """Return full detail for a single fail2ban jail. Sends multiple ``get`` and ``status`` commands in parallel to build the complete jail snapshot. Args: socket_path: Path to the fail2ban Unix domain socket. name: Jail name. Returns: :class:`~app.models.jail_domain.DomainJailDetail` with the full jail. Raises: JailNotFoundError: If *name* is not a known jail. ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket cannot be reached. """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) # Verify the jail exists by sending a status command first. try: status_raw = ok(await client.send(["status", name, "short"])) except ValueError as exc: if is_not_found_error(exc): raise JailNotFoundError(name) from exc raise raw = to_dict(status_raw) filter_stats = to_dict(raw.get("Filter") or []) action_stats = to_dict(raw.get("Actions") or []) jail_status = DomainJailStatus( currently_banned=int(str(action_stats.get("Currently banned", 0) or 0)), total_banned=int(str(action_stats.get("Total banned", 0) or 0)), currently_failed=int(str(filter_stats.get("Currently failed", 0) or 0)), total_failed=int(str(filter_stats.get("Total failed", 0) or 0)), ) # Fetch all detail fields in parallel. ( logpath_raw, failregex_raw, ignoreregex_raw, ignoreip_raw, datepattern_raw, logencoding_raw, bantime_raw, findtime_raw, maxretry_raw, backend_raw, idle_raw, actions_raw, bt_increment_raw, bt_factor_raw, bt_formula_raw, bt_multipliers_raw, bt_maxtime_raw, bt_rndtime_raw, bt_overalljails_raw, ) = await asyncio.gather( _safe_get(client, ["get", name, "logpath"], []), _safe_get(client, ["get", name, "failregex"], []), _safe_get(client, ["get", name, "ignoreregex"], []), _safe_get(client, ["get", name, "ignoreip"], []), _safe_get(client, ["get", name, "datepattern"], None), _safe_get(client, ["get", name, "logencoding"], "UTF-8"), _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, "backend"], "polling"), _safe_get(client, ["get", name, "idle"], False), _safe_get(client, ["get", name, "actions"], []), _safe_get(client, ["get", name, "bantime.increment"], False), _safe_get(client, ["get", name, "bantime.factor"], None), _safe_get(client, ["get", name, "bantime.formula"], None), _safe_get(client, ["get", name, "bantime.multipliers"], None), _safe_get(client, ["get", name, "bantime.maxtime"], None), _safe_get(client, ["get", name, "bantime.rndtime"], None), _safe_get(client, ["get", name, "bantime.overalljails"], False), ) bt_increment: bool = bool(bt_increment_raw) bantime_escalation = DomainBantimeEscalation( increment=bt_increment, factor=float(str(bt_factor_raw)) if bt_factor_raw is not None else None, formula=str(bt_formula_raw) if bt_formula_raw else None, multipliers=str(bt_multipliers_raw) if bt_multipliers_raw else None, max_time=int(str(bt_maxtime_raw)) if bt_maxtime_raw is not None else None, rnd_time=int(str(bt_rndtime_raw)) if bt_rndtime_raw is not None else None, overall_jails=bool(bt_overalljails_raw), ) jail = DomainJail( name=name, enabled=True, running=True, idle=bool(idle_raw), backend=str(backend_raw or "polling"), log_paths=ensure_list(logpath_raw), fail_regex=ensure_list(failregex_raw), ignore_regex=ensure_list(ignoreregex_raw), ignore_ips=ensure_list(ignoreip_raw), date_pattern=str(datepattern_raw) if datepattern_raw else None, log_encoding=str(logencoding_raw or "UTF-8"), find_time=int(str(findtime_raw or 600)), ban_time=int(str(bantime_raw or 600)), max_retry=int(str(maxretry_raw or 5)), bantime_escalation=bantime_escalation, status=jail_status, actions=ensure_list(actions_raw), ) log.info("jail_detail_fetched", jail=name) return DomainJailDetail(jail=jail, ignore_list=[], ignore_self=False) # --------------------------------------------------------------------------- # Public API — Jail control # --------------------------------------------------------------------------- async def start_jail(socket_path: str, name: str) -> None: """Start a stopped fail2ban jail. Args: socket_path: Path to the fail2ban Unix domain socket. name: Jail name to start. Raises: JailNotFoundError: If *name* is not a known jail. JailOperationError: If fail2ban reports the operation failed. ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket cannot be reached. """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) try: ok(await client.send(["start", name])) log.info("jail_started", jail=name) except ValueError as exc: if is_not_found_error(exc): raise JailNotFoundError(name) from exc raise JailOperationError(str(exc)) from exc async def stop_jail(socket_path: str, name: str) -> None: """Stop a running fail2ban jail. If the jail is not currently active (already stopped), this is a no-op and no error is raised — the operation is idempotent. Args: socket_path: Path to the fail2ban Unix domain socket. name: Jail name to stop. Raises: JailOperationError: If fail2ban reports the operation failed. ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket cannot be reached. """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) try: ok(await client.send(["stop", name])) log.info("jail_stopped", jail=name) except ValueError as exc: if is_not_found_error(exc): # Jail is already stopped or was never running — treat as a no-op. log.info("jail_stop_noop", jail=name) return raise JailOperationError(str(exc)) from exc async def set_idle(socket_path: str, name: str, *, on: bool) -> None: """Toggle the idle mode of a fail2ban jail. When idle mode is on the jail pauses monitoring without stopping completely; existing bans remain active. Args: socket_path: Path to the fail2ban Unix domain socket. name: Jail name. on: Pass ``True`` to enable idle, ``False`` to disable it. Raises: JailNotFoundError: If *name* is not a known jail. JailOperationError: If fail2ban reports the operation failed. ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket cannot be reached. """ state = "on" if on else "off" client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) try: ok(await client.send(["set", name, "idle", state])) log.info("jail_idle_toggled", jail=name, idle=on) except ValueError as exc: if is_not_found_error(exc): raise JailNotFoundError(name) from exc raise JailOperationError(str(exc)) from exc async def reload_jail(socket_path: str, name: str) -> None: """Reload a single fail2ban jail to pick up configuration changes. The reload protocol requires a non-empty configuration stream. Without one, fail2ban's end-of-reload phase removes every jail that received no configuration commands — permanently deleting the jail from the running daemon. Sending ``['start', name]`` as the minimal stream is sufficient: ``startJail`` removes the jail from ``reload_state``, which causes the end phase to *commit* the reload instead of deleting the jail. Args: socket_path: Path to the fail2ban Unix domain socket. name: Jail name to reload. Raises: JailNotFoundError: If *name* is not a known jail. JailOperationError: If fail2ban reports the operation failed. ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket cannot be reached. """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) try: ok(await client.send(["reload", name, [], [["start", name]]])) log.info("jail_reloaded", jail=name) except ValueError as exc: if is_not_found_error(exc): raise JailNotFoundError(name) from exc raise JailOperationError(str(exc)) from exc async def restart(socket_path: str) -> None: """Stop the fail2ban daemon via the Unix socket. Sends ``["stop"]`` to the fail2ban daemon, which calls ``server.quit()`` on the daemon side and tears down all jails. The caller is responsible for starting the daemon again (e.g. via ``fail2ban-client start``). Note: ``["restart"]`` is a *client-side* orchestration command that is not handled by the fail2ban server transmitter — sending it to the socket raises ``"Invalid command"`` in the daemon. Args: socket_path: Path to the fail2ban Unix domain socket. Raises: JailOperationError: If fail2ban reports the stop command failed. ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket cannot be reached. """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) try: ok(await client.send(["stop"])) log.info("fail2ban_stopped_for_restart") except ValueError as exc: raise JailOperationError(str(exc)) from exc async def restart_daemon( socket_path: str, start_cmd_parts: list[str], max_wait_seconds: float = FAIL2BAN_SOCKET_TIMEOUT, ) -> bool: """Restart the fail2ban daemon and verify it comes back online. This function stops the daemon through the socket, starts it with the configured command, and probes the socket until fail2ban accepts status requests again. Args: socket_path: Path to the fail2ban Unix domain socket. start_cmd_parts: The configured fail2ban start command split into executable and arguments. max_wait_seconds: The maximum number of seconds to wait for the daemon to become responsive after starting. Returns: ``True`` when the daemon is started and responsive. ``False`` when the command failed or fail2ban never became responsive. Raises: JailOperationError: If the stop command failed. ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket cannot be reached while stopping fail2ban. """ await restart(socket_path) if not await start_daemon(start_cmd_parts): log.warning( "fail2ban_start_command_failed", command=" ".join(start_cmd_parts), ) return False return await wait_for_fail2ban( socket_path, max_wait_seconds=max_wait_seconds, ) def _parse_ban_entry(entry: str, jail: str) -> DomainActiveBan | None: """Parse a ban entry from ``get banip --with-time`` output. Expected format:: "1.2.3.4 \t2025-01-01 12:00:00 + 3600 = 2025-01-01 13:00:00" Args: entry: Raw ban entry string. jail: Jail name for the resulting record. Returns: A :class:`~app.models.jail_domain.DomainActiveBan` or ``None`` if parsing fails. """ from datetime import UTC, datetime try: parts = entry.split("\t", 1) ip = parts[0].strip() # Validate IP ipaddress.ip_address(ip) if len(parts) < 2: # Entry has no time info — return with unknown times. return DomainActiveBan( ip=ip, jail=jail, banned_at=None, expires_at=None, ban_count=1, country=None, ) time_part = parts[1].strip() # Format: "2025-01-01 12:00:00 + 3600 = 2025-01-01 13:00:00" # Split at " + " to get banned_at and remainder. plus_idx = time_part.find(" + ") if plus_idx == -1: banned_at_str = time_part.strip() expires_at_str: str | None = None else: banned_at_str = time_part[:plus_idx].strip() remainder = time_part[plus_idx + 3 :] # skip " + " eq_idx = remainder.find(" = ") expires_at_str = remainder[eq_idx + 3 :].strip() if eq_idx != -1 else None _date_fmt = "%Y-%m-%d %H:%M:%S" def _to_iso(ts: str) -> str: dt = datetime.strptime(ts, _date_fmt).replace(tzinfo=UTC) return dt.isoformat() banned_at_iso: str | None = None expires_at_iso: str | None = None with contextlib.suppress(ValueError): banned_at_iso = _to_iso(banned_at_str) with contextlib.suppress(ValueError): if expires_at_str: expires_at_iso = _to_iso(expires_at_str) return DomainActiveBan( ip=ip, jail=jail, banned_at=banned_at_iso, expires_at=expires_at_iso, ban_count=1, country=None, ) except (ValueError, IndexError, AttributeError) as exc: log.debug("ban_entry_parse_error", entry=entry, jail=jail, error=str(exc)) return None # --------------------------------------------------------------------------- # Public API — Jail-specific paginated bans # --------------------------------------------------------------------------- async def get_jail_banned_ips( socket_path: str, jail_name: str, page: int = 1, page_size: int = 25, search: str | None = None, geo_cache: GeoCache | None = None, http_session: aiohttp.ClientSession | None = None, app_db: aiosqlite.Connection | None = None, ) -> DomainJailBannedIps: """Return a paginated list of currently banned IPs for a single jail. Fetches the full ban list from the fail2ban socket, applies an optional substring search filter on the IP, paginates server-side, and geo-enriches **only** the current page slice to stay within rate limits. Args: socket_path: Path to the fail2ban Unix domain socket. jail_name: Name of the jail to query. page: 1-based page number (default 1). page_size: Items per page; clamped to :data:`_MAX_PAGE_SIZE` (default 25). search: Optional case-insensitive substring filter applied to IP addresses. http_session: Optional shared :class:`aiohttp.ClientSession` for geo enrichment via :meth:`GeoCache.lookup_batch`. app_db: Optional BanGUI application database for persistent geo cache. Returns: :class:`~app.models.jail_domain.DomainJailBannedIps` with the paginated bans. Raises: JailNotFoundError: If *jail_name* is not a known active jail. ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket is unreachable. """ # Clamp page_size to the allowed maximum. page_size = min(page_size, _MAX_PAGE_SIZE) client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) # Verify the jail exists. try: ok(await client.send(["status", jail_name, "short"])) except ValueError as exc: if is_not_found_error(exc): raise JailNotFoundError(jail_name) from exc raise # Fetch the full ban list for this jail. try: raw_result = ok(await client.send(["get", jail_name, "banip", "--with-time"])) except (ValueError, TypeError): raw_result = [] ban_list: list[str] = cast("list[str]", raw_result) or [] # Parse all entries. all_bans: list[DomainActiveBan] = [] for entry in ban_list: ban = _parse_ban_entry(str(entry), jail_name) if ban is not None: all_bans.append(ban) # Apply optional substring search filter (case-insensitive). if search: search_lower = search.lower() all_bans = [b for b in all_bans if search_lower in b.ip.lower()] total = len(all_bans) # Slice the requested page. start = (page - 1) * page_size page_bans = all_bans[start : start + page_size] # Geo-enrich only the page slice. if http_session is not None and page_bans and geo_cache is not None: page_ips = [b.ip for b in page_bans] try: geo_map = await geo_cache.lookup_batch(page_ips, http_session, db=app_db) except Exception: # noqa: BLE001 log.warning("jail_banned_ips_geo_failed", jail=jail_name) geo_map = {} enriched_page: list[DomainActiveBan] = [] for ban in page_bans: geo = geo_map.get(ban.ip) if geo is not None: enriched_page.append( DomainActiveBan( ip=ban.ip, jail=ban.jail, banned_at=ban.banned_at, expires_at=ban.expires_at, ban_count=ban.ban_count, country=geo.country_code, ) ) else: enriched_page.append(ban) page_bans = enriched_page log.info( "jail_banned_ips_fetched", jail=jail_name, total=total, page=page, page_size=page_size, ) return DomainJailBannedIps( items=page_bans, total=total, page=page, page_size=page_size, ) async def _enrich_bans( bans: list[DomainActiveBan], geo_enricher: GeoEnricher, ) -> list[DomainActiveBan]: """Enrich ban records with geo data asynchronously. Args: bans: The list of :class:`~app.models.jail_domain.DomainActiveBan` records to enrich. geo_enricher: Async callable ``(ip) → GeoInfo | None``. Returns: The same list with ``country`` fields populated where lookup succeeded. """ geo_results: list[object | Exception] = await asyncio.gather( *[cast("Awaitable[object]", geo_enricher(ban.ip)) for ban in bans], return_exceptions=True, ) enriched: list[DomainActiveBan] = [] for ban, geo in zip(bans, geo_results, strict=False): if geo is not None and not isinstance(geo, Exception): geo_info = cast("GeoInfo", geo) enriched.append( DomainActiveBan( ip=ban.ip, jail=ban.jail, banned_at=ban.banned_at, expires_at=ban.expires_at, ban_count=ban.ban_count, country=geo_info.country_code, ) ) else: enriched.append(ban) return enriched # --------------------------------------------------------------------------- # Public API — Ignore list (IP whitelist) # --------------------------------------------------------------------------- async def get_ignore_list(socket_path: str, name: str) -> list[str]: """Return the ignore list for a fail2ban jail. Args: socket_path: Path to the fail2ban Unix domain socket. name: Jail name. Returns: List of IP addresses and CIDR networks on the jail's ignore list. Raises: JailNotFoundError: If *name* is not a known jail. ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket cannot be reached. """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) try: raw = ok(await client.send(["get", name, "ignoreip"])) return ensure_list(raw) except ValueError as exc: if is_not_found_error(exc): raise JailNotFoundError(name) from exc raise async def add_ignore_ip(socket_path: str, name: str, ip: str) -> None: """Add an IP address or CIDR network to a jail's ignore list. Args: socket_path: Path to the fail2ban Unix domain socket. name: Jail name. ip: IP address or CIDR network to add. Raises: JailNotFoundError: If *name* is not a known jail. JailOperationError: If fail2ban reports the operation failed. ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket cannot be reached. """ # Basic format validation. try: ipaddress.ip_network(ip, strict=False) except ValueError as exc: raise ValueError(f"Invalid IP address or network: {ip!r}") from exc client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) try: ok(await client.send(["set", name, "addignoreip", ip])) log.info("ignore_ip_added", jail=name, ip=ip) except ValueError as exc: if is_not_found_error(exc): raise JailNotFoundError(name) from exc raise JailOperationError(str(exc)) from exc async def del_ignore_ip(socket_path: str, name: str, ip: str) -> None: """Remove an IP address or CIDR network from a jail's ignore list. Args: socket_path: Path to the fail2ban Unix domain socket. name: Jail name. ip: IP address or CIDR network to remove. Raises: JailNotFoundError: If *name* is not a known jail. JailOperationError: If fail2ban reports the operation failed. ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket cannot be reached. """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) try: ok(await client.send(["set", name, "delignoreip", ip])) log.info("ignore_ip_removed", jail=name, ip=ip) except ValueError as exc: if is_not_found_error(exc): raise JailNotFoundError(name) from exc raise JailOperationError(str(exc)) from exc async def get_ignore_self(socket_path: str, name: str) -> bool: """Return whether a jail ignores the server's own IP addresses. Args: socket_path: Path to the fail2ban Unix domain socket. name: Jail name. Returns: ``True`` when ``ignoreself`` is enabled for the jail. Raises: JailNotFoundError: If *name* is not a known jail. ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket cannot be reached. """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) try: raw = ok(await client.send(["get", name, "ignoreself"])) return bool(raw) except ValueError as exc: if is_not_found_error(exc): raise JailNotFoundError(name) from exc raise async def set_ignore_self(socket_path: str, name: str, *, on: bool) -> None: """Toggle the ``ignoreself`` option for a fail2ban jail. Args: socket_path: Path to the fail2ban Unix domain socket. name: Jail name. on: ``True`` to enable ignoreself, ``False`` to disable. Raises: JailNotFoundError: If *name* is not a known jail. JailOperationError: If fail2ban reports the operation failed. ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket cannot be reached. """ value = "true" if on else "false" client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) try: ok(await client.send(["set", name, "ignoreself", value])) log.info("ignore_self_toggled", jail=name, on=on) except ValueError as exc: if is_not_found_error(exc): raise JailNotFoundError(name) from exc raise JailOperationError(str(exc)) from exc # --------------------------------------------------------------------------- # Public API — IP lookup # --------------------------------------------------------------------------- async def lookup_ip( socket_path: str, ip: str, geo_enricher: GeoEnricher | None = None, http_session: aiohttp.ClientSession | None = None, ) -> IpLookupResponse: """Return ban status and history for a single IP address. Checks every running jail for whether the IP is currently banned. Also queries the fail2ban database for historical ban records. Args: socket_path: Path to the fail2ban Unix domain socket. ip: IP address to look up. geo_enricher: Optional async callable ``(ip) → GeoInfo | None``. Returns: A dict with keys: * ``ip`` — the queried IP address * ``currently_banned_in`` — list of jails where the IP is active * ``geo`` — ``GeoInfo`` dataclass or ``None`` Raises: ValueError: If *ip* is not a valid IP address. ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket cannot be reached. """ try: ipaddress.ip_address(ip) except ValueError as exc: raise ValueError(f"Invalid IP address: {ip!r}") from exc client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) with contextlib.suppress(ValueError, Fail2BanConnectionError): # Use fail2ban's "banned " command which checks all jails. ok(await client.send(["get", "--all", "banned", ip])) # Fetch jail names from status. 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 [] ) # Check ban status per jail in parallel. ban_results: list[object | Exception] = await asyncio.gather( *[client.send(["get", jn, "banip"]) for jn in jail_names], return_exceptions=True, ) currently_banned_in: list[str] = [] for jail_name, result in zip(jail_names, ban_results, strict=False): if isinstance(result, Exception): continue try: ban_list: list[str] = cast("list[str]", ok(result)) or [] if ip in ban_list: currently_banned_in.append(jail_name) except (ValueError, TypeError): pass geo: GeoDetail | None = None if geo_enricher is not None or http_session is not None: with contextlib.suppress(Exception): # noqa: BLE001 raw_geo = await _resolve_geo_info( ip, http_session=http_session, geo_enricher=geo_enricher, ) if raw_geo is not None: geo = GeoDetail( country_code=raw_geo.country_code, country_name=raw_geo.country_name, asn=raw_geo.asn, org=raw_geo.org, ) log.info("ip_lookup_completed", ip=ip, banned_in_jails=currently_banned_in) return IpLookupResponse( ip=ip, currently_banned_in=currently_banned_in, geo=geo, ) async def unban_all_ips(socket_path: str) -> int: """Unban every currently banned IP across all fail2ban jails. Uses fail2ban's global ``unban --all`` command, which atomically removes every active ban from every jail in a single socket round-trip. Args: socket_path: Path to the fail2ban Unix domain socket. Returns: The number of IP addresses that were unbanned. Raises: ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket cannot be reached. """ client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT) count: int = int(str(ok(await client.send(["unban", "--all"])) or 0)) log.info("all_ips_unbanned", count=count) return count