feat(stage-1): inactive jail discovery and activation

- Backend: config_file_service.py parses jail.conf/jail.local/jail.d/*
  following fail2ban merge order; discovers jails not running in fail2ban
- Backend: 3 new API endpoints (GET /jails/inactive, POST /jails/{name}/activate,
  POST /jails/{name}/deactivate); moved /jails/inactive before /jails/{name}
  to fix route-ordering conflict
- Frontend: ActivateJailDialog component with optional parameter overrides
- Frontend: JailsTab extended with inactive jail list and InactiveJailDetail pane
- Frontend: JailsPage JailOverviewSection shows inactive jails with toggle
- Tests: 57 service tests + 16 router tests for all new endpoints (all pass)
- Docs: Features.md, Architekture.md, Tasks.md updated; Tasks 1.1-1.5 marked done
This commit is contained in:
2026-03-13 15:44:36 +01:00
parent a344f1035b
commit 8d9d63b866
15 changed files with 2711 additions and 182 deletions

View File

@@ -3,15 +3,18 @@
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
* ``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
* ``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
@@ -22,9 +25,12 @@ from fastapi import APIRouter, HTTPException, Path, Query, Request, status
from app.dependencies import AuthDep
from app.models.config import (
ActivateJailRequest,
AddLogPathRequest,
GlobalConfigResponse,
GlobalConfigUpdate,
InactiveJailListResponse,
JailActivationResponse,
JailConfigListResponse,
JailConfigResponse,
JailConfigUpdate,
@@ -35,7 +41,14 @@ from app.models.config import (
RegexTestRequest,
RegexTestResponse,
)
from app.services import config_service, jail_service
from app.services import config_file_service, config_service, jail_service
from app.services.config_file_service import (
ConfigWriteError,
JailAlreadyActiveError,
JailAlreadyInactiveError,
JailNameError,
JailNotFoundInConfigError,
)
from app.services.config_service import (
ConfigOperationError,
ConfigValidationError,
@@ -113,6 +126,33 @@ async def get_jail_configs(
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,
@@ -495,3 +535,114 @@ async def update_map_color_thresholds(
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:
return 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
@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:
return 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