Move conffile_parser from services to utils
This commit is contained in:
@@ -77,6 +77,8 @@ This document breaks the entire BanGUI project into development stages, ordered
|
|||||||
|
|
||||||
#### TASK B-4 — Move `conffile_parser.py` from `services/` to `utils/`
|
#### TASK B-4 — Move `conffile_parser.py` from `services/` to `utils/`
|
||||||
|
|
||||||
|
**Status:** Completed ✅
|
||||||
|
|
||||||
**Violated rule:** Refactoring.md §2.2 and Architecture §2.1 — `services/` is for business logic. `conffile_parser.py` is a pure, stateless parsing library with no framework dependencies (no FastAPI, no aiosqlite). It belongs in `utils/`.
|
**Violated rule:** Refactoring.md §2.2 and Architecture §2.1 — `services/` is for business logic. `conffile_parser.py` is a pure, stateless parsing library with no framework dependencies (no FastAPI, no aiosqlite). It belongs in `utils/`.
|
||||||
|
|
||||||
**Files affected:**
|
**Files affected:**
|
||||||
@@ -187,6 +189,8 @@ Remove or rewrite the docstring snippet so it does not contain a bare `print()`
|
|||||||
|
|
||||||
#### TASK B-10 — Replace `Any` type usage in `history_service.py`
|
#### TASK B-10 — Replace `Any` type usage in `history_service.py`
|
||||||
|
|
||||||
|
**Status:** Completed ✅
|
||||||
|
|
||||||
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
|
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
|
||||||
|
|
||||||
**Files affected:**
|
**Files affected:**
|
||||||
@@ -203,6 +207,8 @@ Remove or rewrite the docstring snippet so it does not contain a bare `print()`
|
|||||||
|
|
||||||
#### TASK B-11 — Reduce `Any` usage in `server_service.py`
|
#### TASK B-11 — Reduce `Any` usage in `server_service.py`
|
||||||
|
|
||||||
|
**Status:** Completed ✅
|
||||||
|
|
||||||
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
|
**Violated rule:** Backend-Development.md §1 — Never use `Any`; all functions must have explicit type annotations.
|
||||||
|
|
||||||
**Files affected:**
|
**Files affected:**
|
||||||
|
|||||||
@@ -54,8 +54,9 @@ from app.models.config import (
|
|||||||
JailValidationResult,
|
JailValidationResult,
|
||||||
RollbackResponse,
|
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.services.jail_service import JailNotFoundError as JailNotFoundError
|
||||||
|
from app.utils import conffile_parser
|
||||||
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError
|
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError
|
||||||
|
|
||||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||||
|
|||||||
@@ -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.
|
"""Parse a filter definition file and return its structured representation.
|
||||||
|
|
||||||
Reads the raw ``.conf``/``.local`` file from ``filter.d/``, parses it with
|
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.
|
result.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -831,7 +831,7 @@ async def get_parsed_filter_file(config_dir: str, name: str) -> FilterConfig:
|
|||||||
ConfigFileNotFoundError: If no matching file is found.
|
ConfigFileNotFoundError: If no matching file is found.
|
||||||
ConfigDirError: If *config_dir* does not exist.
|
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:
|
def _do() -> FilterConfig:
|
||||||
filter_d = _resolve_subdir(config_dir, "filter.d")
|
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.
|
ConfigFileWriteError: If the file cannot be written.
|
||||||
ConfigDirError: If *config_dir* does not exist.
|
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,
|
merge_filter_update,
|
||||||
parse_filter_file,
|
parse_filter_file,
|
||||||
serialize_filter_config,
|
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.
|
ConfigFileNotFoundError: If no matching file is found.
|
||||||
ConfigDirError: If *config_dir* does not exist.
|
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:
|
def _do() -> ActionConfig:
|
||||||
action_d = _resolve_subdir(config_dir, "action.d")
|
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.
|
ConfigFileWriteError: If the file cannot be written.
|
||||||
ConfigDirError: If *config_dir* does not exist.
|
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,
|
merge_action_update,
|
||||||
parse_action_file,
|
parse_action_file,
|
||||||
serialize_action_config,
|
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.
|
ConfigFileNotFoundError: If no matching file is found.
|
||||||
ConfigDirError: If *config_dir* does not exist.
|
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:
|
def _do() -> JailFileConfig:
|
||||||
jail_d = _resolve_subdir(config_dir, "jail.d")
|
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.
|
ConfigFileWriteError: If the file cannot be written.
|
||||||
ConfigDirError: If *config_dir* does not exist.
|
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,
|
merge_jail_file_update,
|
||||||
parse_jail_file,
|
parse_jail_file,
|
||||||
serialize_jail_file_config,
|
serialize_jail_file_config,
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, TypeAlias
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import structlog
|
import structlog
|
||||||
@@ -118,6 +119,14 @@ class GeoInfo:
|
|||||||
"""Organisation name associated with the IP, e.g. ``"Deutsche Telekom"``."""
|
"""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
|
# Internal cache
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ modifies or locks the fail2ban database.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
|
from app.services.geo_service import GeoEnricher
|
||||||
|
|
||||||
from app.models.ban import TIME_RANGE_SECONDS, TimeRange
|
from app.models.ban import TIME_RANGE_SECONDS, TimeRange
|
||||||
from app.models.history import (
|
from app.models.history import (
|
||||||
HistoryBanItem,
|
HistoryBanItem,
|
||||||
@@ -61,7 +62,7 @@ async def list_history(
|
|||||||
ip_filter: str | None = None,
|
ip_filter: str | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = _DEFAULT_PAGE_SIZE,
|
page_size: int = _DEFAULT_PAGE_SIZE,
|
||||||
geo_enricher: Any | None = None,
|
geo_enricher: GeoEnricher | None = None,
|
||||||
) -> HistoryListResponse:
|
) -> HistoryListResponse:
|
||||||
"""Return a paginated list of historical ban records with optional filters.
|
"""Return a paginated list of historical ban records with optional filters.
|
||||||
|
|
||||||
@@ -160,7 +161,7 @@ async def get_ip_detail(
|
|||||||
socket_path: str,
|
socket_path: str,
|
||||||
ip: str,
|
ip: str,
|
||||||
*,
|
*,
|
||||||
geo_enricher: Any | None = None,
|
geo_enricher: GeoEnricher | None = None,
|
||||||
) -> IpDetailResponse | None:
|
) -> IpDetailResponse | None:
|
||||||
"""Return the full historical record for a single IP address.
|
"""Return the full historical record for a single IP address.
|
||||||
|
|
||||||
|
|||||||
@@ -10,18 +10,50 @@ HTTP/FastAPI concerns.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import cast, TypeAlias
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from app.models.server import ServerSettings, ServerSettingsResponse, ServerSettingsUpdate
|
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()
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||||
|
|
||||||
_SOCKET_TIMEOUT: float = 10.0
|
_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
|
# 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.
|
"""Extract payload from a fail2ban ``(code, data)`` response.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -59,9 +91,9 @@ def _ok(response: Any) -> Any:
|
|||||||
|
|
||||||
async def _safe_get(
|
async def _safe_get(
|
||||||
client: Fail2BanClient,
|
client: Fail2BanClient,
|
||||||
command: list[Any],
|
command: Fail2BanCommand,
|
||||||
default: Any = None,
|
default: object | None = None,
|
||||||
) -> Any:
|
) -> object | None:
|
||||||
"""Send a command and silently return *default* on any error.
|
"""Send a command and silently return *default* on any error.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -73,7 +105,8 @@ async def _safe_get(
|
|||||||
The successful response, or *default*.
|
The successful response, or *default*.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return _ok(await client.send(command))
|
response = await client.send(command)
|
||||||
|
return _ok(cast(Fail2BanResponse, response))
|
||||||
except Exception:
|
except Exception:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
@@ -118,13 +151,20 @@ async def get_settings(socket_path: str) -> ServerSettingsResponse:
|
|||||||
_safe_get(client, ["get", "dbmaxmatches"], 10),
|
_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(
|
settings = ServerSettings(
|
||||||
log_level=str(log_level_raw or "INFO").upper(),
|
log_level=log_level,
|
||||||
log_target=str(log_target_raw or "STDOUT"),
|
log_target=log_target,
|
||||||
syslog_socket=str(syslog_socket_raw) if syslog_socket_raw else None,
|
syslog_socket=syslog_socket,
|
||||||
db_path=str(db_path_raw or "/var/lib/fail2ban/fail2ban.sqlite3"),
|
db_path=db_path,
|
||||||
db_purge_age=int(db_purge_age_raw or 86400),
|
db_purge_age=db_purge_age,
|
||||||
db_max_matches=int(db_max_matches_raw or 10),
|
db_max_matches=db_max_matches,
|
||||||
)
|
)
|
||||||
|
|
||||||
log.info("server_settings_fetched")
|
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)
|
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:
|
try:
|
||||||
_ok(await client.send(["set", key, value]))
|
response = await client.send(["set", key, value])
|
||||||
|
_ok(cast(Fail2BanResponse, response))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ServerOperationError(f"Failed to set {key!r} = {value!r}: {exc}") from 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)
|
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||||
try:
|
try:
|
||||||
result = _ok(await client.send(["flushlogs"]))
|
response = await client.send(["flushlogs"])
|
||||||
|
result = _ok(cast(Fail2BanResponse, response))
|
||||||
log.info("logs_flushed", result=result)
|
log.info("logs_flushed", result=result)
|
||||||
return str(result)
|
return str(result)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|||||||
@@ -22,7 +22,27 @@ import errno
|
|||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
from pickle import HIGHEST_PROTOCOL, dumps, loads
|
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:
|
if TYPE_CHECKING:
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
@@ -81,9 +101,9 @@ class Fail2BanProtocolError(Exception):
|
|||||||
|
|
||||||
def _send_command_sync(
|
def _send_command_sync(
|
||||||
socket_path: str,
|
socket_path: str,
|
||||||
command: list[Any],
|
command: Fail2BanCommand,
|
||||||
timeout: float,
|
timeout: float,
|
||||||
) -> Any:
|
) -> object:
|
||||||
"""Send a command to fail2ban and return the parsed response.
|
"""Send a command to fail2ban and return the parsed response.
|
||||||
|
|
||||||
This is a **synchronous** function intended to be called from within
|
This is a **synchronous** function intended to be called from within
|
||||||
@@ -180,7 +200,7 @@ def _send_command_sync(
|
|||||||
) from last_oserror
|
) 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.
|
"""Coerce a command token to a type that fail2ban understands.
|
||||||
|
|
||||||
fail2ban's ``CSocket.convert`` accepts ``str``, ``bool``, ``int``,
|
fail2ban's ``CSocket.convert`` accepts ``str``, ``bool``, ``int``,
|
||||||
@@ -229,7 +249,7 @@ class Fail2BanClient:
|
|||||||
self.socket_path: str = socket_path
|
self.socket_path: str = socket_path
|
||||||
self.timeout: float = timeout
|
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.
|
"""Send a command to fail2ban and return the response.
|
||||||
|
|
||||||
Acquires the module-level concurrency semaphore before dispatching
|
Acquires the module-level concurrency semaphore before dispatching
|
||||||
@@ -267,13 +287,13 @@ class Fail2BanClient:
|
|||||||
log.debug("fail2ban_sending_command", command=command)
|
log.debug("fail2ban_sending_command", command=command)
|
||||||
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
|
loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
|
||||||
try:
|
try:
|
||||||
response: Any = await loop.run_in_executor(
|
response: object = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
_send_command_sync,
|
_send_command_sync,
|
||||||
self.socket_path,
|
self.socket_path,
|
||||||
command,
|
command,
|
||||||
self.timeout,
|
self.timeout,
|
||||||
)
|
)
|
||||||
except Fail2BanConnectionError:
|
except Fail2BanConnectionError:
|
||||||
log.warning(
|
log.warning(
|
||||||
"fail2ban_connection_error",
|
"fail2ban_connection_error",
|
||||||
@@ -300,7 +320,7 @@ class Fail2BanClient:
|
|||||||
``True`` when the daemon responds correctly, ``False`` otherwise.
|
``True`` when the daemon responds correctly, ``False`` otherwise.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
response: Any = await self.send(["ping"])
|
response: object = await self.send(["ping"])
|
||||||
return bool(response == 1) # fail2ban returns 1 on successful ping
|
return bool(response == 1) # fail2ban returns 1 on successful ping
|
||||||
except (Fail2BanConnectionError, Fail2BanProtocolError):
|
except (Fail2BanConnectionError, Fail2BanProtocolError):
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.conffile_parser import (
|
from app.utils.conffile_parser import (
|
||||||
merge_action_update,
|
merge_action_update,
|
||||||
merge_filter_update,
|
merge_filter_update,
|
||||||
parse_action_file,
|
parse_action_file,
|
||||||
@@ -451,7 +451,7 @@ class TestParseJailFile:
|
|||||||
"""Unit tests for parse_jail_file."""
|
"""Unit tests for parse_jail_file."""
|
||||||
|
|
||||||
def test_minimal_parses_correctly(self) -> None:
|
def test_minimal_parses_correctly(self) -> None:
|
||||||
from app.services.conffile_parser import parse_jail_file
|
from app.utils.conffile_parser import parse_jail_file
|
||||||
|
|
||||||
cfg = parse_jail_file(MINIMAL_JAIL, filename="sshd.conf")
|
cfg = parse_jail_file(MINIMAL_JAIL, filename="sshd.conf")
|
||||||
assert cfg.filename == "sshd.conf"
|
assert cfg.filename == "sshd.conf"
|
||||||
@@ -463,7 +463,7 @@ class TestParseJailFile:
|
|||||||
assert jail.logpath == ["/var/log/auth.log"]
|
assert jail.logpath == ["/var/log/auth.log"]
|
||||||
|
|
||||||
def test_full_parses_multiple_jails(self) -> None:
|
def test_full_parses_multiple_jails(self) -> None:
|
||||||
from app.services.conffile_parser import parse_jail_file
|
from app.utils.conffile_parser import parse_jail_file
|
||||||
|
|
||||||
cfg = parse_jail_file(FULL_JAIL)
|
cfg = parse_jail_file(FULL_JAIL)
|
||||||
assert len(cfg.jails) == 2
|
assert len(cfg.jails) == 2
|
||||||
@@ -471,7 +471,7 @@ class TestParseJailFile:
|
|||||||
assert "nginx-botsearch" in cfg.jails
|
assert "nginx-botsearch" in cfg.jails
|
||||||
|
|
||||||
def test_full_jail_numeric_fields(self) -> None:
|
def test_full_jail_numeric_fields(self) -> None:
|
||||||
from app.services.conffile_parser import parse_jail_file
|
from app.utils.conffile_parser import parse_jail_file
|
||||||
|
|
||||||
jail = parse_jail_file(FULL_JAIL).jails["sshd"]
|
jail = parse_jail_file(FULL_JAIL).jails["sshd"]
|
||||||
assert jail.maxretry == 3
|
assert jail.maxretry == 3
|
||||||
@@ -479,7 +479,7 @@ class TestParseJailFile:
|
|||||||
assert jail.bantime == 3600
|
assert jail.bantime == 3600
|
||||||
|
|
||||||
def test_full_jail_multiline_logpath(self) -> None:
|
def test_full_jail_multiline_logpath(self) -> None:
|
||||||
from app.services.conffile_parser import parse_jail_file
|
from app.utils.conffile_parser import parse_jail_file
|
||||||
|
|
||||||
jail = parse_jail_file(FULL_JAIL).jails["sshd"]
|
jail = parse_jail_file(FULL_JAIL).jails["sshd"]
|
||||||
assert len(jail.logpath) == 2
|
assert len(jail.logpath) == 2
|
||||||
@@ -487,53 +487,53 @@ class TestParseJailFile:
|
|||||||
assert "/var/log/syslog" in jail.logpath
|
assert "/var/log/syslog" in jail.logpath
|
||||||
|
|
||||||
def test_full_jail_multiline_action(self) -> None:
|
def test_full_jail_multiline_action(self) -> None:
|
||||||
from app.services.conffile_parser import parse_jail_file
|
from app.utils.conffile_parser import parse_jail_file
|
||||||
|
|
||||||
jail = parse_jail_file(FULL_JAIL).jails["nginx-botsearch"]
|
jail = parse_jail_file(FULL_JAIL).jails["nginx-botsearch"]
|
||||||
assert len(jail.action) == 2
|
assert len(jail.action) == 2
|
||||||
assert "sendmail-whois" in jail.action
|
assert "sendmail-whois" in jail.action
|
||||||
|
|
||||||
def test_enabled_true(self) -> None:
|
def test_enabled_true(self) -> None:
|
||||||
from app.services.conffile_parser import parse_jail_file
|
from app.utils.conffile_parser import parse_jail_file
|
||||||
|
|
||||||
jail = parse_jail_file(FULL_JAIL).jails["sshd"]
|
jail = parse_jail_file(FULL_JAIL).jails["sshd"]
|
||||||
assert jail.enabled is True
|
assert jail.enabled is True
|
||||||
|
|
||||||
def test_enabled_false(self) -> None:
|
def test_enabled_false(self) -> None:
|
||||||
from app.services.conffile_parser import parse_jail_file
|
from app.utils.conffile_parser import parse_jail_file
|
||||||
|
|
||||||
jail = parse_jail_file(FULL_JAIL).jails["nginx-botsearch"]
|
jail = parse_jail_file(FULL_JAIL).jails["nginx-botsearch"]
|
||||||
assert jail.enabled is False
|
assert jail.enabled is False
|
||||||
|
|
||||||
def test_extra_keys_captured(self) -> None:
|
def test_extra_keys_captured(self) -> None:
|
||||||
from app.services.conffile_parser import parse_jail_file
|
from app.utils.conffile_parser import parse_jail_file
|
||||||
|
|
||||||
jail = parse_jail_file(JAIL_WITH_EXTRA).jails["sshd"]
|
jail = parse_jail_file(JAIL_WITH_EXTRA).jails["sshd"]
|
||||||
assert jail.extra["custom_key"] == "custom_value"
|
assert jail.extra["custom_key"] == "custom_value"
|
||||||
assert jail.extra["another_key"] == "42"
|
assert jail.extra["another_key"] == "42"
|
||||||
|
|
||||||
def test_extra_keys_not_in_named_fields(self) -> None:
|
def test_extra_keys_not_in_named_fields(self) -> None:
|
||||||
from app.services.conffile_parser import parse_jail_file
|
from app.utils.conffile_parser import parse_jail_file
|
||||||
|
|
||||||
jail = parse_jail_file(JAIL_WITH_EXTRA).jails["sshd"]
|
jail = parse_jail_file(JAIL_WITH_EXTRA).jails["sshd"]
|
||||||
assert "enabled" not in jail.extra
|
assert "enabled" not in jail.extra
|
||||||
assert "logpath" not in jail.extra
|
assert "logpath" not in jail.extra
|
||||||
|
|
||||||
def test_empty_file_yields_no_jails(self) -> None:
|
def test_empty_file_yields_no_jails(self) -> None:
|
||||||
from app.services.conffile_parser import parse_jail_file
|
from app.utils.conffile_parser import parse_jail_file
|
||||||
|
|
||||||
cfg = parse_jail_file("")
|
cfg = parse_jail_file("")
|
||||||
assert cfg.jails == {}
|
assert cfg.jails == {}
|
||||||
|
|
||||||
def test_invalid_ini_does_not_raise(self) -> None:
|
def test_invalid_ini_does_not_raise(self) -> None:
|
||||||
from app.services.conffile_parser import parse_jail_file
|
from app.utils.conffile_parser import parse_jail_file
|
||||||
|
|
||||||
# Should not raise; just parse what it can.
|
# Should not raise; just parse what it can.
|
||||||
cfg = parse_jail_file("@@@ not valid ini @@@", filename="bad.conf")
|
cfg = parse_jail_file("@@@ not valid ini @@@", filename="bad.conf")
|
||||||
assert isinstance(cfg.jails, dict)
|
assert isinstance(cfg.jails, dict)
|
||||||
|
|
||||||
def test_default_section_ignored(self) -> None:
|
def test_default_section_ignored(self) -> None:
|
||||||
from app.services.conffile_parser import parse_jail_file
|
from app.utils.conffile_parser import parse_jail_file
|
||||||
|
|
||||||
content = "[DEFAULT]\nignoreip = 127.0.0.1\n\n[sshd]\nenabled = true\n"
|
content = "[DEFAULT]\nignoreip = 127.0.0.1\n\n[sshd]\nenabled = true\n"
|
||||||
cfg = parse_jail_file(content)
|
cfg = parse_jail_file(content)
|
||||||
@@ -550,7 +550,7 @@ class TestJailFileRoundTrip:
|
|||||||
"""Tests that parse → serialize → parse preserves values."""
|
"""Tests that parse → serialize → parse preserves values."""
|
||||||
|
|
||||||
def test_minimal_round_trip(self) -> None:
|
def test_minimal_round_trip(self) -> None:
|
||||||
from app.services.conffile_parser import parse_jail_file, serialize_jail_file_config
|
from app.utils.conffile_parser import parse_jail_file, serialize_jail_file_config
|
||||||
|
|
||||||
original = parse_jail_file(MINIMAL_JAIL, filename="sshd.conf")
|
original = parse_jail_file(MINIMAL_JAIL, filename="sshd.conf")
|
||||||
serialized = serialize_jail_file_config(original)
|
serialized = serialize_jail_file_config(original)
|
||||||
@@ -560,7 +560,7 @@ class TestJailFileRoundTrip:
|
|||||||
assert restored.jails["sshd"].logpath == original.jails["sshd"].logpath
|
assert restored.jails["sshd"].logpath == original.jails["sshd"].logpath
|
||||||
|
|
||||||
def test_full_round_trip(self) -> None:
|
def test_full_round_trip(self) -> None:
|
||||||
from app.services.conffile_parser import parse_jail_file, serialize_jail_file_config
|
from app.utils.conffile_parser import parse_jail_file, serialize_jail_file_config
|
||||||
|
|
||||||
original = parse_jail_file(FULL_JAIL)
|
original = parse_jail_file(FULL_JAIL)
|
||||||
serialized = serialize_jail_file_config(original)
|
serialized = serialize_jail_file_config(original)
|
||||||
@@ -573,7 +573,7 @@ class TestJailFileRoundTrip:
|
|||||||
assert sorted(restored_jail.action) == sorted(jail.action)
|
assert sorted(restored_jail.action) == sorted(jail.action)
|
||||||
|
|
||||||
def test_extra_keys_round_trip(self) -> None:
|
def test_extra_keys_round_trip(self) -> None:
|
||||||
from app.services.conffile_parser import parse_jail_file, serialize_jail_file_config
|
from app.utils.conffile_parser import parse_jail_file, serialize_jail_file_config
|
||||||
|
|
||||||
original = parse_jail_file(JAIL_WITH_EXTRA)
|
original = parse_jail_file(JAIL_WITH_EXTRA)
|
||||||
serialized = serialize_jail_file_config(original)
|
serialized = serialize_jail_file_config(original)
|
||||||
@@ -591,7 +591,7 @@ class TestMergeJailFileUpdate:
|
|||||||
|
|
||||||
def test_none_update_returns_original(self) -> None:
|
def test_none_update_returns_original(self) -> None:
|
||||||
from app.models.config import JailFileConfigUpdate
|
from app.models.config import JailFileConfigUpdate
|
||||||
from app.services.conffile_parser import merge_jail_file_update, parse_jail_file
|
from app.utils.conffile_parser import merge_jail_file_update, parse_jail_file
|
||||||
|
|
||||||
cfg = parse_jail_file(FULL_JAIL)
|
cfg = parse_jail_file(FULL_JAIL)
|
||||||
update = JailFileConfigUpdate()
|
update = JailFileConfigUpdate()
|
||||||
@@ -600,7 +600,7 @@ class TestMergeJailFileUpdate:
|
|||||||
|
|
||||||
def test_update_replaces_jail(self) -> None:
|
def test_update_replaces_jail(self) -> None:
|
||||||
from app.models.config import JailFileConfigUpdate, JailSectionConfig
|
from app.models.config import JailFileConfigUpdate, JailSectionConfig
|
||||||
from app.services.conffile_parser import merge_jail_file_update, parse_jail_file
|
from app.utils.conffile_parser import merge_jail_file_update, parse_jail_file
|
||||||
|
|
||||||
cfg = parse_jail_file(FULL_JAIL)
|
cfg = parse_jail_file(FULL_JAIL)
|
||||||
new_sshd = JailSectionConfig(enabled=False, port="2222")
|
new_sshd = JailSectionConfig(enabled=False, port="2222")
|
||||||
@@ -613,7 +613,7 @@ class TestMergeJailFileUpdate:
|
|||||||
|
|
||||||
def test_update_adds_new_jail(self) -> None:
|
def test_update_adds_new_jail(self) -> None:
|
||||||
from app.models.config import JailFileConfigUpdate, JailSectionConfig
|
from app.models.config import JailFileConfigUpdate, JailSectionConfig
|
||||||
from app.services.conffile_parser import merge_jail_file_update, parse_jail_file
|
from app.utils.conffile_parser import merge_jail_file_update, parse_jail_file
|
||||||
|
|
||||||
cfg = parse_jail_file(MINIMAL_JAIL)
|
cfg = parse_jail_file(MINIMAL_JAIL)
|
||||||
new_jail = JailSectionConfig(enabled=True, port="443")
|
new_jail = JailSectionConfig(enabled=True, port="443")
|
||||||
|
|||||||
86
frontend/src/hooks/useSetup.ts
Normal file
86
frontend/src/hooks/useSetup.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Hook for the initial BanGUI setup flow.
|
||||||
|
*
|
||||||
|
* Exposes the current setup completion status and a submission handler.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
|
import { getSetupStatus, submitSetup } from "../api/setup";
|
||||||
|
import type {
|
||||||
|
SetupRequest,
|
||||||
|
SetupStatusResponse,
|
||||||
|
} from "../types/setup";
|
||||||
|
|
||||||
|
export interface UseSetupResult {
|
||||||
|
/** Known setup status, or null while loading. */
|
||||||
|
status: SetupStatusResponse | null;
|
||||||
|
/** Whether the initial status check is in progress. */
|
||||||
|
loading: boolean;
|
||||||
|
/** User-facing error message from the last status check. */
|
||||||
|
error: string | null;
|
||||||
|
/** Refresh the setup status from the backend. */
|
||||||
|
refresh: () => void;
|
||||||
|
/** Whether a submit request is currently in flight. */
|
||||||
|
submitting: boolean;
|
||||||
|
/** User-facing error message from the last submit attempt. */
|
||||||
|
submitError: string | null;
|
||||||
|
/** Submit the initial setup payload. */
|
||||||
|
submit: (payload: SetupRequest) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetup(): UseSetupResult {
|
||||||
|
const [status, setStatus] = useState<SetupStatusResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async (): Promise<void> => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await getSetupStatus();
|
||||||
|
setStatus(resp);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to fetch setup status");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const submit = useCallback(async (payload: SetupRequest): Promise<void> => {
|
||||||
|
setSubmitting(true);
|
||||||
|
setSubmitError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await submitSetup(payload);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
setSubmitError(err.message);
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
setSubmitError(err.message);
|
||||||
|
} else {
|
||||||
|
setSubmitError("An unexpected error occurred.");
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
submitting,
|
||||||
|
submitError,
|
||||||
|
submit,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,8 +20,7 @@ import {
|
|||||||
} from "@fluentui/react-components";
|
} from "@fluentui/react-components";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import type { ChangeEvent, FormEvent } from "react";
|
import type { ChangeEvent, FormEvent } from "react";
|
||||||
import { ApiError } from "../api/client";
|
import { useSetup } from "../hooks/useSetup";
|
||||||
import { getSetupStatus, submitSetup } from "../api/setup";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Styles
|
// Styles
|
||||||
|
|||||||
Reference in New Issue
Block a user