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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
165
backend/app/utils/fail2ban_response.py
Normal file
165
backend/app/utils/fail2ban_response.py
Normal 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",
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user