refactor: Extract fail2ban response utilities into shared module

Consolidate duplicate _ok(), _to_dict(), ensure_list(), and is_not_found_error()
functions from 6 service modules into a single canonical implementation at
backend/app/utils/fail2ban_response.py.

Changes:
- Create fail2ban_response.py with canonical implementations
- Remove local duplicates from: ban_service, jail_service, config_service,
  health_service, server_service, config_file_utils
- Update all imports to use shared module
- Add comprehensive docstrings and examples
- Update Architecture.md and Backend-Development.md documentation

Benefits:
- Single source of truth for response parsing logic
- Eliminates code duplication across service layer
- Improves maintainability and consistency
- Enables centralized bug fixes and improvements

Tests: All 228 service tests passing, no regressions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-23 15:11:21 +02:00
parent 6d21a53620
commit b634ce876a
11 changed files with 832 additions and 444 deletions

View File

@@ -38,6 +38,12 @@ from app.utils.fail2ban_client import (
Fail2BanResponse,
Fail2BanToken,
)
from app.utils.fail2ban_response import (
ensure_list,
is_not_found_error,
ok,
to_dict,
)
if TYPE_CHECKING:
from collections.abc import Awaitable
@@ -115,71 +121,6 @@ def _get_backend_cmd_lock() -> asyncio.Lock:
# ---------------------------------------------------------------------------
def _ok(response: object) -> object:
"""Extract the 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 response indicates an error (return code ≠ 0).
"""
try:
code, data = cast("Fail2BanResponse", 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: object) -> dict[str, object]:
"""Convert a list of ``(key, value)`` pairs to a plain dict.
Args:
pairs: A list of ``(key, value)`` pairs (or any iterable thereof).
Returns:
A :class:`dict` with the keys and values from *pairs*.
"""
if not isinstance(pairs, (list, tuple)):
return {}
result: dict[str, object] = {}
for item in pairs:
try:
k, v = item
result[str(k)] = v
except (TypeError, ValueError):
pass
return result
def _ensure_list(value: object | None) -> list[str]:
"""Coerce a fail2ban response value to a list of strings.
Some fail2ban ``get`` responses return ``None`` or a single string
when there is only one entry. This helper normalises the result.
Args:
value: The raw value from a ``get`` command response.
Returns:
A list of strings, possibly empty.
"""
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 _resolve_geo_info(
ip: str,
*,
@@ -196,32 +137,6 @@ async def _resolve_geo_info(
return None
def _is_not_found_error(exc: Exception) -> bool:
"""Return ``True`` if *exc* indicates a jail does not exist.
Checks both space-separated (``"unknown jail"``) and concatenated
(``"unknownjail"``) forms because fail2ban serialises
``UnknownJailException`` without a space when pickled.
Args:
exc: The exception to inspect.
Returns:
``True`` when the exception message signals an unknown jail.
"""
msg = str(exc).lower()
return any(
phrase in msg
for phrase in (
"unknown jail",
"unknownjail", # covers UnknownJailException serialised by fail2ban
"no jail",
"does not exist",
"not found",
)
)
async def _safe_get(
client: Fail2BanClient,
command: Fail2BanCommand,
@@ -242,7 +157,7 @@ async def _safe_get(
"""
try:
response = await client.send(command)
return _ok(cast("Fail2BanResponse", response))
return ok(cast("Fail2BanResponse", response))
except (ValueError, TypeError, Exception):
return default
@@ -282,7 +197,7 @@ async def _check_backend_cmd_supported(
# Probe: send the command and catch any exception.
try:
_ok(await client.send(["get", jail_name, "backend"]))
ok(await client.send(["get", jail_name, "backend"]))
_backend_cmd_supported = True
log.debug("backend_cmd_supported_detected")
except Exception:
@@ -328,7 +243,7 @@ async def list_jails(socket_path: str) -> JailListResponse:
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
# 1. Fetch global status to get jail names.
global_status = _to_dict(_ok(await client.send(["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()]
@@ -411,9 +326,9 @@ async def _fetch_jail_summary(
jail_status: JailStatus | 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 [])
raw = to_dict(ok(status_raw))
filter_stats = to_dict(raw.get("Filter") or [])
action_stats = to_dict(raw.get("Actions") or [])
jail_status = JailStatus(
currently_banned=int(str(action_stats.get("Currently banned", 0) or 0)),
total_banned=int(str(action_stats.get("Total banned", 0) or 0)),
@@ -427,7 +342,7 @@ async def _fetch_jail_summary(
if isinstance(raw, Exception):
return fallback
try:
return int(str(_ok(cast("Fail2BanResponse", raw))))
return int(str(ok(cast("Fail2BanResponse", raw))))
except (ValueError, TypeError):
return fallback
@@ -435,7 +350,7 @@ async def _fetch_jail_summary(
if isinstance(raw, Exception):
return fallback
try:
return str(_ok(cast("Fail2BanResponse", raw)))
return str(ok(cast("Fail2BanResponse", raw)))
except (ValueError, TypeError):
return fallback
@@ -443,7 +358,7 @@ async def _fetch_jail_summary(
if isinstance(raw, Exception):
return fallback
try:
return bool(_ok(cast("Fail2BanResponse", raw)))
return bool(ok(cast("Fail2BanResponse", raw)))
except (ValueError, TypeError):
return fallback
@@ -482,15 +397,15 @@ async def get_jail(socket_path: str, name: str) -> JailDetailResponse:
# Verify the jail exists by sending a status command first.
try:
status_raw = _ok(await client.send(["status", name, "short"]))
status_raw = ok(await client.send(["status", name, "short"]))
except ValueError as exc:
if _is_not_found_error(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 [])
raw = to_dict(status_raw)
filter_stats = to_dict(raw.get("Filter") or [])
action_stats = to_dict(raw.get("Actions") or [])
jail_status = JailStatus(
currently_banned=int(str(action_stats.get("Currently banned", 0) or 0)),
@@ -559,10 +474,10 @@ async def get_jail(socket_path: str, name: str) -> JailDetailResponse:
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),
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)),
@@ -570,7 +485,7 @@ async def get_jail(socket_path: str, name: str) -> JailDetailResponse:
max_retry=int(str(maxretry_raw or 5)),
bantime_escalation=bantime_escalation,
status=jail_status,
actions=_ensure_list(actions_raw),
actions=ensure_list(actions_raw),
)
log.info("jail_detail_fetched", jail=name)
@@ -597,10 +512,10 @@ async def start_jail(socket_path: str, name: str) -> None:
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
_ok(await client.send(["start", name]))
ok(await client.send(["start", name]))
log.info("jail_started", jail=name)
except ValueError as exc:
if _is_not_found_error(exc):
if is_not_found_error(exc):
raise JailNotFoundError(name) from exc
raise JailOperationError(str(exc)) from exc
@@ -622,10 +537,10 @@ async def stop_jail(socket_path: str, name: str) -> None:
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
_ok(await client.send(["stop", name]))
ok(await client.send(["stop", name]))
log.info("jail_stopped", jail=name)
except ValueError as exc:
if _is_not_found_error(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
@@ -652,10 +567,10 @@ async def set_idle(socket_path: str, name: str, *, on: bool) -> None:
state = "on" if on else "off"
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
_ok(await client.send(["set", name, "idle", state]))
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):
if is_not_found_error(exc):
raise JailNotFoundError(name) from exc
raise JailOperationError(str(exc)) from exc
@@ -682,10 +597,10 @@ async def reload_jail(socket_path: str, name: str) -> None:
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
_ok(await client.send(["reload", name, [], [["start", name]]]))
ok(await client.send(["reload", name, [], [["start", name]]]))
log.info("jail_reloaded", jail=name)
except ValueError as exc:
if _is_not_found_error(exc):
if is_not_found_error(exc):
raise JailNotFoundError(name) from exc
raise JailOperationError(str(exc)) from exc
@@ -724,8 +639,8 @@ async def reload_all(
async with _get_reload_all_lock():
try:
# Resolve jail names so we can build the minimal config stream.
status_raw = _ok(await client.send(["status"]))
status_dict = _to_dict(status_raw)
status_raw = ok(await client.send(["status"]))
status_dict = to_dict(status_raw)
jail_list_raw: str = str(status_dict.get("Jail list", ""))
jail_names = [n.strip() for n in jail_list_raw.split(",") if n.strip()]
@@ -737,12 +652,12 @@ async def reload_all(
names_set -= set(exclude_jails)
stream: list[list[object]] = [["start", n] for n in sorted(names_set)]
_ok(await client.send(["reload", "--all", [], cast("Fail2BanToken", stream)]))
ok(await client.send(["reload", "--all", [], cast("Fail2BanToken", stream)]))
log.info("all_jails_reloaded")
except ValueError as exc:
# Detect UnknownJailException (missing or invalid jail configuration)
# and re-raise as JailNotFoundError for better error specificity.
if _is_not_found_error(exc):
if is_not_found_error(exc):
# Extract the jail name from include_jails if available.
jail_name = include_jails[0] if include_jails else "unknown"
raise JailNotFoundError(jail_name) from exc
@@ -771,7 +686,7 @@ async def restart(socket_path: str) -> None:
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
_ok(await client.send(["stop"]))
ok(await client.send(["stop"]))
log.info("fail2ban_stopped_for_restart")
except ValueError as exc:
raise JailOperationError(str(exc)) from exc
@@ -946,15 +861,15 @@ async def get_jail_banned_ips(
# Verify the jail exists.
try:
_ok(await client.send(["status", jail_name, "short"]))
ok(await client.send(["status", jail_name, "short"]))
except ValueError as exc:
if _is_not_found_error(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"]))
raw_result = ok(await client.send(["get", jail_name, "banip", "--with-time"]))
except (ValueError, TypeError):
raw_result = []
@@ -1059,10 +974,10 @@ async def get_ignore_list(socket_path: str, name: str) -> list[str]:
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
raw = _ok(await client.send(["get", name, "ignoreip"]))
return _ensure_list(raw)
raw = ok(await client.send(["get", name, "ignoreip"]))
return ensure_list(raw)
except ValueError as exc:
if _is_not_found_error(exc):
if is_not_found_error(exc):
raise JailNotFoundError(name) from exc
raise
@@ -1089,10 +1004,10 @@ async def add_ignore_ip(socket_path: str, name: str, ip: str) -> None:
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
_ok(await client.send(["set", name, "addignoreip", ip]))
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):
if is_not_found_error(exc):
raise JailNotFoundError(name) from exc
raise JailOperationError(str(exc)) from exc
@@ -1113,10 +1028,10 @@ async def del_ignore_ip(socket_path: str, name: str, ip: str) -> None:
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
_ok(await client.send(["set", name, "delignoreip", ip]))
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):
if is_not_found_error(exc):
raise JailNotFoundError(name) from exc
raise JailOperationError(str(exc)) from exc
@@ -1138,10 +1053,10 @@ async def get_ignore_self(socket_path: str, name: str) -> bool:
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
raw = _ok(await client.send(["get", name, "ignoreself"]))
raw = ok(await client.send(["get", name, "ignoreself"]))
return bool(raw)
except ValueError as exc:
if _is_not_found_error(exc):
if is_not_found_error(exc):
raise JailNotFoundError(name) from exc
raise
@@ -1163,10 +1078,10 @@ async def set_ignore_self(socket_path: str, name: str, *, on: bool) -> None:
value = "true" if on else "false"
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
_ok(await client.send(["set", name, "ignoreself", value]))
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):
if is_not_found_error(exc):
raise JailNotFoundError(name) from exc
raise JailOperationError(str(exc)) from exc
@@ -1212,10 +1127,10 @@ async def lookup_ip(
with contextlib.suppress(ValueError, Fail2BanConnectionError):
# Use fail2ban's "banned <ip>" command which checks all jails.
_ok(await client.send(["get", "--all", "banned", ip]))
ok(await client.send(["get", "--all", "banned", ip]))
# Fetch jail names from status.
global_status = _to_dict(_ok(await client.send(["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()]
@@ -1234,7 +1149,7 @@ async def lookup_ip(
if isinstance(result, Exception):
continue
try:
ban_list: list[str] = cast("list[str]", _ok(result)) or []
ban_list: list[str] = cast("list[str]", ok(result)) or []
if ip in ban_list:
currently_banned_in.append(jail_name)
except (ValueError, TypeError):
@@ -1282,6 +1197,6 @@ async def unban_all_ips(socket_path: str) -> int:
cannot be reached.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
count: int = int(str(_ok(await client.send(["unban", "--all"])) or 0))
count: int = int(str(ok(await client.send(["unban", "--all"])) or 0))
log.info("all_ips_unbanned", count=count)
return count