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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user