This commit enforces the repository boundary by eliminating direct database connection dependencies (DbDep) from all routers. Routers now depend on service context dependencies that combine the database connection with the related repositories. Changes: - Add 5 service context dependencies in dependencies.py: * SessionServiceContext: db + session_repo * BlocklistServiceContext: db + blocklist_repo + import_log_repo + settings_repo * SettingsServiceContext: db + settings_repo * BanServiceContext: db + fail2ban_db_repo * HistoryServiceContext: db + fail2ban_db_repo + history_archive_repo - Refactor all 9 routers (auth, bans, blocklist, config_misc, dashboard, geo, history, jails, setup) to use service contexts instead of DbDep. - Update Backend-Development.md with clear examples of the new pattern and documentation of available service contexts. Rationale: - Enforces the repository boundary through the dependency system - Makes database operations explicit and auditable - Improves testability by allowing service contexts to be mocked - Prevents accidental direct database access from routers The deprecated DbDep remains available for backward compatibility with services that have not yet been refactored, but routers can no longer import it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
373 lines
11 KiB
Python
373 lines
11 KiB
Python
from __future__ import annotations
|
||
|
||
import shlex
|
||
from typing import Annotated
|
||
|
||
import structlog
|
||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||
|
||
from app.dependencies import (
|
||
AuthDep,
|
||
Fail2BanSocketDep,
|
||
Fail2BanStartCommandDep,
|
||
SettingsServiceContextDep,
|
||
)
|
||
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"])
|
||
|
||
|
||
@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.
|
||
HTTPException: 502 when fail2ban is unreachable.
|
||
"""
|
||
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 HTTPException(
|
||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail=(
|
||
"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,
|
||
)
|