Files
BanGUI/backend/app/routers/config.py
Lukas ea35695221 Add better jail configuration: file CRUD, enable/disable, log paths
Task 4 (Better Jail Configuration) implementation:
- Add fail2ban_config_dir setting to app/config.py
- New file_config_service: list/view/edit/create jail.d, filter.d, action.d files
  with path-traversal prevention and 512 KB content size limit
- New file_config router: GET/PUT/POST endpoints for jail files, filter files,
  and action files; PUT .../enabled for toggle on/off
- Extend config_service with delete_log_path() and add_log_path()
- Add DELETE /api/config/jails/{name}/logpath and POST /api/config/jails/{name}/logpath
- Extend geo router with re-resolve endpoint; add geo_re_resolve background task
- Update blocklist_service with revised scheduling helpers
- Update Docker compose files with BANGUI_FAIL2BAN_CONFIG_DIR env var and
  rw volume mount for the fail2ban config directory
- Frontend: new Jail Files, Filters, Actions tabs in ConfigPage; file editor
  with accordion-per-file, editable textarea, save/create; add/delete log paths
- Frontend: types in types/config.ts; API calls in api/config.ts and api/endpoints.ts
- 63 new backend tests (test_file_config_service, test_file_config, test_geo_re_resolve)
- 6 new frontend tests in ConfigPageLogPath.test.tsx
- ruff, mypy --strict, tsc --noEmit, eslint: all clean; 617 backend tests pass
2026-03-12 20:08:33 +01:00

498 lines
15 KiB
Python

"""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, Query, Request, status
from app.dependencies import AuthDep
from app.models.config import (
AddLogPathRequest,
GlobalConfigResponse,
GlobalConfigUpdate,
JailConfigListResponse,
JailConfigResponse,
JailConfigUpdate,
LogPreviewRequest,
LogPreviewResponse,
MapColorThresholdsResponse,
MapColorThresholdsUpdate,
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
@router.delete(
"/jails/{name}/logpath",
status_code=status.HTTP_204_NO_CONTENT,
summary="Remove a monitored log path from a jail",
)
async def delete_log_path(
request: Request,
_auth: AuthDep,
name: _NamePath,
log_path: str = Query(..., description="Absolute path of the log file to stop monitoring."),
) -> None:
"""Stop a jail from monitoring the specified log file.
Uses ``set <jail> dellogpath <path>`` to remove the log path at runtime
without requiring a daemon restart.
Args:
request: Incoming request.
_auth: Validated session.
name: Jail name.
log_path: Absolute path to the log file to remove (query parameter).
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.delete_log_path(socket_path, name, log_path)
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
@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)
# ---------------------------------------------------------------------------
# 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,
) -> MapColorThresholdsResponse:
"""Return the configured map color thresholds.
Args:
request: FastAPI request object.
_auth: Validated session.
Returns:
:class:`~app.models.config.MapColorThresholdsResponse` with
current thresholds.
"""
from app.services import setup_service
high, medium, low = await setup_service.get_map_color_thresholds(
request.app.state.db
)
return MapColorThresholdsResponse(
threshold_high=high,
threshold_medium=medium,
threshold_low=low,
)
@router.put(
"/map-color-thresholds",
response_model=MapColorThresholdsResponse,
summary="Update map color threshold configuration",
)
async def update_map_color_thresholds(
request: Request,
_auth: AuthDep,
body: MapColorThresholdsUpdate,
) -> MapColorThresholdsResponse:
"""Update the map color threshold configuration.
Args:
request: FastAPI request object.
_auth: Validated session.
body: New threshold values.
Returns:
:class:`~app.models.config.MapColorThresholdsResponse` with
updated thresholds.
Raises:
HTTPException: 400 if validation fails (thresholds not
properly ordered).
"""
from app.services import setup_service
try:
await setup_service.set_map_color_thresholds(
request.app.state.db,
threshold_high=body.threshold_high,
threshold_medium=body.threshold_medium,
threshold_low=body.threshold_low,
)
except ValueError as exc:
raise _bad_request(str(exc)) from exc
return MapColorThresholdsResponse(
threshold_high=body.threshold_high,
threshold_medium=body.threshold_medium,
threshold_low=body.threshold_low,
)