Models in app/models/ are now pure data classes with no cross-layer dependencies. This ensures the models layer remains a true leaf node in the dependency graph. Changes: - Create app/models/_common.py with shared types (TimeRange, bucket_count, constants) - Move TimeRange and time-range constants from ban.py to _common.py - Update history.py, routers, and services to import from _common.py - Remove imports from app.config and app.utils from config.py models - Move field validators from models to router layer: - Add log_target validation in config_misc router - Add log_path validation in jail_config router - Update test_models.py to reflect validators moved to router layer - Update documentation (Architekture.md, Backend-Development.md) with model layering rules - Fix import ordering and type annotations in affected files Model layering rule: Models may only import from: ✓ Standard library and third-party packages (Pydantic, typing) ✓ Other models in app/models/ (sibling models) ✓ app.models.response (response envelopes) ✗ app.services, app.config, app.utils, or any application layer Validation requiring app-level state (settings, allowed directories) now happens at the router or service layer, not in model validators. Fixes: Models were not true leaf nodes due to circular imports and app-layer dependencies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
407 lines
12 KiB
Python
407 lines
12 KiB
Python
from __future__ import annotations
|
||
|
||
import shlex
|
||
from pathlib import Path
|
||
from typing import Annotated
|
||
|
||
import structlog
|
||
from fastapi import APIRouter, Query, Request, status
|
||
|
||
from app.config import get_settings
|
||
from app.dependencies import (
|
||
AuthDep,
|
||
Fail2BanSocketDep,
|
||
Fail2BanStartCommandDep,
|
||
SettingsServiceContextDep,
|
||
)
|
||
from app.exceptions import OperationError
|
||
from app.models.config import (
|
||
Fail2BanLogResponse,
|
||
GlobalConfigResponse,
|
||
GlobalConfigUpdate,
|
||
LogPreviewRequest,
|
||
LogPreviewResponse,
|
||
MapColorThresholdsResponse,
|
||
MapColorThresholdsUpdate,
|
||
RegexTestRequest,
|
||
RegexTestResponse,
|
||
ServiceStatusResponse,
|
||
)
|
||
from app.services import (
|
||
config_service,
|
||
jail_service,
|
||
log_service,
|
||
)
|
||
|
||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||
|
||
router: APIRouter = APIRouter(tags=["Config Misc"])
|
||
|
||
|
||
def _validate_log_target(value: str) -> None:
|
||
"""Validate that log_target is either a special value or a valid file path.
|
||
|
||
Args:
|
||
value: The log target to validate.
|
||
|
||
Raises:
|
||
ValueError: If the target is not a special value and not in allowed directories.
|
||
"""
|
||
if value.upper() in ("STDOUT", "STDERR", "SYSLOG"):
|
||
return
|
||
|
||
settings = get_settings()
|
||
try:
|
||
resolved_path = Path(value).resolve()
|
||
except (OSError, RuntimeError) as e:
|
||
raise ValueError(f"Cannot resolve path {value!r}: {e}") from e
|
||
|
||
for allowed_dir in settings.allowed_log_dirs:
|
||
allowed_path = Path(allowed_dir).resolve()
|
||
try:
|
||
resolved_path.relative_to(allowed_path)
|
||
return
|
||
except ValueError:
|
||
continue
|
||
|
||
allowed_dirs_str = ", ".join(settings.allowed_log_dirs)
|
||
raise ValueError(
|
||
f"Log target {value!r} is outside allowed directories: {allowed_dirs_str}"
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/global",
|
||
response_model=GlobalConfigResponse,
|
||
summary="Return global fail2ban settings",
|
||
)
|
||
async def get_global_config(
|
||
_request: Request,
|
||
_auth: AuthDep,
|
||
socket_path: Fail2BanSocketDep,
|
||
) -> GlobalConfigResponse:
|
||
"""Return global fail2ban settings.
|
||
|
||
Includes log level, log target, and database configuration.
|
||
|
||
Args:
|
||
request: Incoming request.
|
||
_auth: Validated session.
|
||
|
||
Returns:
|
||
:class:`~app.models.config.GlobalConfigResponse`.
|
||
|
||
Raises:
|
||
HTTPException: 502 when fail2ban is unreachable.
|
||
"""
|
||
return await config_service.get_global_config(socket_path)
|
||
|
||
|
||
@router.put(
|
||
"/global",
|
||
status_code=status.HTTP_204_NO_CONTENT,
|
||
summary="Update global fail2ban settings",
|
||
)
|
||
async def update_global_config(
|
||
_request: Request,
|
||
_auth: AuthDep,
|
||
socket_path: Fail2BanSocketDep,
|
||
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 or log_target is invalid.
|
||
HTTPException: 502 when fail2ban is unreachable.
|
||
"""
|
||
if body.log_target is not None:
|
||
_validate_log_target(body.log_target)
|
||
await config_service.update_global_config(socket_path, body)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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,
|
||
socket_path: Fail2BanSocketDep,
|
||
) -> 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: 409 when fail2ban reports the reload failed.
|
||
HTTPException: 502 when fail2ban is unreachable.
|
||
"""
|
||
await jail_service.reload_all(socket_path)
|
||
|
||
|
||
# Restart endpoint
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@router.post(
|
||
"/restart",
|
||
status_code=status.HTTP_204_NO_CONTENT,
|
||
summary="Restart the fail2ban service",
|
||
)
|
||
async def restart_fail2ban(
|
||
_request: Request,
|
||
_auth: AuthDep,
|
||
socket_path: Fail2BanSocketDep,
|
||
start_cmd: Fail2BanStartCommandDep,
|
||
) -> None:
|
||
"""Trigger a full fail2ban service restart.
|
||
|
||
Stops the fail2ban daemon via the Unix domain socket, then starts it
|
||
again using the configured ``fail2ban_start_command``. After starting,
|
||
probes the socket for up to 10 seconds to confirm the daemon came back
|
||
online.
|
||
|
||
Args:
|
||
request: Incoming request.
|
||
_auth: Validated session.
|
||
|
||
Raises:
|
||
HTTPException: 409 when fail2ban reports the stop command failed.
|
||
HTTPException: 502 when fail2ban is unreachable for the stop command.
|
||
HTTPException: 503 when fail2ban does not come back online within
|
||
10 seconds after being started. Check the fail2ban log for
|
||
initialisation errors. Use
|
||
``POST /api/config/jails/{name}/rollback``
|
||
if a specific jail is suspect.
|
||
"""
|
||
start_cmd_parts: list[str] = shlex.split(start_cmd)
|
||
|
||
restarted = await jail_service.restart_daemon(
|
||
socket_path,
|
||
start_cmd_parts,
|
||
)
|
||
|
||
if not restarted:
|
||
raise OperationError(
|
||
"fail2ban was stopped but did not come back "
|
||
"online within 10 seconds. "
|
||
"Check the fail2ban log for initialisation errors. "
|
||
"Use POST /api/config/jails/{name}/rollback if a "
|
||
"specific jail is suspect."
|
||
)
|
||
log.info("fail2ban_restarted")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 log_service.test_regex(body)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Log path management
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@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 log_service.preview_log(body)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Map color thresholds
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@router.get(
|
||
"/map-color-thresholds",
|
||
response_model=MapColorThresholdsResponse,
|
||
summary="Get map color threshold configuration",
|
||
)
|
||
async def get_map_color_thresholds(
|
||
_request: Request,
|
||
_auth: AuthDep,
|
||
settings_ctx: SettingsServiceContextDep,
|
||
) -> MapColorThresholdsResponse:
|
||
"""Return the configured map color thresholds.
|
||
|
||
Args:
|
||
_request: FastAPI request object.
|
||
_auth: Validated session.
|
||
settings_ctx: Settings service context containing db and repository.
|
||
|
||
Returns:
|
||
:class:`~app.models.config.MapColorThresholdsResponse` with
|
||
current thresholds.
|
||
"""
|
||
return await config_service.get_map_color_thresholds(settings_ctx.db)
|
||
|
||
@router.put(
|
||
"/map-color-thresholds",
|
||
response_model=MapColorThresholdsResponse,
|
||
summary="Update map color threshold configuration",
|
||
)
|
||
async def update_map_color_thresholds(
|
||
_request: Request,
|
||
_auth: AuthDep,
|
||
settings_ctx: SettingsServiceContextDep,
|
||
body: MapColorThresholdsUpdate,
|
||
) -> MapColorThresholdsResponse:
|
||
"""Update the map color threshold configuration.
|
||
|
||
Args:
|
||
_request: FastAPI request object.
|
||
_auth: Validated session.
|
||
settings_ctx: Settings service context containing db and repository.
|
||
body: New threshold values.
|
||
|
||
Returns:
|
||
:class:`~app.models.config.MapColorThresholdsResponse` with
|
||
updated thresholds.
|
||
|
||
Raises:
|
||
HTTPException: 400 if validation fails (thresholds not
|
||
properly ordered).
|
||
"""
|
||
await config_service.update_map_color_thresholds(settings_ctx.db, body)
|
||
return await config_service.get_map_color_thresholds(settings_ctx.db)
|
||
|
||
|
||
@router.get(
|
||
"/fail2ban-log",
|
||
response_model=Fail2BanLogResponse,
|
||
summary="Read the tail of the fail2ban daemon log file",
|
||
)
|
||
async def get_fail2ban_log(
|
||
_request: Request,
|
||
_auth: AuthDep,
|
||
socket_path: Fail2BanSocketDep,
|
||
lines: Annotated[
|
||
int,
|
||
Query(
|
||
ge=1,
|
||
le=2000,
|
||
description="Number of lines to return from the tail.",
|
||
),
|
||
] = 200,
|
||
filter_: Annotated[ # noqa: A002
|
||
str | None,
|
||
Query(
|
||
alias="filter",
|
||
description=(
|
||
"Plain-text substring filter; "
|
||
"only matching lines are returned."
|
||
),
|
||
),
|
||
] = None,
|
||
) -> Fail2BanLogResponse:
|
||
"""Return the tail of the fail2ban daemon log file.
|
||
|
||
Queries the fail2ban socket for the current log target and log level,
|
||
reads the last *lines* entries from the file, and optionally filters
|
||
them by *filter*. Only file-based log targets are supported.
|
||
|
||
Args:
|
||
request: Incoming request.
|
||
_auth: Validated session — enforces authentication.
|
||
lines: Number of tail lines to return (1–2000, default 200).
|
||
filter: Optional plain-text substring — only matching lines returned.
|
||
|
||
Returns:
|
||
:class:`~app.models.config.Fail2BanLogResponse`.
|
||
|
||
Raises:
|
||
HTTPException: 400 when the log target is not a file or path is outside
|
||
the allowed directory.
|
||
HTTPException: 502 when fail2ban is unreachable.
|
||
"""
|
||
return await log_service.read_fail2ban_log(socket_path, lines, filter_)
|
||
|
||
|
||
@router.get(
|
||
"/service-status",
|
||
response_model=ServiceStatusResponse,
|
||
summary="Return fail2ban service health status with log configuration",
|
||
)
|
||
async def get_service_status(
|
||
_request: Request,
|
||
_auth: AuthDep,
|
||
socket_path: Fail2BanSocketDep,
|
||
) -> ServiceStatusResponse:
|
||
"""Return fail2ban service health and current log configuration.
|
||
|
||
Probes the fail2ban daemon to determine online/offline state, then
|
||
augments the result with the current log level and log target values.
|
||
|
||
Args:
|
||
request: Incoming request.
|
||
_auth: Validated session — enforces authentication.
|
||
|
||
Returns:
|
||
:class:`~app.models.config.ServiceStatusResponse`.
|
||
|
||
Raises:
|
||
HTTPException: 502 when fail2ban is unreachable (the service itself
|
||
handles this gracefully and returns ``online=False``).
|
||
"""
|
||
from app.services import health_service
|
||
|
||
return await health_service.get_service_status(
|
||
socket_path,
|
||
probe_fn=health_service.probe,
|
||
)
|