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

@@ -24,13 +24,13 @@ from app.models.ban import (
BUCKET_SECONDS,
BUCKET_SIZE_LABEL,
TIME_RANGE_SECONDS,
ActiveBan,
ActiveBanListResponse,
BanOrigin,
BansByCountryResponse,
BansByJailResponse,
BanTrendBucket,
BanTrendResponse,
ActiveBan,
ActiveBanListResponse,
DashboardBanItem,
DashboardBanListResponse,
TimeRange,
@@ -40,12 +40,17 @@ from app.models.ban import (
from app.models.ban import (
JailBanCount as JailBanCountModel,
)
from app.repositories import fail2ban_db_repo, history_archive_repo as default_history_archive_repo
from app.repositories import fail2ban_db_repo
from app.repositories import history_archive_repo as default_history_archive_repo
from app.services.fail2ban_metadata_service import default_fail2ban_metadata_service
from app.utils.fail2ban_db_utils import parse_data_json, ts_to_iso
from app.utils.fail2ban_client import (
Fail2BanClient,
Fail2BanResponse,
)
from app.utils.fail2ban_db_utils import parse_data_json, ts_to_iso
from app.utils.fail2ban_response import (
is_not_found_error,
ok,
to_dict,
)
if TYPE_CHECKING:
@@ -71,80 +76,8 @@ _DEFAULT_PAGE_SIZE: int = 100
_MAX_PAGE_SIZE: int = 500
_SOCKET_TIMEOUT: float = 5.0
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
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 = response # type: ignore[assignment]
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."""
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)]
def _is_not_found_error(exc: Exception) -> bool:
"""Return ``True`` if *exc* indicates a jail does not exist."""
msg = str(exc).lower()
return any(
phrase in msg
for phrase in (
"unknown jail",
"unknownjail",
"no jail",
"does not exist",
"not found",
)
)
async def ban_ip(socket_path: str, jail: str, ip: str) -> None:
"""Ban an IP address in the specified jail."""
@@ -156,9 +89,9 @@ async def ban_ip(socket_path: str, jail: str, ip: str) -> None:
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
_ok(await client.send(["set", jail, "banip", ip]))
ok(await client.send(["set", jail, "banip", ip]))
except ValueError as exc:
if _is_not_found_error(exc):
if is_not_found_error(exc):
raise JailNotFoundError(jail) from exc
raise JailOperationError(str(exc)) from exc
@@ -173,13 +106,13 @@ async def unban_ip(socket_path: str, ip: str, jail: str | None = None) -> None:
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
if jail is None:
_ok(await client.send(["unban", ip]))
ok(await client.send(["unban", ip]))
return
try:
_ok(await client.send(["set", jail, "unbanip", ip]))
ok(await client.send(["set", jail, "unbanip", ip]))
except ValueError as exc:
if _is_not_found_error(exc):
if is_not_found_error(exc):
raise JailNotFoundError(jail) from exc
raise JailOperationError(str(exc)) from exc
@@ -324,7 +257,7 @@ async def get_active_bans(
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
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()]
@@ -351,7 +284,7 @@ async def get_active_bans(
continue
try:
ban_list: list[str] = cast("list[str]", _ok(raw_result)) or []
ban_list: list[str] = cast("list[str]", ok(raw_result)) or []
except (TypeError, ValueError) as exc:
log.warning(
"active_bans_parse_error",

View File

@@ -19,7 +19,7 @@ from typing import TYPE_CHECKING, TypeVar, cast
import structlog
from app.utils.fail2ban_client import Fail2BanCommand, Fail2BanResponse, Fail2BanToken
from app.utils.fail2ban_client import Fail2BanCommand, Fail2BanToken
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
@@ -52,6 +52,12 @@ from app.services.settings_service import (
set_map_color_thresholds as util_set_map_color_thresholds,
)
from app.utils.fail2ban_client import Fail2BanClient
from app.utils.fail2ban_response import (
ensure_list,
is_not_found_error,
ok,
to_dict,
)
log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -65,56 +71,10 @@ _SOCKET_TIMEOUT: float = 10.0
# ---------------------------------------------------------------------------
# Internal helpers (mirrored from jail_service for isolation)
# Internal helpers
# ---------------------------------------------------------------------------
def _ok(response: object) -> object:
"""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 = 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."""
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 ``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)]
T = TypeVar("T")
@@ -125,7 +85,7 @@ async def _safe_get(
) -> object | None:
"""Send a command and return *default* if it fails."""
try:
return _ok(await client.send(command))
return ok(await client.send(command))
except Exception:
return default
@@ -139,15 +99,6 @@ async def _safe_get_typed[T](
return cast("T", await _safe_get(client, command, 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.
@@ -187,9 +138,9 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse:
# Verify existence.
try:
_ok(await client.send(["status", name, "short"]))
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
@@ -228,15 +179,15 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse:
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),
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"),
use_dns=str(usedns_raw or "warn"),
prefregex=str(prefregex_raw) if prefregex_raw else "",
actions=_ensure_list(actions_raw),
actions=ensure_list(actions_raw),
bantime_escalation=bantime_escalation,
)
@@ -258,7 +209,7 @@ async def list_jail_configs(socket_path: str) -> JailConfigListResponse:
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
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()]
@@ -325,15 +276,15 @@ async def update_jail_config(
# Verify existence.
try:
_ok(await client.send(["status", name, "short"]))
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
async def _set(key: str, value: Fail2BanToken) -> None:
try:
_ok(await client.send(["set", name, key, value]))
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
@@ -402,7 +353,7 @@ async def _replace_regex_list(
"""
# Determine current count.
current_raw: list[object] = await _safe_get_typed(client, ["get", jail, field], [])
current: list[str] = _ensure_list(current_raw)
current: list[str] = ensure_list(current_raw)
del_cmd = f"del{field}"
add_cmd = f"add{field}"
@@ -410,7 +361,7 @@ async def _replace_regex_list(
# 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]))
ok(await client.send(["set", jail, del_cmd, idx]))
# Add new patterns.
for pattern in new_patterns:
@@ -418,7 +369,7 @@ async def _replace_regex_list(
if err:
raise ConfigValidationError(f"Invalid regex: {err!r} (pattern: {pattern!r})")
try:
_ok(await client.send(["set", jail, add_cmd, pattern]))
ok(await client.send(["set", jail, add_cmd, pattern]))
except ValueError as exc:
raise ConfigOperationError(f"Failed to add {field} pattern: {exc}") from exc
@@ -477,7 +428,7 @@ async def update_global_config(socket_path: str, update: GlobalConfigUpdate) ->
async def _set_global(key: str, value: Fail2BanToken) -> None:
try:
_ok(await client.send(["set", key, value]))
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
@@ -528,15 +479,15 @@ async def add_log_path(
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
_ok(await client.send(["status", jail, "short"]))
ok(await client.send(["status", jail, "short"]))
except ValueError as exc:
if _is_not_found_error(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]))
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
@@ -565,14 +516,14 @@ async def delete_log_path(
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
_ok(await client.send(["status", jail, "short"]))
ok(await client.send(["status", jail, "short"]))
except ValueError as exc:
if _is_not_found_error(exc):
if is_not_found_error(exc):
raise JailNotFoundError(jail) from exc
raise
try:
_ok(await client.send(["set", jail, "dellogpath", log_path]))
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

View File

@@ -23,7 +23,10 @@ from app.utils.fail2ban_client import (
Fail2BanCommand,
Fail2BanConnectionError,
Fail2BanProtocolError,
Fail2BanResponse,
)
from app.utils.fail2ban_response import (
ok,
to_dict,
)
log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -35,59 +38,6 @@ log: structlog.stdlib.BoundLogger = structlog.get_logger()
_SOCKET_TIMEOUT: float = 5.0
def _ok(response: object) -> object:
"""Extract the payload from a fail2ban ``(return_code, data)`` response.
fail2ban wraps every response in a ``(0, data)`` success tuple or
a ``(1, exception)`` error tuple. This helper returns ``data`` for
successful responses or raises :class:`ValueError` for error responses.
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.
fail2ban returns structured data as lists of 2-tuples rather than dicts.
This helper converts them safely, ignoring non-pair items.
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
T = TypeVar("T")
@@ -98,7 +48,7 @@ async def _safe_get(
) -> object | None:
"""Send a command and return *default* if it fails."""
try:
return _ok(await client.send(command))
return ok(await client.send(command))
except (
Fail2BanConnectionError,
Fail2BanProtocolError,
@@ -202,7 +152,7 @@ async def probe(
# ------------------------------------------------------------------ #
# 1. Connectivity check #
# ------------------------------------------------------------------ #
ping_data = _ok(await client.send(["ping"]))
ping_data = ok(await client.send(["ping"]))
if ping_data != "pong":
log.warning(
"fail2ban_unexpected_ping_response",
@@ -214,14 +164,14 @@ async def probe(
# 2. Version
# ------------------------------------------------------------------ #
try:
version: str | None = str(_ok(await client.send(["version"])))
version: str | None = str(ok(await client.send(["version"])))
except (ValueError, TypeError):
version = None
# ------------------------------------------------------------------ #
# 3. Global status — jail count and names #
# ------------------------------------------------------------------ #
status_data = _to_dict(_ok(await client.send(["status"])))
status_data = to_dict(ok(await client.send(["status"])))
active_jails: int = int(str(status_data.get("Number of jail", 0) or 0))
jail_list_raw: str = str(
status_data.get("Jail list", "") or ""
@@ -240,11 +190,11 @@ async def probe(
for jail_name in jail_names:
try:
jail_resp = _to_dict(
_ok(await client.send(["status", jail_name]))
jail_resp = to_dict(
ok(await client.send(["status", jail_name]))
)
filter_stats = _to_dict(jail_resp.get("Filter") or [])
action_stats = _to_dict(jail_resp.get("Actions") or [])
filter_stats = to_dict(jail_resp.get("Filter") or [])
action_stats = to_dict(jail_resp.get("Actions") or [])
total_failures += int(
str(filter_stats.get("Currently failed", 0) or 0)
)

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

View File

@@ -8,7 +8,6 @@ from __future__ import annotations
import asyncio
import re
from pathlib import Path
from typing import cast
import structlog
@@ -26,8 +25,8 @@ from app.utils.fail2ban_client import (
Fail2BanClient,
Fail2BanConnectionError,
Fail2BanProtocolError,
Fail2BanResponse,
)
from app.utils.fail2ban_response import ok
log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -40,21 +39,6 @@ _NON_FILE_LOG_TARGETS: frozenset[str] = frozenset(
_SAFE_LOG_PREFIXES: tuple[str, ...] = ("/var/log", "/config/log")
def _ok(response: object) -> object:
"""Extract the payload from a fail2ban ``(return_code, data)`` response."""
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 _count_file_lines(file_path: str) -> int:
"""Count the total number of lines in *file_path* synchronously."""
count = 0
@@ -71,7 +55,7 @@ async def _safe_get(
) -> object | None:
"""Send a command and return *default* if it fails."""
try:
return _ok(await client.send(command))
return ok(await client.send(command))
except (
Fail2BanConnectionError,
Fail2BanProtocolError,

View File

@@ -17,6 +17,7 @@ import structlog
from app.exceptions import ServerOperationError
from app.models.server import ServerSettings, ServerSettingsResponse, ServerSettingsUpdate
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanCommand, Fail2BanResponse
from app.utils.fail2ban_response import ok
# ---------------------------------------------------------------------------
# Types
@@ -60,27 +61,6 @@ def _to_str(value: object | None, default: str) -> str:
# ---------------------------------------------------------------------------
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,
@@ -98,7 +78,7 @@ async def _safe_get(
"""
try:
response = await client.send(command)
return _ok(cast("Fail2BanResponse", response))
return ok(cast("Fail2BanResponse", response))
except Exception:
return default
@@ -185,7 +165,7 @@ async def update_settings(socket_path: str, update: ServerSettingsUpdate) -> Non
async def _set(key: str, value: Fail2BanSettingValue) -> None:
try:
response = await client.send(["set", key, value])
_ok(cast("Fail2BanResponse", response))
ok(cast("Fail2BanResponse", response))
except ValueError as exc:
raise ServerOperationError(f"Failed to set {key!r} = {value!r}: {exc}") from exc
@@ -220,7 +200,7 @@ async def flush_logs(socket_path: str) -> str:
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
response = await client.send(["flushlogs"])
result = _ok(cast("Fail2BanResponse", response))
result = ok(cast("Fail2BanResponse", response))
log.info("logs_flushed", result=result)
return str(result)
except ValueError as exc:

View File

@@ -17,19 +17,18 @@ from app.exceptions import (
JailNameError,
)
from app.models.config import (
ActionConfig,
BantimeEscalation,
InactiveJail,
JailValidationIssue,
JailValidationResult,
)
from app.utils import conffile_parser
from app.utils.constants import FAIL2BAN_TRUTHY_VALUES
from app.utils.fail2ban_client import (
Fail2BanClient,
Fail2BanConnectionError,
Fail2BanResponse,
)
from app.utils.fail2ban_response import ok, to_dict
log: structlog.stdlib.BoundLogger = structlog.get_logger()
@@ -256,26 +255,8 @@ async def _get_active_jail_names(socket_path: str) -> set[str]:
try:
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
def _to_dict_inner(pairs: object) -> dict[str, object]:
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 _ok(response: object) -> object:
code, data = cast("Fail2BanResponse", response)
if code != 0:
raise ValueError(f"fail2ban error {code}: {data!r}")
return data
status_raw = _ok(await client.send(["status"]))
status_dict = _to_dict_inner(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", "") or "").strip()
if not jail_list_raw:
return set()

View File

@@ -0,0 +1,165 @@
"""Shared utilities for parsing fail2ban responses.
This module provides canonical implementations of response parsing helpers
used across all service modules. All services should import from here instead
of maintaining local copies.
"""
from __future__ import annotations
def ok(response: object) -> object:
"""Extract the payload from a fail2ban ``(return_code, data)`` response.
fail2ban commands return a tuple of ``(return_code, data)`` where
``return_code`` is 0 for success or non-zero for errors. This function
extracts and returns the ``data`` portion.
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) or
has an unexpected shape.
Examples:
>>> response = (0, {'Jail list': 'sshd,recidive'})
>>> ok(response)
{'Jail list': 'sshd,recidive'}
>>> error_response = (1, 'Unknown jail')
>>> ok(error_response)
Traceback (most recent call last):
...
ValueError: fail2ban returned error code 1: 'Unknown jail'
"""
try:
code_val: int
data_val: object
code_val, data_val = response # type: ignore[misc]
except (TypeError, ValueError) as exc:
raise ValueError(f"Unexpected fail2ban response shape: {response!r}") from exc
if code_val != 0:
raise ValueError(f"fail2ban returned error code {code_val}: {data_val!r}")
return data_val
def to_dict(pairs: object) -> dict[str, object]:
"""Convert a list of ``(key, value)`` pairs to a plain dict.
fail2ban returns many results as a list of key-value tuples. This
function converts them to a regular Python dict, skipping malformed
entries and converting keys to strings.
Args:
pairs: A list of ``(key, value)`` pairs (or any iterable thereof).
Non-list/tuple inputs return an empty dict.
Returns:
A :class:`dict` with the keys and values from *pairs*. Keys are
converted to strings; values are preserved as-is. Malformed entries
are silently skipped.
Examples:
>>> to_dict([('name', 'sshd'), ('port', 22)])
{'name': 'sshd', 'port': 22}
>>> to_dict([('a', 1), 'broken', ('b', 2)])
{'a': 1, 'b': 2}
>>> to_dict('not a list')
{}
"""
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`` when a field is empty,
a single string when there is only one entry, or a list of strings.
This helper normalises all three cases to a consistent list.
Args:
value: The raw value from a fail2ban command response. Can be
``None``, a string, a list/tuple of strings, or any other object.
Returns:
A :class:`list` of strings. Empty input returns an empty list.
Single strings are wrapped in a list. Lists/tuples are converted to
strings element-wise.
Examples:
>>> ensure_list(None)
[]
>>> ensure_list('sshd')
['sshd']
>>> ensure_list(['sshd', 'apache2'])
['sshd', 'apache2']
>>> ensure_list(42)
['42']
"""
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)]
def is_not_found_error(exc: Exception) -> bool:
"""Return ``True`` if *exc* indicates a jail does not exist.
fail2ban raises errors when a jail is not found, but serializes them
in different formats depending on the context. This function checks
for multiple common error message patterns.
Args:
exc: The exception to inspect.
Returns:
``True`` if the exception message contains any of the known
"not found" phrases (case-insensitive), ``False`` otherwise.
Examples:
>>> exc = ValueError('Unknown jail: sshd')
>>> is_not_found_error(exc)
True
>>> exc = ValueError('unknownjail')
>>> is_not_found_error(exc)
True
>>> exc = ValueError('Internal error')
>>> is_not_found_error(exc)
False
"""
msg = str(exc).lower()
return any(
phrase in msg
for phrase in (
"unknown jail",
"unknownjail",
"no jail",
"does not exist",
"not found",
)
)