Refactor backend services and utilities

- Update service layer implementations
- Improve configuration handling utilities
- Update documentation tasks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-25 18:39:30 +02:00
parent 83452ffc23
commit 420ea18fd9
12 changed files with 52 additions and 83 deletions

View File

@@ -65,8 +65,6 @@ async def _get_active_jail_names(socket_path: str) -> set[str]:
# Constants
# ---------------------------------------------------------------------------
_SOCKET_TIMEOUT: float = 10.0
# Allowlist pattern for action names used in path construction.
_SAFE_ACTION_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")

View File

@@ -43,7 +43,11 @@ from app.models.ban import (
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.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
from app.utils.constants import (
DEFAULT_PAGE_SIZE,
FAIL2BAN_SOCKET_TIMEOUT,
MAX_PAGE_SIZE,
)
from app.utils.fail2ban_client import (
Fail2BanClient,
)
@@ -73,10 +77,6 @@ async def get_fail2ban_db_path(socket_path: str) -> str:
# Constants
# ---------------------------------------------------------------------------
_SOCKET_TIMEOUT: float = 5.0
async def ban_ip(socket_path: str, jail: str, ip: str) -> None:
"""Ban an IP address in the specified jail."""
@@ -85,7 +85,7 @@ async def ban_ip(socket_path: str, jail: str, ip: str) -> None:
except ValueError as exc:
raise ValueError(f"Invalid IP address: {ip!r}") from exc
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
try:
ok(await client.send(["set", jail, "banip", ip]))
@@ -102,7 +102,7 @@ async def unban_ip(socket_path: str, ip: str, jail: str | None = None) -> None:
except ValueError as exc:
raise ValueError(f"Invalid IP address: {ip!r}") from exc
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
if jail is None:
ok(await client.send(["unban", ip]))
@@ -254,7 +254,7 @@ async def get_active_bans(
cannot be reached.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
global_status = to_dict(ok(await client.send(["status"])))
jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip()

View File

@@ -51,6 +51,7 @@ from app.services.settings_service import (
from app.services.settings_service import (
set_map_color_thresholds as util_set_map_color_thresholds,
)
from app.utils.constants import FAIL2BAN_SOCKET_TIMEOUT
from app.utils.fail2ban_client import Fail2BanClient
from app.utils.fail2ban_response import (
ensure_list,
@@ -61,8 +62,6 @@ from app.utils.fail2ban_response import (
log: structlog.stdlib.BoundLogger = structlog.get_logger()
_SOCKET_TIMEOUT: float = 10.0
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
@@ -134,7 +133,7 @@ async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse:
JailNotFoundError: If *name* is not a known jail.
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
# Verify existence.
try:
@@ -207,7 +206,7 @@ async def list_jail_configs(socket_path: str) -> JailConfigListResponse:
Raises:
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
global_status = to_dict(ok(await client.send(["status"])))
jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip()
@@ -272,7 +271,7 @@ async def update_jail_config(
if err:
raise ConfigValidationError(f"Invalid regex in 'prefregex': {err!r} (pattern: {update.prefregex!r})")
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
# Verify existence.
try:
@@ -391,7 +390,7 @@ async def get_global_config(socket_path: str) -> GlobalConfigResponse:
Raises:
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
(
log_level_raw,
@@ -424,7 +423,7 @@ async def update_global_config(socket_path: str, update: GlobalConfigUpdate) ->
ConfigOperationError: If a ``set`` command is rejected.
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
async def _set_global(key: str, value: Fail2BanToken) -> None:
try:
@@ -476,7 +475,7 @@ async def add_log_path(
ConfigOperationError: If the command is rejected by fail2ban.
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
try:
ok(await client.send(["status", jail, "short"]))
@@ -513,7 +512,7 @@ async def delete_log_path(
ConfigOperationError: If the command is rejected by fail2ban.
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
try:
ok(await client.send(["status", jail, "short"]))

View File

@@ -6,6 +6,7 @@ import asyncio
import structlog
from app.utils.constants import FAIL2BAN_SOCKET_TIMEOUT_FAST
from app.utils.fail2ban_client import (
Fail2BanClient,
Fail2BanConnectionError,
@@ -64,8 +65,7 @@ class Fail2BanMetadataService:
async def _resolve_db_path(self, socket_path: str) -> str:
"""Query fail2ban for the configured database path."""
socket_timeout: float = 5.0
async with Fail2BanClient(socket_path, timeout=socket_timeout) as client:
async with Fail2BanClient(socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT_FAST) as client:
response = await client.send(["get", "dbfile"])
if not isinstance(response, tuple) or len(response) != 2:

View File

@@ -18,6 +18,7 @@ import structlog
from app import __version__
from app.models.config import ServiceStatusResponse
from app.models.server import ServerStatus
from app.utils.constants import FAIL2BAN_SOCKET_TIMEOUT_FAST
from app.utils.fail2ban_client import (
Fail2BanClient,
Fail2BanCommand,
@@ -35,8 +36,6 @@ log: structlog.stdlib.BoundLogger = structlog.get_logger()
# Internal helpers
# ---------------------------------------------------------------------------
_SOCKET_TIMEOUT: float = 5.0
T = TypeVar("T")
@@ -93,7 +92,7 @@ async def get_service_status(
if server_status.online:
client = Fail2BanClient(
socket_path=socket_path,
timeout=_SOCKET_TIMEOUT,
timeout=FAIL2BAN_SOCKET_TIMEOUT_FAST,
)
log_level_raw, log_target_raw = await asyncio.gather(
_safe_get_typed(client, ["get", "loglevel"], "INFO"),
@@ -129,7 +128,7 @@ async def get_service_status(
async def probe(
socket_path: str,
timeout: float = _SOCKET_TIMEOUT,
timeout: float = FAIL2BAN_SOCKET_TIMEOUT_FAST,
) -> ServerStatus:
"""Probe the fail2ban daemon and return a
:class:`~app.models.server.ServerStatus`.

View File

@@ -70,8 +70,6 @@ async def _get_active_jail_names(socket_path: str) -> set[str]:
# Constants
# ---------------------------------------------------------------------------
_SOCKET_TIMEOUT: float = 10.0
# Sections that are not jail definitions.
_META_SECTIONS: frozenset[str] = frozenset({"INCLUDES", "DEFAULT"})

View File

@@ -31,6 +31,7 @@ from app.models.jail import (
)
from app.services import geo_service
from app.utils.config_file_utils import start_daemon, wait_for_fail2ban
from app.utils.constants import FAIL2BAN_SOCKET_TIMEOUT
from app.utils.fail2ban_client import (
Fail2BanClient,
Fail2BanCommand,
@@ -73,8 +74,6 @@ class IpLookupResult(TypedDict):
# Constants
# ---------------------------------------------------------------------------
_SOCKET_TIMEOUT: float = 10.0
# Capability detection for optional fail2ban transmitter commands (backend, idle).
# These commands are not supported in all fail2ban versions. Caching the result
# avoids sending unsupported commands every polling cycle and spamming the
@@ -223,7 +222,7 @@ async def list_jails(socket_path: str) -> JailListResponse:
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
cannot be reached.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
# 1. Fetch global status to get jail names.
global_status = to_dict(ok(await client.send(["status"])))
@@ -376,7 +375,7 @@ async def get_jail(socket_path: str, name: str) -> JailDetailResponse:
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
cannot be reached.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
# Verify the jail exists by sending a status command first.
try:
@@ -493,7 +492,7 @@ async def start_jail(socket_path: str, name: str) -> None:
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
cannot be reached.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
try:
ok(await client.send(["start", name]))
log.info("jail_started", jail=name)
@@ -518,7 +517,7 @@ async def stop_jail(socket_path: str, name: str) -> None:
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
cannot be reached.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
try:
ok(await client.send(["stop", name]))
log.info("jail_stopped", jail=name)
@@ -548,7 +547,7 @@ async def set_idle(socket_path: str, name: str, *, on: bool) -> None:
cannot be reached.
"""
state = "on" if on else "off"
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
try:
ok(await client.send(["set", name, "idle", state]))
log.info("jail_idle_toggled", jail=name, idle=on)
@@ -578,7 +577,7 @@ async def reload_jail(socket_path: str, name: str) -> None:
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
cannot be reached.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
try:
ok(await client.send(["reload", name, [], [["start", name]]]))
log.info("jail_reloaded", jail=name)
@@ -608,7 +607,7 @@ async def restart(socket_path: str) -> None:
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
cannot be reached.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
try:
ok(await client.send(["stop"]))
log.info("fail2ban_stopped_for_restart")
@@ -619,7 +618,7 @@ async def restart(socket_path: str) -> None:
async def restart_daemon(
socket_path: str,
start_cmd_parts: list[str],
max_wait_seconds: float = _SOCKET_TIMEOUT,
max_wait_seconds: float = FAIL2BAN_SOCKET_TIMEOUT,
) -> bool:
"""Restart the fail2ban daemon and verify it comes back online.
@@ -781,7 +780,7 @@ async def get_jail_banned_ips(
# Clamp page_size to the allowed maximum.
page_size = min(page_size, _MAX_PAGE_SIZE)
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
# Verify the jail exists.
try:
@@ -896,7 +895,7 @@ async def get_ignore_list(socket_path: str, name: str) -> list[str]:
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
cannot be reached.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
try:
raw = ok(await client.send(["get", name, "ignoreip"]))
return ensure_list(raw)
@@ -926,7 +925,7 @@ async def add_ignore_ip(socket_path: str, name: str, ip: str) -> None:
except ValueError as exc:
raise ValueError(f"Invalid IP address or network: {ip!r}") from exc
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
try:
ok(await client.send(["set", name, "addignoreip", ip]))
log.info("ignore_ip_added", jail=name, ip=ip)
@@ -950,7 +949,7 @@ async def del_ignore_ip(socket_path: str, name: str, ip: str) -> None:
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
cannot be reached.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
try:
ok(await client.send(["set", name, "delignoreip", ip]))
log.info("ignore_ip_removed", jail=name, ip=ip)
@@ -975,7 +974,7 @@ async def get_ignore_self(socket_path: str, name: str) -> bool:
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
cannot be reached.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
try:
raw = ok(await client.send(["get", name, "ignoreself"]))
return bool(raw)
@@ -1000,7 +999,7 @@ async def set_ignore_self(socket_path: str, name: str, *, on: bool) -> None:
cannot be reached.
"""
value = "true" if on else "false"
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
try:
ok(await client.send(["set", name, "ignoreself", value]))
log.info("ignore_self_toggled", jail=name, on=on)
@@ -1047,7 +1046,7 @@ async def lookup_ip(
except ValueError as exc:
raise ValueError(f"Invalid IP address: {ip!r}") from exc
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
with contextlib.suppress(ValueError, Fail2BanConnectionError):
# Use fail2ban's "banned <ip>" command which checks all jails.
@@ -1120,7 +1119,7 @@ async def unban_all_ips(socket_path: str) -> int:
~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
cannot be reached.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
count: int = int(str(ok(await client.send(["unban", "--all"])) or 0))
log.info("all_ips_unbanned", count=count)
return count

View File

@@ -21,6 +21,7 @@ from app.models.config import (
RegexTestResponse,
)
from app.utils.async_utils import run_blocking
from app.utils.constants import FAIL2BAN_SOCKET_TIMEOUT
from app.utils.fail2ban_client import (
Fail2BanClient,
Fail2BanConnectionError,
@@ -30,8 +31,6 @@ from app.utils.fail2ban_response import ok
log: structlog.stdlib.BoundLogger = structlog.get_logger()
_SOCKET_TIMEOUT: float = 10.0
_NON_FILE_LOG_TARGETS: frozenset[str] = frozenset(
{"STDOUT", "STDERR", "SYSLOG", "SYSTEMD-JOURNAL"}
)
@@ -85,7 +84,7 @@ async def read_fail2ban_log(
validates that the target is a readable file, then returns the last
*lines* entries optionally filtered by *filter_text*.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
log_level_raw, log_target_raw = await asyncio.gather(
_safe_get_typed(client, ["get", "loglevel"], "INFO"),

View File

@@ -16,6 +16,7 @@ import structlog
from app.exceptions import ServerOperationError
from app.models.server import ServerSettings, ServerSettingsResponse, ServerSettingsUpdate
from app.utils.constants import FAIL2BAN_SOCKET_TIMEOUT
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanCommand, Fail2BanResponse
from app.utils.fail2ban_response import ok
@@ -28,8 +29,6 @@ type Fail2BanSettingValue = str | int | bool
log: structlog.stdlib.BoundLogger = structlog.get_logger()
_SOCKET_TIMEOUT: float = 10.0
def _to_int(value: object | None, default: int) -> int:
"""Convert a raw value to an int, falling back to a default.
@@ -105,7 +104,7 @@ async def get_settings(socket_path: str) -> ServerSettingsResponse:
"""
import asyncio
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
(
log_level_raw,
@@ -160,7 +159,7 @@ async def update_settings(socket_path: str, update: ServerSettingsUpdate) -> Non
ServerOperationError: If any ``set`` command is rejected.
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
async def _set(key: str, value: Fail2BanSettingValue) -> None:
try:
@@ -197,7 +196,7 @@ async def flush_logs(socket_path: str) -> str:
ServerOperationError: If the command is rejected.
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
try:
response = await client.send(["flushlogs"])
result = ok(cast("Fail2BanResponse", response))

View File

@@ -22,7 +22,7 @@ from app.models.config import (
JailValidationIssue,
JailValidationResult,
)
from app.utils.constants import FAIL2BAN_TRUTHY_VALUES
from app.utils.constants import FAIL2BAN_SOCKET_TIMEOUT, FAIL2BAN_TRUTHY_VALUES
from app.utils.fail2ban_client import (
Fail2BanClient,
Fail2BanConnectionError,
@@ -32,8 +32,6 @@ from app.utils.fail2ban_response import ok, to_dict
log: structlog.stdlib.BoundLogger = structlog.get_logger()
_SOCKET_TIMEOUT: float = 10.0
# Allowlist pattern for jail names used in path construction.
_SAFE_JAIL_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
@@ -253,7 +251,7 @@ def _parse_jails_sync(
async def _get_active_jail_names(socket_path: str) -> set[str]:
"""Fetch the set of currently running jail names from fail2ban."""
try:
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
status_raw = ok(await client.send(["status"]))
status_dict = to_dict(status_raw)
@@ -272,7 +270,7 @@ async def _get_active_jail_names(socket_path: str) -> set[str]:
async def _probe_fail2ban_running(socket_path: str) -> bool:
"""Return ``True`` when fail2ban responds successfully to a status request."""
try:
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)
response = await client.send(["status"])
code, _ = cast("Fail2BanResponse", response)
return code == 0

View File

@@ -13,8 +13,11 @@ from typing import Final
DEFAULT_FAIL2BAN_SOCKET: Final[str] = "/var/run/fail2ban/fail2ban.sock"
"""Default path to the fail2ban Unix domain socket."""
FAIL2BAN_SOCKET_TIMEOUT_SECONDS: Final[float] = 5.0
"""Maximum seconds to wait for a response from the fail2ban socket."""
FAIL2BAN_SOCKET_TIMEOUT_FAST: Final[float] = 5.0
"""Maximum seconds for fast operations (health checks, metadata probes)."""
FAIL2BAN_SOCKET_TIMEOUT: Final[float] = 10.0
"""Maximum seconds for command operations (config, jail management)."""
FAIL2BAN_TRUTHY_VALUES: Final[frozenset[str]] = frozenset({"true", "yes", "1"})
"""String values treated as boolean true by fail2ban configuration parsers."""