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

@@ -33,7 +33,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app.config import Settings, get_settings
from app.db import init_db
from app.routers import auth, bans, dashboard, geo, health, jails, setup
from app.routers import auth, bans, config, dashboard, geo, health, jails, server, setup
from app.tasks import health_check
# ---------------------------------------------------------------------------
@@ -276,5 +276,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.include_router(jails.router)
app.include_router(bans.router)
app.include_router(geo.router)
app.include_router(config.router)
app.include_router(server.router)
return app

View File

@@ -5,6 +5,45 @@ Request, response, and domain models for the config router and service.
from pydantic import BaseModel, ConfigDict, Field
# ---------------------------------------------------------------------------
# Jail configuration models
# ---------------------------------------------------------------------------
class JailConfig(BaseModel):
"""Configuration snapshot of a single jail (editable fields)."""
model_config = ConfigDict(strict=True)
name: str = Field(..., description="Jail name as configured in fail2ban.")
ban_time: int = Field(..., description="Ban duration in seconds. -1 for permanent.")
max_retry: int = Field(..., ge=1, description="Number of failures before a ban is issued.")
find_time: int = Field(..., ge=1, description="Time window (seconds) for counting failures.")
fail_regex: list[str] = Field(default_factory=list, description="Failure detection regex patterns.")
ignore_regex: list[str] = Field(default_factory=list, description="Regex patterns that bypass the ban logic.")
log_paths: list[str] = Field(default_factory=list, description="Monitored log files.")
date_pattern: str | None = Field(default=None, description="Custom date pattern for log parsing.")
log_encoding: str = Field(default="UTF-8", description="Log file encoding.")
backend: str = Field(default="polling", description="Log monitoring backend.")
actions: list[str] = Field(default_factory=list, description="Names of actions attached to this jail.")
class JailConfigResponse(BaseModel):
"""Response for ``GET /api/config/jails/{name}``."""
model_config = ConfigDict(strict=True)
jail: JailConfig
class JailConfigListResponse(BaseModel):
"""Response for ``GET /api/config/jails``."""
model_config = ConfigDict(strict=True)
jails: list[JailConfig] = Field(default_factory=list)
total: int = Field(..., ge=0)
class JailConfigUpdate(BaseModel):
"""Payload for ``PUT /api/config/jails/{name}``."""
@@ -21,6 +60,11 @@ class JailConfigUpdate(BaseModel):
enabled: bool | None = Field(default=None)
# ---------------------------------------------------------------------------
# Regex tester models
# ---------------------------------------------------------------------------
class RegexTestRequest(BaseModel):
"""Payload for ``POST /api/config/regex-test``."""
@@ -46,6 +90,11 @@ class RegexTestResponse(BaseModel):
)
# ---------------------------------------------------------------------------
# Global config models
# ---------------------------------------------------------------------------
class GlobalConfigResponse(BaseModel):
"""Response for ``GET /api/config/global``."""
@@ -55,3 +104,68 @@ class GlobalConfigResponse(BaseModel):
log_target: str
db_purge_age: int = Field(..., description="Seconds after which ban records are purged from the fail2ban DB.")
db_max_matches: int = Field(..., description="Maximum stored log-line matches per ban record.")
class GlobalConfigUpdate(BaseModel):
"""Payload for ``PUT /api/config/global``."""
model_config = ConfigDict(strict=True)
log_level: str | None = Field(
default=None,
description="Log level: CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG.",
)
log_target: str | None = Field(
default=None,
description="Log target: STDOUT, STDERR, SYSLOG, SYSTEMD-JOURNAL, or a file path.",
)
db_purge_age: int | None = Field(default=None, ge=0)
db_max_matches: int | None = Field(default=None, ge=0)
# ---------------------------------------------------------------------------
# Log observation / preview models
# ---------------------------------------------------------------------------
class AddLogPathRequest(BaseModel):
"""Payload for ``POST /api/config/jails/{name}/logpath``."""
model_config = ConfigDict(strict=True)
log_path: str = Field(..., description="Absolute path to the log file to monitor.")
tail: bool = Field(
default=True,
description="If true, monitor from current end of file (tail). If false, read from the beginning.",
)
class LogPreviewRequest(BaseModel):
"""Payload for ``POST /api/config/preview-log``."""
model_config = ConfigDict(strict=True)
log_path: str = Field(..., description="Absolute path to the log file to preview.")
fail_regex: str = Field(..., description="Regex pattern to test against log lines.")
num_lines: int = Field(default=200, ge=1, le=5000, description="Number of lines to read from the end of the file.")
class LogPreviewLine(BaseModel):
"""A single log line with match information."""
model_config = ConfigDict(strict=True)
line: str
matched: bool
groups: list[str] = Field(default_factory=list)
class LogPreviewResponse(BaseModel):
"""Response for ``POST /api/config/preview-log``."""
model_config = ConfigDict(strict=True)
lines: list[LogPreviewLine] = Field(default_factory=list)
total_lines: int = Field(..., ge=0)
matched_count: int = Field(..., ge=0)
regex_error: str | None = Field(default=None, description="Set if the regex failed to compile.")

View File

@@ -0,0 +1,382 @@
"""Configuration router.
Provides endpoints to inspect and edit fail2ban jail configuration and
global settings, test regex patterns, add log paths, and preview log files.
* ``GET /api/config/jails`` — list all jail configs
* ``GET /api/config/jails/{name}`` — full config for one jail
* ``PUT /api/config/jails/{name}`` — update a jail's config
* ``GET /api/config/global`` — global fail2ban settings
* ``PUT /api/config/global`` — update global settings
* ``POST /api/config/reload`` — reload fail2ban
* ``POST /api/config/regex-test`` — test a regex pattern
* ``POST /api/config/jails/{name}/logpath`` — add a log path to a jail
* ``POST /api/config/preview-log`` — preview log matches
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, HTTPException, Path, Request, status
from app.dependencies import AuthDep
from app.models.config import (
AddLogPathRequest,
GlobalConfigResponse,
GlobalConfigUpdate,
JailConfigListResponse,
JailConfigResponse,
JailConfigUpdate,
LogPreviewRequest,
LogPreviewResponse,
RegexTestRequest,
RegexTestResponse,
)
from app.services import config_service, jail_service
from app.services.config_service import (
ConfigOperationError,
ConfigValidationError,
JailNotFoundError,
)
from app.utils.fail2ban_client import Fail2BanConnectionError
router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_NamePath = Annotated[str, Path(description="Jail name as configured in fail2ban.")]
def _not_found(name: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Jail not found: {name!r}",
)
def _bad_gateway(exc: Exception) -> HTTPException:
return HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Cannot reach fail2ban: {exc}",
)
def _unprocessable(message: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=message,
)
def _bad_request(message: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message,
)
# ---------------------------------------------------------------------------
# Jail configuration endpoints
# ---------------------------------------------------------------------------
@router.get(
"/jails",
response_model=JailConfigListResponse,
summary="List configuration for all active jails",
)
async def get_jail_configs(
request: Request,
_auth: AuthDep,
) -> JailConfigListResponse:
"""Return editable configuration for every active fail2ban jail.
Fetches ban time, find time, max retries, regex patterns, log paths,
date pattern, encoding, backend, and attached actions for all jails.
Args:
request: Incoming request (used to access ``app.state``).
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.config.JailConfigListResponse`.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_service.list_jail_configs(socket_path)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.get(
"/jails/{name}",
response_model=JailConfigResponse,
summary="Return configuration for a single jail",
)
async def get_jail_config(
request: Request,
_auth: AuthDep,
name: _NamePath,
) -> JailConfigResponse:
"""Return the full editable configuration for one fail2ban jail.
Args:
request: Incoming request.
_auth: Validated session.
name: Jail name.
Returns:
:class:`~app.models.config.JailConfigResponse`.
Raises:
HTTPException: 404 when the jail does not exist.
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_service.get_jail_config(socket_path, name)
except JailNotFoundError:
raise _not_found(name) from None
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.put(
"/jails/{name}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update jail configuration",
)
async def update_jail_config(
request: Request,
_auth: AuthDep,
name: _NamePath,
body: JailConfigUpdate,
) -> None:
"""Update one or more configuration fields for an active fail2ban jail.
Regex patterns are validated before being sent to fail2ban. An invalid
pattern returns 422 with the regex error message.
Args:
request: Incoming request.
_auth: Validated session.
name: Jail name.
body: Partial update — only non-None fields are written.
Raises:
HTTPException: 404 when the jail does not exist.
HTTPException: 422 when a regex pattern fails to compile.
HTTPException: 400 when a set command is rejected.
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
await config_service.update_jail_config(socket_path, name, body)
except JailNotFoundError:
raise _not_found(name) from None
except ConfigValidationError as exc:
raise _unprocessable(str(exc)) from exc
except ConfigOperationError as exc:
raise _bad_request(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# ---------------------------------------------------------------------------
# Global configuration endpoints
# ---------------------------------------------------------------------------
@router.get(
"/global",
response_model=GlobalConfigResponse,
summary="Return global fail2ban settings",
)
async def get_global_config(
request: Request,
_auth: AuthDep,
) -> GlobalConfigResponse:
"""Return global fail2ban settings (log level, log target, database config).
Args:
request: Incoming request.
_auth: Validated session.
Returns:
:class:`~app.models.config.GlobalConfigResponse`.
Raises:
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_service.get_global_config(socket_path)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.put(
"/global",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update global fail2ban settings",
)
async def update_global_config(
request: Request,
_auth: AuthDep,
body: GlobalConfigUpdate,
) -> None:
"""Update global fail2ban settings.
Args:
request: Incoming request.
_auth: Validated session.
body: Partial update — only non-None fields are written.
Raises:
HTTPException: 400 when a set command is rejected.
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
await config_service.update_global_config(socket_path, body)
except ConfigOperationError as exc:
raise _bad_request(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# ---------------------------------------------------------------------------
# Reload endpoint
# ---------------------------------------------------------------------------
@router.post(
"/reload",
status_code=status.HTTP_204_NO_CONTENT,
summary="Reload fail2ban to apply configuration changes",
)
async def reload_fail2ban(
request: Request,
_auth: AuthDep,
) -> None:
"""Trigger a full fail2ban reload.
All jails are stopped and restarted with the current configuration.
Args:
request: Incoming request.
_auth: Validated session.
Raises:
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
await jail_service.reload_all(socket_path)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# ---------------------------------------------------------------------------
# Regex tester (stateless)
# ---------------------------------------------------------------------------
@router.post(
"/regex-test",
response_model=RegexTestResponse,
summary="Test a fail regex pattern against a sample log line",
)
async def regex_test(
_auth: AuthDep,
body: RegexTestRequest,
) -> RegexTestResponse:
"""Test whether a regex pattern matches a given log line.
This endpoint is entirely in-process — no fail2ban socket call is made.
Returns the match result and any captured groups.
Args:
_auth: Validated session.
body: Sample log line and regex pattern.
Returns:
:class:`~app.models.config.RegexTestResponse` with match result and groups.
"""
return config_service.test_regex(body)
# ---------------------------------------------------------------------------
# Log path management
# ---------------------------------------------------------------------------
@router.post(
"/jails/{name}/logpath",
status_code=status.HTTP_204_NO_CONTENT,
summary="Add a log file path to an existing jail",
)
async def add_log_path(
request: Request,
_auth: AuthDep,
name: _NamePath,
body: AddLogPathRequest,
) -> None:
"""Register an additional log file for an existing jail to monitor.
Uses ``set <jail> addlogpath <path> <tail|head>`` to add the path
without requiring a daemon restart.
Args:
request: Incoming request.
_auth: Validated session.
name: Jail name.
body: Log path and tail/head preference.
Raises:
HTTPException: 404 when the jail does not exist.
HTTPException: 400 when the command is rejected.
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
await config_service.add_log_path(socket_path, name, body)
except JailNotFoundError:
raise _not_found(name) from None
except ConfigOperationError as exc:
raise _bad_request(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# ---------------------------------------------------------------------------
# Log preview
# ---------------------------------------------------------------------------
@router.post(
"/preview-log",
response_model=LogPreviewResponse,
summary="Preview log file lines against a regex pattern",
)
async def preview_log(
_auth: AuthDep,
body: LogPreviewRequest,
) -> LogPreviewResponse:
"""Read the last N lines of a log file and test a regex against each one.
Returns each line with a flag indicating whether the regex matched, and
the captured groups for matching lines. The log file is read from the
server's local filesystem.
Args:
_auth: Validated session.
body: Log file path, regex pattern, and number of lines to read.
Returns:
:class:`~app.models.config.LogPreviewResponse` with per-line results.
"""
return await config_service.preview_log(body)

View File

@@ -0,0 +1,144 @@
"""Server settings router.
Provides endpoints to view and update fail2ban server-level settings and
to flush log files.
* ``GET /api/server/settings`` — current log level, target, and DB config
* ``PUT /api/server/settings`` — update server-level settings
* ``POST /api/server/flush-logs`` — flush and re-open log files
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, status
from app.dependencies import AuthDep
from app.models.server import ServerSettingsResponse, ServerSettingsUpdate
from app.services import server_service
from app.services.server_service import ServerOperationError
from app.utils.fail2ban_client import Fail2BanConnectionError
router: APIRouter = APIRouter(prefix="/api/server", tags=["Server"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _bad_gateway(exc: Exception) -> HTTPException:
return HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Cannot reach fail2ban: {exc}",
)
def _bad_request(message: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message,
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get(
"/settings",
response_model=ServerSettingsResponse,
summary="Return fail2ban server-level settings",
)
async def get_server_settings(
request: Request,
_auth: AuthDep,
) -> ServerSettingsResponse:
"""Return the current fail2ban server-level settings.
Includes log level, log target, syslog socket, database file path,
database purge age, and maximum stored matches per record.
Args:
request: Incoming request (used to access ``app.state``).
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.server.ServerSettingsResponse`.
Raises:
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await server_service.get_settings(socket_path)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.put(
"/settings",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update fail2ban server-level settings",
)
async def update_server_settings(
request: Request,
_auth: AuthDep,
body: ServerSettingsUpdate,
) -> None:
"""Update fail2ban server-level settings.
Only non-None fields in the request body are written. Changes take
effect immediately without a daemon restart.
Args:
request: Incoming request.
_auth: Validated session.
body: Partial settings update.
Raises:
HTTPException: 400 when a set command is rejected by fail2ban.
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
await server_service.update_settings(socket_path, body)
except ServerOperationError as exc:
raise _bad_request(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.post(
"/flush-logs",
status_code=status.HTTP_200_OK,
summary="Flush and re-open fail2ban log files",
)
async def flush_logs(
request: Request,
_auth: AuthDep,
) -> dict[str, str]:
"""Flush and re-open fail2ban log files.
Useful after log rotation so the daemon writes to the newly created
log file rather than continuing to append to the rotated one.
Args:
request: Incoming request.
_auth: Validated session.
Returns:
``{"message": "<response from fail2ban>"}``
Raises:
HTTPException: 400 when the command is rejected.
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
result = await server_service.flush_logs(socket_path)
return {"message": result}
except ServerOperationError as exc:
raise _bad_request(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc

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()]

View File

@@ -0,0 +1,189 @@
"""Server-level settings service.
Provides methods to read and update fail2ban server-level settings
(log level, log target, database configuration) via the Unix domain socket.
Also exposes the ``flushlogs`` command for use after log rotation.
Architecture note: this module is a pure service — it contains **no**
HTTP/FastAPI concerns.
"""
from __future__ import annotations
from typing import Any
import structlog
from app.models.server import ServerSettings, ServerSettingsResponse, ServerSettingsUpdate
from app.utils.fail2ban_client import Fail2BanClient
log: structlog.stdlib.BoundLogger = structlog.get_logger()
_SOCKET_TIMEOUT: float = 10.0
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class ServerOperationError(Exception):
"""Raised when a server-level set command fails."""
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _ok(response: Any) -> Any:
"""Extract payload from a fail2ban ``(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 response shape: {response!r}") from exc
if code != 0:
raise ValueError(f"fail2ban error {code}: {data!r}")
return data
async def _safe_get(
client: Fail2BanClient,
command: list[Any],
default: Any = None,
) -> Any:
"""Send a command and silently return *default* on any error.
Args:
client: The :class:`~app.utils.fail2ban_client.Fail2BanClient` to use.
command: Command list to send.
default: Fallback value.
Returns:
The successful response, or *default*.
"""
try:
return _ok(await client.send(command))
except Exception:
return default
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def get_settings(socket_path: str) -> ServerSettingsResponse:
"""Return current fail2ban server-level settings.
Fetches log level, log target, syslog socket, database file path, purge
age, and max matches in a single round-trip batch.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Returns:
:class:`~app.models.server.ServerSettingsResponse`.
Raises:
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
import asyncio
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
(
log_level_raw,
log_target_raw,
syslog_socket_raw,
db_path_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", "syslogsocket"], None),
_safe_get(client, ["get", "dbfile"], "/var/lib/fail2ban/fail2ban.sqlite3"),
_safe_get(client, ["get", "dbpurgeage"], 86400),
_safe_get(client, ["get", "dbmaxmatches"], 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.info("server_settings_fetched")
return ServerSettingsResponse(settings=settings)
async def update_settings(socket_path: str, update: ServerSettingsUpdate) -> None:
"""Apply *update* to fail2ban server-level settings.
Only non-None fields in *update* are sent.
Args:
socket_path: Path to the fail2ban Unix domain socket.
update: Partial update payload.
Raises:
ServerOperationError: If any ``set`` command is rejected.
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
async def _set(key: str, value: Any) -> None:
try:
_ok(await client.send(["set", key, value]))
except ValueError as exc:
raise ServerOperationError(f"Failed to set {key!r} = {value!r}: {exc}") from exc
if update.log_level is not None:
await _set("loglevel", update.log_level.upper())
if update.log_target is not None:
await _set("logtarget", update.log_target)
if update.db_purge_age is not None:
await _set("dbpurgeage", update.db_purge_age)
if update.db_max_matches is not None:
await _set("dbmaxmatches", update.db_max_matches)
log.info("server_settings_updated")
async def flush_logs(socket_path: str) -> str:
"""Flush and re-open fail2ban log files.
Useful after log rotation so the daemon starts writing to the newly
created file rather than the old rotated one.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Returns:
The response message from fail2ban (e.g. ``"OK"``) as a string.
Raises:
ServerOperationError: If the command is rejected.
~app.utils.fail2ban_client.Fail2BanConnectionError: Socket unreachable.
"""
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
try:
result = _ok(await client.send(["flushlogs"]))
log.info("logs_flushed", result=result)
return str(result)
except ValueError as exc:
raise ServerOperationError(f"flushlogs failed: {exc}") from exc