Stage 7: configuration view — backend service, routers, tests, and frontend

- config_service.py: read/write jail config via asyncio.gather, global
  settings, in-process regex validation, log preview via _read_tail_lines
- server_service.py: read/write server settings, flush logs
- config router: 9 endpoints for jail/global config, regex-test,
  logpath management, log preview
- server router: GET/PUT settings, POST flush-logs
- models/config.py expanded with JailConfig, GlobalConfigUpdate,
  LogPreview* models
- 285 tests pass (68 new), ruff clean, mypy clean (44 files)
- Frontend: types/config.ts, api/config.ts, hooks/useConfig.ts,
  ConfigPage.tsx full implementation (Jails accordion editor,
  Global config, Server settings, Regex Tester with preview)
- Fixed pre-existing frontend lint: JSX.Element → React.JSX.Element
  (10 files), void/promise patterns in useServerStatus + useJails,
  no-misused-spread in client.ts, eslint.config.ts self-excluded
This commit is contained in:
2026-03-01 14:37:55 +01:00
parent ebec5e0f58
commit 7f81f0614b
33 changed files with 4488 additions and 82 deletions

View File

@@ -0,0 +1,611 @@
"""Configuration inspection and editing service.
Provides methods to read and update fail2ban jail configuration and global
server settings via the Unix domain socket. Regex validation is performed
locally with Python's :mod:`re` module before any write is sent to the daemon
so that invalid patterns are rejected early.
Architecture note: this module is a pure service — it contains **no**
HTTP/FastAPI concerns. All results are returned as Pydantic models so
routers can serialise them directly.
"""
from __future__ import annotations
import asyncio
import contextlib
import re
from pathlib import Path
from typing import Any
import structlog
from app.models.config import (
AddLogPathRequest,
GlobalConfigResponse,
GlobalConfigUpdate,
JailConfig,
JailConfigListResponse,
JailConfigResponse,
JailConfigUpdate,
LogPreviewLine,
LogPreviewRequest,
LogPreviewResponse,
RegexTestRequest,
RegexTestResponse,
)
from app.utils.fail2ban_client import Fail2BanClient
log: structlog.stdlib.BoundLogger = structlog.get_logger()
_SOCKET_TIMEOUT: float = 10.0
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class JailNotFoundError(Exception):
"""Raised when a requested jail name does not exist in fail2ban."""
def __init__(self, name: str) -> None:
"""Initialise with the jail name that was not found.
Args:
name: The jail name that could not be located.
"""
self.name: str = name
super().__init__(f"Jail not found: {name!r}")
class ConfigValidationError(Exception):
"""Raised when a configuration value fails validation before writing."""
class ConfigOperationError(Exception):
"""Raised when a configuration write command fails."""
# ---------------------------------------------------------------------------
# Internal helpers (mirrored from jail_service for isolation)
# ---------------------------------------------------------------------------
def _ok(response: Any) -> Any:
"""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 = 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: Any) -> dict[str, Any]:
"""Convert a list of ``(key, value)`` pairs to a plain dict."""
if not isinstance(pairs, (list, tuple)):
return {}
result: dict[str, Any] = {}
for item in pairs:
try:
k, v = item
result[str(k)] = v
except (TypeError, ValueError):
pass
return result
def _ensure_list(value: Any) -> 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)]
async def _safe_get(
client: Fail2BanClient,
command: list[Any],
default: Any = None,
) -> Any:
"""Send a command and return *default* if it fails."""
try:
return _ok(await client.send(command))
except Exception:
return 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.
Args:
pattern: A regex pattern string to validate.
Returns:
``None`` if valid, or an error message string if the pattern is broken.
"""
try:
re.compile(pattern)
return None
except re.error as exc:
return str(exc)
# ---------------------------------------------------------------------------
# Public API — read jail configuration
# ---------------------------------------------------------------------------
async def get_jail_config(socket_path: str, name: str) -> JailConfigResponse:
"""Return the editable configuration for a single jail.
Args:
socket_path: Path to the fail2ban Unix domain socket.
name: Jail name.
Returns:
:class:`~app.models.config.JailConfigResponse`.
Raises:
JailNotFoundError: If *name* is not a known jail.
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
# Verify existence.
try:
_ok(await client.send(["status", name, "short"]))
except ValueError as exc:
if _is_not_found_error(exc):
raise JailNotFoundError(name) from exc
raise
(
bantime_raw,
findtime_raw,
maxretry_raw,
failregex_raw,
ignoreregex_raw,
logpath_raw,
datepattern_raw,
logencoding_raw,
backend_raw,
actions_raw,
) = await asyncio.gather(
_safe_get(client, ["get", name, "bantime"], 600),
_safe_get(client, ["get", name, "findtime"], 600),
_safe_get(client, ["get", name, "maxretry"], 5),
_safe_get(client, ["get", name, "failregex"], []),
_safe_get(client, ["get", name, "ignoreregex"], []),
_safe_get(client, ["get", name, "logpath"], []),
_safe_get(client, ["get", name, "datepattern"], None),
_safe_get(client, ["get", name, "logencoding"], "UTF-8"),
_safe_get(client, ["get", name, "backend"], "polling"),
_safe_get(client, ["get", name, "actions"], []),
)
jail_cfg = JailConfig(
name=name,
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),
date_pattern=str(datepattern_raw) if datepattern_raw else None,
log_encoding=str(logencoding_raw or "UTF-8"),
backend=str(backend_raw or "polling"),
actions=_ensure_list(actions_raw),
)
log.info("jail_config_fetched", jail=name)
return JailConfigResponse(jail=jail_cfg)
async def list_jail_configs(socket_path: str) -> JailConfigListResponse:
"""Return configuration for all active jails.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Returns:
:class:`~app.models.config.JailConfigListResponse`.
Raises:
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
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()]
if jail_list_raw
else []
)
if not jail_names:
return JailConfigListResponse(jails=[], total=0)
responses: list[JailConfigResponse] = await asyncio.gather(
*[get_jail_config(socket_path, name) for name in jail_names],
return_exceptions=False,
)
jails = [r.jail for r in responses]
log.info("jail_configs_listed", count=len(jails))
return JailConfigListResponse(jails=jails, total=len(jails))
# ---------------------------------------------------------------------------
# Public API — write jail configuration
# ---------------------------------------------------------------------------
async def update_jail_config(
socket_path: str,
name: str,
update: JailConfigUpdate,
) -> None:
"""Apply *update* to the configuration of a running jail.
Each non-None field in *update* is sent as a separate ``set`` command.
Regex patterns are validated locally before any write is sent.
Args:
socket_path: Path to the fail2ban Unix domain socket.
name: Jail name.
update: Partial update payload.
Raises:
JailNotFoundError: If *name* is not a known jail.
ConfigValidationError: If a regex pattern fails to compile.
ConfigOperationError: If a ``set`` command is rejected by fail2ban.
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
# Validate all regex patterns before touching the daemon.
for pattern_list, field in [
(update.fail_regex, "fail_regex"),
(update.ignore_regex, "ignore_regex"),
]:
if pattern_list is None:
continue
for pattern in pattern_list:
err = _validate_regex(pattern)
if err:
raise ConfigValidationError(f"Invalid regex in {field!r}: {err!r} (pattern: {pattern!r})")
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
# Verify existence.
try:
_ok(await client.send(["status", name, "short"]))
except ValueError as exc:
if _is_not_found_error(exc):
raise JailNotFoundError(name) from exc
raise
async def _set(key: str, value: Any) -> None:
try:
_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
if update.ban_time is not None:
await _set("bantime", update.ban_time)
if update.find_time is not None:
await _set("findtime", update.find_time)
if update.max_retry is not None:
await _set("maxretry", update.max_retry)
if update.date_pattern is not None:
await _set("datepattern", update.date_pattern)
if update.dns_mode is not None:
await _set("usedns", update.dns_mode)
if update.enabled is not None:
await _set("idle", "off" if update.enabled else "on")
# Replacing regex lists requires deleting old entries then adding new ones.
if update.fail_regex is not None:
await _replace_regex_list(client, name, "failregex", update.fail_regex)
if update.ignore_regex is not None:
await _replace_regex_list(client, name, "ignoreregex", update.ignore_regex)
log.info("jail_config_updated", jail=name)
async def _replace_regex_list(
client: Fail2BanClient,
jail: str,
field: str,
new_patterns: list[str],
) -> None:
"""Replace the full regex list for *field* in *jail*.
Deletes all existing entries (highest index first to preserve ordering)
then inserts all *new_patterns* in order.
Args:
client: Shared :class:`~app.utils.fail2ban_client.Fail2BanClient`.
jail: Jail name.
field: Either ``"failregex"`` or ``"ignoreregex"``.
new_patterns: Replacement list (may be empty to clear).
"""
# Determine current count.
current_raw = await _safe_get(client, ["get", jail, field], [])
current: list[str] = _ensure_list(current_raw)
del_cmd = f"del{field}"
add_cmd = f"add{field}"
# 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]))
# Add new patterns.
for pattern in new_patterns:
err = _validate_regex(pattern)
if err:
raise ConfigValidationError(f"Invalid regex: {err!r} (pattern: {pattern!r})")
try:
_ok(await client.send(["set", jail, add_cmd, pattern]))
except ValueError as exc:
raise ConfigOperationError(f"Failed to add {field} pattern: {exc}") from exc
# ---------------------------------------------------------------------------
# Public API — global configuration
# ---------------------------------------------------------------------------
async def get_global_config(socket_path: str) -> GlobalConfigResponse:
"""Return fail2ban global configuration settings.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Returns:
:class:`~app.models.config.GlobalConfigResponse`.
Raises:
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
(
log_level_raw,
log_target_raw,
db_purge_age_raw,
db_max_matches_raw,
) = await asyncio.gather(
_safe_get(client, ["get", "loglevel"], "INFO"),
_safe_get(client, ["get", "logtarget"], "STDOUT"),
_safe_get(client, ["get", "dbpurgeage"], 86400),
_safe_get(client, ["get", "dbmaxmatches"], 10),
)
return GlobalConfigResponse(
log_level=str(log_level_raw or "INFO").upper(),
log_target=str(log_target_raw or "STDOUT"),
db_purge_age=int(db_purge_age_raw or 86400),
db_max_matches=int(db_max_matches_raw or 10),
)
async def update_global_config(socket_path: str, update: GlobalConfigUpdate) -> None:
"""Apply *update* to fail2ban global settings.
Args:
socket_path: Path to the fail2ban Unix domain socket.
update: Partial update payload.
Raises:
ConfigOperationError: If a ``set`` command is rejected.
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
async def _set_global(key: str, value: Any) -> None:
try:
_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
if update.log_level is not None:
await _set_global("loglevel", update.log_level.upper())
if update.log_target is not None:
await _set_global("logtarget", update.log_target)
if update.db_purge_age is not None:
await _set_global("dbpurgeage", update.db_purge_age)
if update.db_max_matches is not None:
await _set_global("dbmaxmatches", update.db_max_matches)
log.info("global_config_updated")
# ---------------------------------------------------------------------------
# Public API — regex tester (stateless, no socket)
# ---------------------------------------------------------------------------
def test_regex(request: RegexTestRequest) -> RegexTestResponse:
"""Test a regex pattern against a sample log line.
This is a pure in-process operation — no socket communication occurs.
Args:
request: The :class:`~app.models.config.RegexTestRequest` payload.
Returns:
:class:`~app.models.config.RegexTestResponse` with match result.
"""
try:
compiled = re.compile(request.fail_regex)
except re.error as exc:
return RegexTestResponse(matched=False, groups=[], error=str(exc))
match = compiled.search(request.log_line)
if match is None:
return RegexTestResponse(matched=False)
groups: list[str] = list(match.groups() or [])
return RegexTestResponse(matched=True, groups=[str(g) for g in groups if g is not None])
# ---------------------------------------------------------------------------
# Public API — log observation
# ---------------------------------------------------------------------------
async def add_log_path(
socket_path: str,
jail: str,
req: AddLogPathRequest,
) -> None:
"""Add a log path to an existing jail.
Args:
socket_path: Path to the fail2ban Unix domain socket.
jail: Jail name to which the log path should be added.
req: :class:`~app.models.config.AddLogPathRequest` with the path to add.
Raises:
JailNotFoundError: If *jail* is not a known jail.
ConfigOperationError: If the command is rejected by fail2ban.
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
_ok(await client.send(["status", jail, "short"]))
except ValueError as 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]))
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
async def preview_log(req: LogPreviewRequest) -> LogPreviewResponse:
"""Read the last *num_lines* of a log file and test *fail_regex* against each.
This operation reads from the local filesystem — no socket is used.
Args:
req: :class:`~app.models.config.LogPreviewRequest`.
Returns:
:class:`~app.models.config.LogPreviewResponse` with line-by-line results.
"""
# Validate the regex first.
try:
compiled = re.compile(req.fail_regex)
except re.error as exc:
return LogPreviewResponse(
lines=[],
total_lines=0,
matched_count=0,
regex_error=str(exc),
)
path = Path(req.log_path)
if not path.is_file():
return LogPreviewResponse(
lines=[],
total_lines=0,
matched_count=0,
regex_error=f"File not found: {req.log_path!r}",
)
# Read the last num_lines lines efficiently.
try:
raw_lines = await asyncio.get_event_loop().run_in_executor(
None,
_read_tail_lines,
str(path),
req.num_lines,
)
except OSError as exc:
return LogPreviewResponse(
lines=[],
total_lines=0,
matched_count=0,
regex_error=f"Cannot read file: {exc}",
)
result_lines: list[LogPreviewLine] = []
matched_count = 0
for line in raw_lines:
m = compiled.search(line)
groups = [str(g) for g in (m.groups() or []) if g is not None] if m else []
result_lines.append(LogPreviewLine(line=line, matched=(m is not None), groups=groups))
if m:
matched_count += 1
return LogPreviewResponse(
lines=result_lines,
total_lines=len(result_lines),
matched_count=matched_count,
)
def _read_tail_lines(file_path: str, num_lines: int) -> list[str]:
"""Read the last *num_lines* from *file_path* synchronously.
Uses a memory-efficient approach that seeks from the end of the file.
Args:
file_path: Absolute path to the log file.
num_lines: Number of lines to return.
Returns:
A list of stripped line strings.
"""
chunk_size = 8192
raw_lines: list[bytes] = []
with open(file_path, "rb") as fh:
fh.seek(0, 2) # seek to end
end_pos = fh.tell()
if end_pos == 0:
return []
buf = b""
pos = end_pos
while len(raw_lines) <= num_lines and pos > 0:
read_size = min(chunk_size, pos)
pos -= read_size
fh.seek(pos)
chunk = fh.read(read_size)
buf = chunk + buf
raw_lines = buf.split(b"\n")
# Strip incomplete leading line unless we've read the whole file.
if pos > 0 and len(raw_lines) > 1:
raw_lines = raw_lines[1:]
return [ln.decode("utf-8", errors="replace").rstrip() for ln in raw_lines[-num_lines:] if ln.strip()]