Move conffile_parser from services to utils

This commit is contained in:
2026-03-17 11:11:08 +01:00
parent dfbe126368
commit ce59a66973
11 changed files with 226 additions and 62 deletions

View File

@@ -54,8 +54,9 @@ from app.models.config import (
JailValidationResult,
RollbackResponse,
)
from app.services import conffile_parser, jail_service
from app.services import jail_service
from app.services.jail_service import JailNotFoundError as JailNotFoundError
from app.utils import conffile_parser
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError
log: structlog.stdlib.BoundLogger = structlog.get_logger()

View File

@@ -817,7 +817,7 @@ async def get_parsed_filter_file(config_dir: str, name: str) -> FilterConfig:
"""Parse a filter definition file and return its structured representation.
Reads the raw ``.conf``/``.local`` file from ``filter.d/``, parses it with
:func:`~app.services.conffile_parser.parse_filter_file`, and returns the
:func:`~app.utils.conffile_parser.parse_filter_file`, and returns the
result.
Args:
@@ -831,7 +831,7 @@ async def get_parsed_filter_file(config_dir: str, name: str) -> FilterConfig:
ConfigFileNotFoundError: If no matching file is found.
ConfigDirError: If *config_dir* does not exist.
"""
from app.services.conffile_parser import parse_filter_file # avoid circular imports
from app.utils.conffile_parser import parse_filter_file # avoid circular imports
def _do() -> FilterConfig:
filter_d = _resolve_subdir(config_dir, "filter.d")
@@ -863,7 +863,7 @@ async def update_parsed_filter_file(
ConfigFileWriteError: If the file cannot be written.
ConfigDirError: If *config_dir* does not exist.
"""
from app.services.conffile_parser import ( # avoid circular imports
from app.utils.conffile_parser import ( # avoid circular imports
merge_filter_update,
parse_filter_file,
serialize_filter_config,
@@ -901,7 +901,7 @@ async def get_parsed_action_file(config_dir: str, name: str) -> ActionConfig:
ConfigFileNotFoundError: If no matching file is found.
ConfigDirError: If *config_dir* does not exist.
"""
from app.services.conffile_parser import parse_action_file # avoid circular imports
from app.utils.conffile_parser import parse_action_file # avoid circular imports
def _do() -> ActionConfig:
action_d = _resolve_subdir(config_dir, "action.d")
@@ -930,7 +930,7 @@ async def update_parsed_action_file(
ConfigFileWriteError: If the file cannot be written.
ConfigDirError: If *config_dir* does not exist.
"""
from app.services.conffile_parser import ( # avoid circular imports
from app.utils.conffile_parser import ( # avoid circular imports
merge_action_update,
parse_action_file,
serialize_action_config,
@@ -963,7 +963,7 @@ async def get_parsed_jail_file(config_dir: str, filename: str) -> JailFileConfig
ConfigFileNotFoundError: If no matching file is found.
ConfigDirError: If *config_dir* does not exist.
"""
from app.services.conffile_parser import parse_jail_file # avoid circular imports
from app.utils.conffile_parser import parse_jail_file # avoid circular imports
def _do() -> JailFileConfig:
jail_d = _resolve_subdir(config_dir, "jail.d")
@@ -992,7 +992,7 @@ async def update_parsed_jail_file(
ConfigFileWriteError: If the file cannot be written.
ConfigDirError: If *config_dir* does not exist.
"""
from app.services.conffile_parser import ( # avoid circular imports
from app.utils.conffile_parser import ( # avoid circular imports
merge_jail_file_update,
parse_jail_file,
serialize_jail_file_config,

View File

@@ -40,8 +40,9 @@ from __future__ import annotations
import asyncio
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, TypeAlias
import aiohttp
import structlog
@@ -118,6 +119,14 @@ class GeoInfo:
"""Organisation name associated with the IP, e.g. ``"Deutsche Telekom"``."""
GeoEnricher: TypeAlias = Callable[[str], Awaitable[GeoInfo | None]]
"""Async callable used to enrich IPs with :class:`~app.services.geo_service.GeoInfo`.
This is a shared type alias used by services that optionally accept a geo
lookup callable (for example, :mod:`app.services.history_service`).
"""
# ---------------------------------------------------------------------------
# Internal cache
# ---------------------------------------------------------------------------

View File

@@ -11,10 +11,11 @@ modifies or locks the fail2ban database.
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import structlog
from app.services.geo_service import GeoEnricher
from app.models.ban import TIME_RANGE_SECONDS, TimeRange
from app.models.history import (
HistoryBanItem,
@@ -61,7 +62,7 @@ async def list_history(
ip_filter: str | None = None,
page: int = 1,
page_size: int = _DEFAULT_PAGE_SIZE,
geo_enricher: Any | None = None,
geo_enricher: GeoEnricher | None = None,
) -> HistoryListResponse:
"""Return a paginated list of historical ban records with optional filters.
@@ -160,7 +161,7 @@ async def get_ip_detail(
socket_path: str,
ip: str,
*,
geo_enricher: Any | None = None,
geo_enricher: GeoEnricher | None = None,
) -> IpDetailResponse | None:
"""Return the full historical record for a single IP address.

View File

@@ -10,18 +10,50 @@ HTTP/FastAPI concerns.
from __future__ import annotations
from typing import Any
from typing import cast, TypeAlias
import structlog
from app.models.server import ServerSettings, ServerSettingsResponse, ServerSettingsUpdate
from app.utils.fail2ban_client import Fail2BanClient
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanCommand, Fail2BanResponse
# ---------------------------------------------------------------------------
# Types
# ---------------------------------------------------------------------------
Fail2BanSettingValue: TypeAlias = str | int | bool
"""Allowed values for server settings commands."""
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.
The fail2ban control socket can return either int or str values for some
settings, so we normalise them here in a type-safe way.
"""
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
try:
return int(value)
except ValueError:
return default
return default
def _to_str(value: object | None, default: str) -> str:
"""Convert a raw value to a string, falling back to a default."""
if value is None:
return default
return str(value)
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
@@ -36,7 +68,7 @@ class ServerOperationError(Exception):
# ---------------------------------------------------------------------------
def _ok(response: Any) -> Any:
def _ok(response: Fail2BanResponse) -> object:
"""Extract payload from a fail2ban ``(code, data)`` response.
Args:
@@ -59,9 +91,9 @@ def _ok(response: Any) -> Any:
async def _safe_get(
client: Fail2BanClient,
command: list[Any],
default: Any = None,
) -> Any:
command: Fail2BanCommand,
default: object | None = None,
) -> object | None:
"""Send a command and silently return *default* on any error.
Args:
@@ -73,7 +105,8 @@ async def _safe_get(
The successful response, or *default*.
"""
try:
return _ok(await client.send(command))
response = await client.send(command)
return _ok(cast(Fail2BanResponse, response))
except Exception:
return default
@@ -118,13 +151,20 @@ async def get_settings(socket_path: str) -> ServerSettingsResponse:
_safe_get(client, ["get", "dbmaxmatches"], 10),
)
log_level = _to_str(log_level_raw, "INFO").upper()
log_target = _to_str(log_target_raw, "STDOUT")
syslog_socket = _to_str(syslog_socket_raw, "") or None
db_path = _to_str(db_path_raw, "/var/lib/fail2ban/fail2ban.sqlite3")
db_purge_age = _to_int(db_purge_age_raw, 86400)
db_max_matches = _to_int(db_max_matches_raw, 10)
settings = ServerSettings(
log_level=str(log_level_raw or "INFO").upper(),
log_target=str(log_target_raw or "STDOUT"),
syslog_socket=str(syslog_socket_raw) if syslog_socket_raw else None,
db_path=str(db_path_raw or "/var/lib/fail2ban/fail2ban.sqlite3"),
db_purge_age=int(db_purge_age_raw or 86400),
db_max_matches=int(db_max_matches_raw or 10),
log_level=log_level,
log_target=log_target,
syslog_socket=syslog_socket,
db_path=db_path,
db_purge_age=db_purge_age,
db_max_matches=db_max_matches,
)
log.info("server_settings_fetched")
@@ -146,9 +186,10 @@ async def update_settings(socket_path: str, update: ServerSettingsUpdate) -> Non
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
async def _set(key: str, value: Any) -> None:
async def _set(key: str, value: Fail2BanSettingValue) -> None:
try:
_ok(await client.send(["set", key, value]))
response = await client.send(["set", key, value])
_ok(cast(Fail2BanResponse, response))
except ValueError as exc:
raise ServerOperationError(f"Failed to set {key!r} = {value!r}: {exc}") from exc
@@ -182,7 +223,8 @@ async def flush_logs(socket_path: str) -> str:
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
result = _ok(await client.send(["flushlogs"]))
response = await client.send(["flushlogs"])
result = _ok(cast(Fail2BanResponse, response))
log.info("logs_flushed", result=result)
return str(result)
except ValueError as exc:

View File

@@ -22,7 +22,27 @@ import errno
import socket
import time
from pickle import HIGHEST_PROTOCOL, dumps, loads
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, TypeAlias
# ---------------------------------------------------------------------------
# Types
# ---------------------------------------------------------------------------
Fail2BanToken: TypeAlias = str | int | float | bool | None | dict[str, object] | list[object]
"""A single token in a fail2ban command.
Fail2ban accepts simple types (str/int/float/bool) plus compound types
(list/dict). Complex objects are stringified before being sent.
"""
Fail2BanCommand: TypeAlias = list[Fail2BanToken]
"""A command sent to fail2ban over the socket.
Commands are pickle serialised lists of tokens.
"""
Fail2BanResponse: TypeAlias = tuple[int, object]
"""A typical fail2ban response containing a status code and payload."""
if TYPE_CHECKING:
from types import TracebackType
@@ -81,9 +101,9 @@ class Fail2BanProtocolError(Exception):
def _send_command_sync(
socket_path: str,
command: list[Any],
command: Fail2BanCommand,
timeout: float,
) -> Any:
) -> object:
"""Send a command to fail2ban and return the parsed response.
This is a **synchronous** function intended to be called from within
@@ -180,7 +200,7 @@ def _send_command_sync(
) from last_oserror
def _coerce_command_token(token: Any) -> Any:
def _coerce_command_token(token: Fail2BanToken) -> Fail2BanToken:
"""Coerce a command token to a type that fail2ban understands.
fail2ban's ``CSocket.convert`` accepts ``str``, ``bool``, ``int``,
@@ -229,7 +249,7 @@ class Fail2BanClient:
self.socket_path: str = socket_path
self.timeout: float = timeout
async def send(self, command: list[Any]) -> Any:
async def send(self, command: Fail2BanCommand) -> object:
"""Send a command to fail2ban and return the response.
Acquires the module-level concurrency semaphore before dispatching
@@ -267,13 +287,13 @@ class Fail2BanClient:
log.debug("fail2ban_sending_command", command=command)
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
try:
response: Any = await loop.run_in_executor(
None,
_send_command_sync,
self.socket_path,
command,
self.timeout,
)
response: object = await loop.run_in_executor(
None,
_send_command_sync,
self.socket_path,
command,
self.timeout,
)
except Fail2BanConnectionError:
log.warning(
"fail2ban_connection_error",
@@ -300,7 +320,7 @@ class Fail2BanClient:
``True`` when the daemon responds correctly, ``False`` otherwise.
"""
try:
response: Any = await self.send(["ping"])
response: object = await self.send(["ping"])
return bool(response == 1) # fail2ban returns 1 on successful ping
except (Fail2BanConnectionError, Fail2BanProtocolError):
return False