"""Fail2Ban runtime metadata resolution and caching service.""" from __future__ import annotations import asyncio from app.utils.logging_compat import get_logger from app.utils.constants import FAIL2BAN_SOCKET_TIMEOUT_FAST from app.utils.fail2ban_client import ( Fail2BanClient, Fail2BanConnectionError, Fail2BanProtocolError, ) log = get_logger(__name__) class Fail2BanMetadataService: """Resolve and cache runtime fail2ban metadata. This service is responsible for querying fail2ban for metadata such as the path to the fail2ban SQLite database and caching the result so repeated requests do not require a socket discovery round-trip. """ def __init__(self) -> None: self._cache: dict[str, str] = {} self._lock: asyncio.Lock[None] = asyncio.Lock() async def get_db_path(self, socket_path: str, *, force_refresh: bool = False) -> str: """Return the cached fail2ban database path or resolve it if missing. Args: socket_path: The fail2ban Unix domain socket path. force_refresh: If ``True``, attempt to resolve the DB path again even when a cached value exists. Returns: The resolved fail2ban SQLite database path. """ cached = self._cache.get(socket_path) if cached is not None and not force_refresh: return cached async with self._lock: cached = self._cache.get(socket_path) if cached is not None and not force_refresh: return cached try: db_path = await self._resolve_db_path(socket_path) except (Fail2BanConnectionError, Fail2BanProtocolError) as exc: if cached is not None: log.warning( "fail2ban_metadata_service_fallback_to_cache", socket_path=socket_path, error=str(exc), ) return cached raise self._cache[socket_path] = db_path return db_path async def _resolve_db_path(self, socket_path: str) -> str: """Query fail2ban for the configured database path.""" async with Fail2BanClient(socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT_FAST) as client: response = await client.send(["get", "dbfile"]) if not isinstance(response, tuple) or len(response) != 2: raise RuntimeError(f"Unexpected response from fail2ban: {response!r}") code, data = response if code != 0: raise RuntimeError(f"fail2ban error code {code}: {data!r}") if data is None: raise RuntimeError("fail2ban has no database configured (dbfile is None)") db_path = str(data) log.info( "fail2ban_metadata_service_resolved_db_path", socket_path=socket_path, db_path=db_path, ) return db_path def invalidate_db_path(self, socket_path: str) -> None: """Clear a cached DB path so it is re-resolved on next request.""" self._cache.pop(socket_path, None) default_fail2ban_metadata_service = Fail2BanMetadataService()