Files
BanGUI/backend/app/routers/config.py
Lukas d4d04491d2 Add Deactivate Jail button for inactive jails with local override
- Add has_local_override field to InactiveJail model
- Update _build_inactive_jail and list_inactive_jails to compute the field
- Add delete_jail_local_override() service function
- Add DELETE /api/config/jails/{name}/local router endpoint
- Surface has_local_override in frontend InactiveJail type
- Show Deactivate Jail button in JailsTab when has_local_override is true
- Add tests: TestBuildInactiveJail, TestListInactiveJails, TestDeleteJailLocalOverride
2026-03-15 13:41:00 +01:00

1693 lines
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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/jails/inactive`` — list all inactive jails
* ``POST /api/config/jails/{name}/activate`` — activate an inactive jail
* ``POST /api/config/jails/{name}/deactivate`` — deactivate an active jail
* ``POST /api/config/jails/{name}/validate`` — validate jail config pre-activation (Task 3)
* ``POST /api/config/jails/{name}/rollback`` — disable bad jail and restart fail2ban (Task 3)
* ``GET /api/config/pending-recovery`` — active crash-recovery record (Task 3)
* ``POST /api/config/jails/{name}/filter`` — assign a filter to a jail
* ``POST /api/config/jails/{name}/action`` — add an action to a jail
* ``DELETE /api/config/jails/{name}/action/{action_name}`` — remove an action from a jail
* ``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
* ``GET /api/config/filters`` — list all filters with active/inactive status
* ``GET /api/config/filters/{name}`` — full parsed detail for one filter
* ``PUT /api/config/filters/{name}`` — update a filter's .local override
* ``POST /api/config/filters`` — create a new user-defined filter
* ``DELETE /api/config/filters/{name}`` — delete a filter's .local file
* ``GET /api/config/actions`` — list all actions with active/inactive status
* ``GET /api/config/actions/{name}`` — full parsed detail for one action
* ``PUT /api/config/actions/{name}`` — update an action's .local override
* ``POST /api/config/actions`` — create a new user-defined action
* ``DELETE /api/config/actions/{name}`` — delete an action's .local file
* ``GET /api/config/fail2ban-log`` — read the tail of the fail2ban log file
* ``GET /api/config/service-status`` — fail2ban health + log configuration
"""
from __future__ import annotations
import datetime
from typing import Annotated
import structlog
from fastapi import APIRouter, HTTPException, Path, Query, Request, status
from app.dependencies import AuthDep
log: structlog.stdlib.BoundLogger = structlog.get_logger()
from app.models.config import (
ActionConfig,
ActionCreateRequest,
ActionListResponse,
ActionUpdateRequest,
ActivateJailRequest,
AddLogPathRequest,
AssignActionRequest,
AssignFilterRequest,
Fail2BanLogResponse,
FilterConfig,
FilterCreateRequest,
FilterListResponse,
FilterUpdateRequest,
GlobalConfigResponse,
GlobalConfigUpdate,
InactiveJailListResponse,
JailActivationResponse,
JailConfigListResponse,
JailConfigResponse,
JailConfigUpdate,
JailValidationResult,
LogPreviewRequest,
LogPreviewResponse,
MapColorThresholdsResponse,
MapColorThresholdsUpdate,
PendingRecovery,
RegexTestRequest,
RegexTestResponse,
RollbackResponse,
ServiceStatusResponse,
)
from app.services import config_file_service, config_service, jail_service
from app.services.config_file_service import (
ActionAlreadyExistsError,
ActionNameError,
ActionNotFoundError,
ActionReadonlyError,
ConfigWriteError,
FilterAlreadyExistsError,
FilterInvalidRegexError,
FilterNameError,
FilterNotFoundError,
FilterReadonlyError,
JailAlreadyActiveError,
JailAlreadyInactiveError,
JailNameError,
JailNotFoundInConfigError,
)
from app.services.config_service import (
ConfigOperationError,
ConfigValidationError,
JailNotFoundError,
)
from app.services.jail_service import JailOperationError
from app.tasks.health_check import _run_probe
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/inactive",
response_model=InactiveJailListResponse,
summary="List all inactive jails discovered in config files",
)
async def get_inactive_jails(
request: Request,
_auth: AuthDep,
) -> InactiveJailListResponse:
"""Return all jails defined in fail2ban config files that are not running.
Parses ``jail.conf``, ``jail.local``, and ``jail.d/`` following the
fail2ban merge order. Jails that fail2ban currently reports as running
are excluded; only truly inactive entries are returned.
Args:
request: FastAPI request object.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.config.InactiveJailListResponse`.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
return await config_file_service.list_inactive_jails(config_dir, socket_path)
@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: 409 when fail2ban reports the reload failed.
HTTPException: 502 when fail2ban is unreachable.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
await jail_service.reload_all(socket_path)
except JailOperationError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"fail2ban reload failed: {exc}",
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# 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,
) -> 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.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
start_cmd: str = request.app.state.settings.fail2ban_start_command
start_cmd_parts: list[str] = start_cmd.split()
# Step 1: stop the daemon via socket.
try:
await jail_service.restart(socket_path)
except JailOperationError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"fail2ban stop command failed: {exc}",
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# Step 2: start the daemon via subprocess.
await config_file_service.start_daemon(start_cmd_parts)
# Step 3: probe the socket until fail2ban is responsive or the budget expires.
fail2ban_running: bool = await config_file_service.wait_for_fail2ban(
socket_path, max_wait_seconds=10.0
)
if not fail2ban_running:
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 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,
)
@router.post(
"/jails/{name}/activate",
response_model=JailActivationResponse,
summary="Activate an inactive jail",
)
async def activate_jail(
request: Request,
_auth: AuthDep,
name: _NamePath,
body: ActivateJailRequest | None = None,
) -> JailActivationResponse:
"""Enable an inactive jail and reload fail2ban.
Writes ``enabled = true`` (plus any override values from the request
body) to ``jail.d/{name}.local`` and triggers a full fail2ban reload so
the jail starts immediately.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Name of the jail to activate.
body: Optional override values (bantime, findtime, maxretry, port,
logpath).
Returns:
:class:`~app.models.config.JailActivationResponse`.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if *name* is not found in any config file.
HTTPException: 409 if the jail is already active.
HTTPException: 502 if fail2ban is unreachable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
req = body if body is not None else ActivateJailRequest()
try:
result = await config_file_service.activate_jail(
config_dir, socket_path, name, req
)
except JailNameError as exc:
raise _bad_request(str(exc)) from exc
except JailNotFoundInConfigError:
raise _not_found(name) from None
except JailAlreadyActiveError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Jail {name!r} is already active.",
) from None
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write config override: {exc}",
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# Record this activation so the health-check task can attribute a
# subsequent fail2ban crash to it.
request.app.state.last_activation = {
"jail_name": name,
"at": datetime.datetime.now(tz=datetime.UTC),
}
# If fail2ban stopped responding after the reload, create a pending-recovery
# record immediately (before the background health task notices).
if not result.fail2ban_running:
request.app.state.pending_recovery = PendingRecovery(
jail_name=name,
activated_at=request.app.state.last_activation["at"],
detected_at=datetime.datetime.now(tz=datetime.UTC),
)
# Force an immediate health probe so the cached status reflects the current
# fail2ban state without waiting for the next scheduled check.
await _run_probe(request.app)
return result
@router.post(
"/jails/{name}/deactivate",
response_model=JailActivationResponse,
summary="Deactivate an active jail",
)
async def deactivate_jail(
request: Request,
_auth: AuthDep,
name: _NamePath,
) -> JailActivationResponse:
"""Disable an active jail and reload fail2ban.
Writes ``enabled = false`` to ``jail.d/{name}.local`` and triggers a
full fail2ban reload so the jail stops immediately.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Name of the jail to deactivate.
Returns:
:class:`~app.models.config.JailActivationResponse`.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if *name* is not found in any config file.
HTTPException: 409 if the jail is already inactive.
HTTPException: 502 if fail2ban is unreachable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
try:
result = await config_file_service.deactivate_jail(config_dir, socket_path, name)
except JailNameError as exc:
raise _bad_request(str(exc)) from exc
except JailNotFoundInConfigError:
raise _not_found(name) from None
except JailAlreadyInactiveError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Jail {name!r} is already inactive.",
) from None
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write config override: {exc}",
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# Force an immediate health probe so the cached status reflects the current
# fail2ban state (reload changes the active-jail count) without waiting for
# the next scheduled background check (up to 30 seconds).
await _run_probe(request.app)
return result
@router.delete(
"/jails/{name}/local",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete the jail.d override file for an inactive jail",
)
async def delete_jail_local_override(
request: Request,
_auth: AuthDep,
name: _NamePath,
) -> None:
"""Remove the ``jail.d/{name}.local`` override file for an inactive jail.
This endpoint is the clean-up action for inactive jails that still carry
a ``.local`` override file (e.g. one written with ``enabled = false`` by a
previous deactivation). The file is deleted without modifying fail2ban's
running state, since the jail is already inactive.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Name of the jail whose ``.local`` file should be removed.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if *name* is not found in any config file.
HTTPException: 409 if the jail is currently active.
HTTPException: 500 if the file cannot be deleted.
HTTPException: 502 if fail2ban is unreachable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
try:
await config_file_service.delete_jail_local_override(
config_dir, socket_path, name
)
except JailNameError as exc:
raise _bad_request(str(exc)) from exc
except JailNotFoundInConfigError:
raise _not_found(name) from None
except JailAlreadyActiveError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Jail {name!r} is currently active; deactivate it first.",
) from None
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete config override: {exc}",
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# ---------------------------------------------------------------------------
# Jail validation & rollback endpoints (Task 3)
# ---------------------------------------------------------------------------
@router.post(
"/jails/{name}/validate",
response_model=JailValidationResult,
summary="Validate jail configuration before activation",
)
async def validate_jail(
request: Request,
_auth: AuthDep,
name: _NamePath,
) -> JailValidationResult:
"""Run pre-activation validation checks on a jail configuration.
Validates filter and action file existence, regex pattern compilation, and
log path existence without modifying any files or reloading fail2ban.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Jail name to validate.
Returns:
:class:`~app.models.config.JailValidationResult` with any issues found.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if *name* is not found in any config file.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
return await config_file_service.validate_jail_config(config_dir, name)
except JailNameError as exc:
raise _bad_request(str(exc)) from exc
@router.get(
"/pending-recovery",
response_model=PendingRecovery | None,
summary="Return active crash-recovery record if one exists",
)
async def get_pending_recovery(
request: Request,
_auth: AuthDep,
) -> PendingRecovery | None:
"""Return the current :class:`~app.models.config.PendingRecovery` record.
A non-null response means fail2ban crashed shortly after a jail activation
and the user should be offered a rollback option. Returns ``null`` (HTTP
200 with ``null`` body) when no recovery is pending.
Args:
request: FastAPI request object.
_auth: Validated session.
Returns:
:class:`~app.models.config.PendingRecovery` or ``None``.
"""
return getattr(request.app.state, "pending_recovery", None)
@router.post(
"/jails/{name}/rollback",
response_model=RollbackResponse,
summary="Disable a bad jail config and restart fail2ban",
)
async def rollback_jail(
request: Request,
_auth: AuthDep,
name: _NamePath,
) -> RollbackResponse:
"""Disable the specified jail and attempt to restart fail2ban.
Writes ``enabled = false`` to ``jail.d/{name}.local`` (works even when
fail2ban is down — no socket is needed), then runs the configured start
command and waits up to ten seconds for the daemon to come back online.
On success, clears the :class:`~app.models.config.PendingRecovery` record.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Jail name to disable and roll back.
Returns:
:class:`~app.models.config.RollbackResponse`.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 500 if writing the .local override file fails.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
start_cmd: str = request.app.state.settings.fail2ban_start_command
start_cmd_parts: list[str] = start_cmd.split()
try:
result = await config_file_service.rollback_jail(
config_dir, socket_path, name, start_cmd_parts
)
except JailNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write config override: {exc}",
) from exc
# Clear pending recovery if fail2ban came back online.
if result.fail2ban_running:
request.app.state.pending_recovery = None
request.app.state.last_activation = None
return result
# ---------------------------------------------------------------------------
# Filter discovery endpoints (Task 2.1)
# ---------------------------------------------------------------------------
@router.get(
"/filters",
response_model=FilterListResponse,
summary="List all available filters with active/inactive status",
)
async def list_filters(
request: Request,
_auth: AuthDep,
) -> FilterListResponse:
"""Return all filters discovered in ``filter.d/`` with active/inactive status.
Scans ``{config_dir}/filter.d/`` for ``.conf`` files, merges any
corresponding ``.local`` overrides, and cross-references each filter's
name against the ``filter`` fields of currently running jails to determine
whether it is active.
Active filters (those used by at least one running jail) are sorted to the
top of the list; inactive filters follow. Both groups are sorted
alphabetically within themselves.
Args:
request: FastAPI request object.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.config.FilterListResponse` with all discovered
filters.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
result = await config_file_service.list_filters(config_dir, socket_path)
# Sort: active first (by name), then inactive (by name).
result.filters.sort(key=lambda f: (not f.active, f.name.lower()))
return result
@router.get(
"/filters/{name}",
response_model=FilterConfig,
summary="Return full parsed detail for a single filter",
)
async def get_filter(
request: Request,
_auth: AuthDep,
name: Annotated[str, Path(description="Filter base name, e.g. ``sshd`` or ``sshd.conf``.")],
) -> FilterConfig:
"""Return the full parsed configuration and active/inactive status for one filter.
Reads ``{config_dir}/filter.d/{name}.conf``, merges any corresponding
``.local`` override, and annotates the result with ``active``,
``used_by_jails``, ``source_file``, and ``has_local_override``.
Args:
request: FastAPI request object.
_auth: Validated session — enforces authentication.
name: Filter base name (with or without ``.conf`` extension).
Returns:
:class:`~app.models.config.FilterConfig`.
Raises:
HTTPException: 404 if the filter is not found in ``filter.d/``.
HTTPException: 502 if fail2ban is unreachable.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_file_service.get_filter(config_dir, socket_path, name)
except FilterNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Filter not found: {name!r}",
) from None
# ---------------------------------------------------------------------------
# Filter write endpoints (Task 2.2)
# ---------------------------------------------------------------------------
_FilterNamePath = Annotated[
str,
Path(description="Filter base name, e.g. ``sshd`` or ``sshd.conf``."),
]
def _filter_not_found(name: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Filter not found: {name!r}",
)
@router.put(
"/filters/{name}",
response_model=FilterConfig,
summary="Update a filter's .local override with new regex/pattern values",
)
async def update_filter(
request: Request,
_auth: AuthDep,
name: _FilterNamePath,
body: FilterUpdateRequest,
reload: bool = Query(default=False, description="Reload fail2ban after writing."),
) -> FilterConfig:
"""Update a filter's ``[Definition]`` fields by writing a ``.local`` override.
All regex patterns are validated before writing. The original ``.conf``
file is never modified. Fields left as ``null`` in the request body are
kept at their current values.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Filter base name (with or without ``.conf`` extension).
body: Partial update — ``failregex``, ``ignoreregex``, ``datepattern``,
``journalmatch``.
reload: When ``true``, trigger a fail2ban reload after writing.
Returns:
Updated :class:`~app.models.config.FilterConfig`.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if the filter does not exist.
HTTPException: 422 if any regex pattern fails to compile.
HTTPException: 500 if writing the ``.local`` file fails.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_file_service.update_filter(
config_dir, socket_path, name, body, do_reload=reload
)
except FilterNameError as exc:
raise _bad_request(str(exc)) from exc
except FilterNotFoundError:
raise _filter_not_found(name) from None
except FilterInvalidRegexError as exc:
raise _unprocessable(str(exc)) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write filter override: {exc}",
) from exc
@router.post(
"/filters",
response_model=FilterConfig,
status_code=status.HTTP_201_CREATED,
summary="Create a new user-defined filter",
)
async def create_filter(
request: Request,
_auth: AuthDep,
body: FilterCreateRequest,
reload: bool = Query(default=False, description="Reload fail2ban after creating."),
) -> FilterConfig:
"""Create a new user-defined filter at ``filter.d/{name}.local``.
The filter is created as a ``.local`` file so it can coexist safely with
shipped ``.conf`` files. Returns 409 if a ``.conf`` or ``.local`` for
the requested name already exists.
Args:
request: FastAPI request object.
_auth: Validated session.
body: Filter name and ``[Definition]`` fields.
reload: When ``true``, trigger a fail2ban reload after creating.
Returns:
:class:`~app.models.config.FilterConfig` for the new filter.
Raises:
HTTPException: 400 if the name contains invalid characters.
HTTPException: 409 if the filter already exists.
HTTPException: 422 if any regex pattern is invalid.
HTTPException: 500 if writing fails.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_file_service.create_filter(
config_dir, socket_path, body, do_reload=reload
)
except FilterNameError as exc:
raise _bad_request(str(exc)) from exc
except FilterAlreadyExistsError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Filter {exc.name!r} already exists.",
) from exc
except FilterInvalidRegexError as exc:
raise _unprocessable(str(exc)) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write filter: {exc}",
) from exc
@router.delete(
"/filters/{name}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a user-created filter's .local file",
)
async def delete_filter(
request: Request,
_auth: AuthDep,
name: _FilterNamePath,
) -> None:
"""Delete a user-created filter's ``.local`` override file.
Shipped ``.conf``-only filters cannot be deleted (returns 409). When
both a ``.conf`` and a ``.local`` exist, only the ``.local`` is removed.
When only a ``.local`` exists (user-created filter), the file is deleted
entirely.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Filter base name.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if the filter does not exist.
HTTPException: 409 if the filter is a shipped default (conf-only).
HTTPException: 500 if deletion fails.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
await config_file_service.delete_filter(config_dir, name)
except FilterNameError as exc:
raise _bad_request(str(exc)) from exc
except FilterNotFoundError:
raise _filter_not_found(name) from None
except FilterReadonlyError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete filter: {exc}",
) from exc
@router.post(
"/jails/{name}/filter",
status_code=status.HTTP_204_NO_CONTENT,
summary="Assign a filter to a jail",
)
async def assign_filter_to_jail(
request: Request,
_auth: AuthDep,
name: _NamePath,
body: AssignFilterRequest,
reload: bool = Query(default=False, description="Reload fail2ban after assigning."),
) -> None:
"""Write ``filter = {filter_name}`` to the jail's ``.local`` config.
Existing keys in the jail's ``.local`` file are preserved. If the file
does not exist it is created.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Jail name.
body: Filter to assign.
reload: When ``true``, trigger a fail2ban reload after writing.
Raises:
HTTPException: 400 if *name* or *filter_name* contain invalid characters.
HTTPException: 404 if the jail or filter does not exist.
HTTPException: 500 if writing fails.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
try:
await config_file_service.assign_filter_to_jail(
config_dir, socket_path, name, body, do_reload=reload
)
except (JailNameError, FilterNameError) as exc:
raise _bad_request(str(exc)) from exc
except JailNotFoundInConfigError:
raise _not_found(name) from None
except FilterNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Filter not found: {exc.name!r}",
) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write jail override: {exc}",
) from exc
# ---------------------------------------------------------------------------
# Action discovery endpoints (Task 3.1)
# ---------------------------------------------------------------------------
_ActionNamePath = Annotated[
str,
Path(description="Action base name, e.g. ``iptables`` or ``iptables.conf``."),
]
def _action_not_found(name: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Action not found: {name!r}",
)
@router.get(
"/actions",
response_model=ActionListResponse,
summary="List all available actions with active/inactive status",
)
async def list_actions(
request: Request,
_auth: AuthDep,
) -> ActionListResponse:
"""Return all actions discovered in ``action.d/`` with active/inactive status.
Scans ``{config_dir}/action.d/`` for ``.conf`` files, merges any
corresponding ``.local`` overrides, and cross-references each action's
name against the ``action`` fields of currently running jails to determine
whether it is active.
Active actions (those used by at least one running jail) are sorted to the
top of the list; inactive actions follow. Both groups are sorted
alphabetically within themselves.
Args:
request: FastAPI request object.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.config.ActionListResponse` with all discovered
actions.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
result = await config_file_service.list_actions(config_dir, socket_path)
result.actions.sort(key=lambda a: (not a.active, a.name.lower()))
return result
@router.get(
"/actions/{name}",
response_model=ActionConfig,
summary="Return full parsed detail for a single action",
)
async def get_action(
request: Request,
_auth: AuthDep,
name: _ActionNamePath,
) -> ActionConfig:
"""Return the full parsed configuration and active/inactive status for one action.
Reads ``{config_dir}/action.d/{name}.conf``, merges any corresponding
``.local`` override, and annotates the result with ``active``,
``used_by_jails``, ``source_file``, and ``has_local_override``.
Args:
request: FastAPI request object.
_auth: Validated session — enforces authentication.
name: Action base name (with or without ``.conf`` extension).
Returns:
:class:`~app.models.config.ActionConfig`.
Raises:
HTTPException: 404 if the action is not found in ``action.d/``.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_file_service.get_action(config_dir, socket_path, name)
except ActionNotFoundError:
raise _action_not_found(name) from None
# ---------------------------------------------------------------------------
# Action write endpoints (Task 3.2)
# ---------------------------------------------------------------------------
@router.put(
"/actions/{name}",
response_model=ActionConfig,
summary="Update an action's .local override with new lifecycle command values",
)
async def update_action(
request: Request,
_auth: AuthDep,
name: _ActionNamePath,
body: ActionUpdateRequest,
reload: bool = Query(default=False, description="Reload fail2ban after writing."),
) -> ActionConfig:
"""Update an action's ``[Definition]`` fields by writing a ``.local`` override.
Only non-``null`` fields in the request body are written. The original
``.conf`` file is never modified.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Action base name (with or without ``.conf`` extension).
body: Partial update — lifecycle commands and ``[Init]`` parameters.
reload: When ``true``, trigger a fail2ban reload after writing.
Returns:
Updated :class:`~app.models.config.ActionConfig`.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if the action does not exist.
HTTPException: 500 if writing the ``.local`` file fails.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_file_service.update_action(
config_dir, socket_path, name, body, do_reload=reload
)
except ActionNameError as exc:
raise _bad_request(str(exc)) from exc
except ActionNotFoundError:
raise _action_not_found(name) from None
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write action override: {exc}",
) from exc
@router.post(
"/actions",
response_model=ActionConfig,
status_code=status.HTTP_201_CREATED,
summary="Create a new user-defined action",
)
async def create_action(
request: Request,
_auth: AuthDep,
body: ActionCreateRequest,
reload: bool = Query(default=False, description="Reload fail2ban after creating."),
) -> ActionConfig:
"""Create a new user-defined action at ``action.d/{name}.local``.
Returns 409 if a ``.conf`` or ``.local`` for the requested name already
exists.
Args:
request: FastAPI request object.
_auth: Validated session.
body: Action name and ``[Definition]`` lifecycle fields.
reload: When ``true``, trigger a fail2ban reload after creating.
Returns:
:class:`~app.models.config.ActionConfig` for the new action.
Raises:
HTTPException: 400 if the name contains invalid characters.
HTTPException: 409 if the action already exists.
HTTPException: 500 if writing fails.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_file_service.create_action(
config_dir, socket_path, body, do_reload=reload
)
except ActionNameError as exc:
raise _bad_request(str(exc)) from exc
except ActionAlreadyExistsError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Action {exc.name!r} already exists.",
) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write action: {exc}",
) from exc
@router.delete(
"/actions/{name}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a user-created action's .local file",
)
async def delete_action(
request: Request,
_auth: AuthDep,
name: _ActionNamePath,
) -> None:
"""Delete a user-created action's ``.local`` override file.
Shipped ``.conf``-only actions cannot be deleted (returns 409). When
both a ``.conf`` and a ``.local`` exist, only the ``.local`` is removed.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Action base name.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if the action does not exist.
HTTPException: 409 if the action is a shipped default (conf-only).
HTTPException: 500 if deletion fails.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
await config_file_service.delete_action(config_dir, name)
except ActionNameError as exc:
raise _bad_request(str(exc)) from exc
except ActionNotFoundError:
raise _action_not_found(name) from None
except ActionReadonlyError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete action: {exc}",
) from exc
@router.post(
"/jails/{name}/action",
status_code=status.HTTP_204_NO_CONTENT,
summary="Add an action to a jail",
)
async def assign_action_to_jail(
request: Request,
_auth: AuthDep,
name: _NamePath,
body: AssignActionRequest,
reload: bool = Query(default=False, description="Reload fail2ban after assigning."),
) -> None:
"""Append an action entry to the jail's ``.local`` config.
Existing keys in the jail's ``.local`` file are preserved. If the file
does not exist it is created. The action is not duplicated if it is
already present.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Jail name.
body: Action to add plus optional per-jail parameters.
reload: When ``true``, trigger a fail2ban reload after writing.
Raises:
HTTPException: 400 if *name* or *action_name* contain invalid characters.
HTTPException: 404 if the jail or action does not exist.
HTTPException: 500 if writing fails.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
try:
await config_file_service.assign_action_to_jail(
config_dir, socket_path, name, body, do_reload=reload
)
except (JailNameError, ActionNameError) as exc:
raise _bad_request(str(exc)) from exc
except JailNotFoundInConfigError:
raise _not_found(name) from None
except ActionNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Action not found: {exc.name!r}",
) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write jail override: {exc}",
) from exc
@router.delete(
"/jails/{name}/action/{action_name}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Remove an action from a jail",
)
async def remove_action_from_jail(
request: Request,
_auth: AuthDep,
name: _NamePath,
action_name: Annotated[str, Path(description="Action base name to remove.")],
reload: bool = Query(default=False, description="Reload fail2ban after removing."),
) -> None:
"""Remove an action from the jail's ``.local`` config.
If the jail has no ``.local`` file or the action is not listed there,
the call is silently idempotent.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Jail name.
action_name: Base name of the action to remove.
reload: When ``true``, trigger a fail2ban reload after writing.
Raises:
HTTPException: 400 if *name* or *action_name* contain invalid characters.
HTTPException: 404 if the jail is not found in config files.
HTTPException: 500 if writing fails.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
try:
await config_file_service.remove_action_from_jail(
config_dir, socket_path, name, action_name, do_reload=reload
)
except (JailNameError, ActionNameError) as exc:
raise _bad_request(str(exc)) from exc
except JailNotFoundInConfigError:
raise _not_found(name) from None
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write jail override: {exc}",
) from exc
# ---------------------------------------------------------------------------
# fail2ban log viewer endpoints
# ---------------------------------------------------------------------------
@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,
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(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 (12000, 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.
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_service.read_fail2ban_log(socket_path, lines, filter)
except config_service.ConfigOperationError as exc:
raise _bad_request(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@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,
) -> 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``).
"""
socket_path: str = request.app.state.settings.fail2ban_socket
try:
return await config_service.get_service_status(socket_path)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc