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