Refactor map color threshold storage into dedicated settings service
This commit is contained in:
@@ -9,13 +9,18 @@ seconds by the background health-check task, not on every HTTP request.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TypeVar, cast
|
||||
|
||||
import structlog
|
||||
|
||||
from app import __version__
|
||||
from app.models.config import ServiceStatusResponse
|
||||
from app.models.server import ServerStatus
|
||||
from app.utils.fail2ban_client import (
|
||||
Fail2BanClient,
|
||||
Fail2BanCommand,
|
||||
Fail2BanConnectionError,
|
||||
Fail2BanProtocolError,
|
||||
Fail2BanResponse,
|
||||
@@ -49,7 +54,9 @@ def _ok(response: object) -> object:
|
||||
try:
|
||||
code, data = cast("Fail2BanResponse", response)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Unexpected fail2ban response shape: {response!r}") from 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}")
|
||||
@@ -81,13 +88,101 @@ def _to_dict(pairs: object) -> dict[str, object]:
|
||||
return result
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
async def _safe_get(
|
||||
client: Fail2BanClient,
|
||||
command: Fail2BanCommand,
|
||||
default: object | None = None,
|
||||
) -> object | None:
|
||||
"""Send a command and return *default* if it fails."""
|
||||
try:
|
||||
return _ok(await client.send(command))
|
||||
except (
|
||||
Fail2BanConnectionError,
|
||||
Fail2BanProtocolError,
|
||||
ValueError,
|
||||
OSError,
|
||||
):
|
||||
return default
|
||||
|
||||
|
||||
async def _safe_get_typed(
|
||||
client: Fail2BanClient,
|
||||
command: Fail2BanCommand,
|
||||
default: T,
|
||||
) -> T:
|
||||
"""Send a command and return the result typed as ``default``'s type."""
|
||||
return cast("T", await _safe_get(client, command, default))
|
||||
|
||||
|
||||
async def get_service_status(
|
||||
socket_path: str,
|
||||
probe_fn: Callable[[str], Awaitable[ServerStatus]] | None = None,
|
||||
) -> ServiceStatusResponse:
|
||||
"""Return fail2ban service health status with log configuration.
|
||||
|
||||
Delegates to an injectable *probe_fn* (defaults to
|
||||
:func:`~app.services.health_service.probe`).
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
probe_fn: Optional probe function.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.ServiceStatusResponse`.
|
||||
"""
|
||||
if probe_fn is None:
|
||||
raise ValueError(
|
||||
"probe_fn is required to avoid service-to-service coupling"
|
||||
)
|
||||
|
||||
server_status = await probe_fn(socket_path)
|
||||
|
||||
if server_status.online:
|
||||
client = Fail2BanClient(
|
||||
socket_path=socket_path,
|
||||
timeout=_SOCKET_TIMEOUT,
|
||||
)
|
||||
log_level_raw, log_target_raw = await asyncio.gather(
|
||||
_safe_get_typed(client, ["get", "loglevel"], "INFO"),
|
||||
_safe_get_typed(client, ["get", "logtarget"], "STDOUT"),
|
||||
)
|
||||
log_level = str(log_level_raw or "INFO").upper()
|
||||
log_target = str(log_target_raw or "STDOUT")
|
||||
else:
|
||||
log_level = "UNKNOWN"
|
||||
log_target = "UNKNOWN"
|
||||
|
||||
log.info(
|
||||
"service_status_fetched",
|
||||
online=server_status.online,
|
||||
jail_count=server_status.active_jails,
|
||||
)
|
||||
|
||||
return ServiceStatusResponse(
|
||||
online=server_status.online,
|
||||
version=__version__,
|
||||
jail_count=server_status.active_jails,
|
||||
total_bans=server_status.total_bans,
|
||||
total_failures=server_status.total_failures,
|
||||
log_level=log_level,
|
||||
log_target=log_target,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public interface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def probe(socket_path: str, timeout: float = _SOCKET_TIMEOUT) -> ServerStatus:
|
||||
"""Probe the fail2ban daemon and return a :class:`~app.models.server.ServerStatus`.
|
||||
async def probe(
|
||||
socket_path: str,
|
||||
timeout: float = _SOCKET_TIMEOUT,
|
||||
) -> ServerStatus:
|
||||
"""Probe the fail2ban daemon and return a
|
||||
:class:`~app.models.server.ServerStatus`.
|
||||
|
||||
Sends ``ping``, ``version``, ``status``, and per-jail ``status <jail>``
|
||||
commands. Any socket or protocol error is caught and results in an
|
||||
@@ -109,11 +204,14 @@ async def probe(socket_path: str, timeout: float = _SOCKET_TIMEOUT) -> ServerSta
|
||||
# ------------------------------------------------------------------ #
|
||||
ping_data = _ok(await client.send(["ping"]))
|
||||
if ping_data != "pong":
|
||||
log.warning("fail2ban_unexpected_ping_response", response=ping_data)
|
||||
log.warning(
|
||||
"fail2ban_unexpected_ping_response",
|
||||
response=ping_data,
|
||||
)
|
||||
return ServerStatus(online=False)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 2. Version #
|
||||
# 2. Version
|
||||
# ------------------------------------------------------------------ #
|
||||
try:
|
||||
version: str | None = str(_ok(await client.send(["version"])))
|
||||
@@ -125,7 +223,9 @@ async def probe(socket_path: str, timeout: float = _SOCKET_TIMEOUT) -> ServerSta
|
||||
# ------------------------------------------------------------------ #
|
||||
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 "").strip()
|
||||
jail_list_raw: str = str(
|
||||
status_data.get("Jail list", "") or ""
|
||||
).strip()
|
||||
jail_names: list[str] = (
|
||||
[j.strip() for j in jail_list_raw.split(",") if j.strip()]
|
||||
if jail_list_raw
|
||||
@@ -140,11 +240,17 @@ async def probe(socket_path: str, timeout: float = _SOCKET_TIMEOUT) -> ServerSta
|
||||
|
||||
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 [])
|
||||
total_failures += int(str(filter_stats.get("Currently failed", 0) or 0))
|
||||
total_bans += int(str(action_stats.get("Currently banned", 0) or 0))
|
||||
total_failures += int(
|
||||
str(filter_stats.get("Currently failed", 0) or 0)
|
||||
)
|
||||
total_bans += int(
|
||||
str(action_stats.get("Currently banned", 0) or 0)
|
||||
)
|
||||
except (ValueError, TypeError, KeyError) as exc:
|
||||
log.warning(
|
||||
"fail2ban_jail_status_parse_error",
|
||||
@@ -174,5 +280,3 @@ async def probe(socket_path: str, timeout: float = _SOCKET_TIMEOUT) -> ServerSta
|
||||
except ValueError as exc:
|
||||
log.error("fail2ban_probe_parse_error", error=str(exc))
|
||||
return ServerStatus(online=False)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user