Files
BanGUI/backend/app/routers/jail_config.py

764 lines
23 KiB
Python

from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, HTTPException, Path, Query, Request, status
from app.dependencies import (
AppDep,
AuthDep,
Fail2BanConfigDirDep,
Fail2BanSocketDep,
Fail2BanStartCommandDep,
PendingRecoveryDep,
)
from app.exceptions import (
ActionNameError,
ActionNotFoundError,
ConfigOperationError,
ConfigValidationError,
ConfigWriteError,
FilterNameError,
FilterNotFoundError,
JailAlreadyActiveError,
JailAlreadyInactiveError,
JailNameError,
JailNotFoundError,
JailNotFoundInConfigError,
)
from app.models.config import (
ActivateJailRequest,
AddLogPathRequest,
AssignActionRequest,
AssignFilterRequest,
InactiveJailListResponse,
JailActivationResponse,
JailConfigListResponse,
JailConfigResponse,
JailConfigUpdate,
JailValidationResult,
PendingRecovery,
RollbackResponse,
)
from app.services import (
action_config_service,
config_service,
filter_config_service,
jail_config_service,
)
from app.utils.fail2ban_client import Fail2BanConnectionError
from app.utils.runtime_state import (
clear_activation_record,
clear_pending_recovery,
create_pending_recovery,
record_activation,
)
router: APIRouter = APIRouter(prefix="/jails", tags=["Jail Config"])
_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,
)
def _filter_not_found(name: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Filter not found: {name!r}",
)
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(
"",
response_model=JailConfigListResponse,
summary="List configuration for all active jails",
)
async def get_jail_configs(
request: Request,
_auth: AuthDep,
socket_path: Fail2BanSocketDep,
) -> 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`.
"""
try:
return await config_service.list_jail_configs(socket_path)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.get(
"/inactive",
response_model=InactiveJailListResponse,
summary="List all inactive jails discovered in config files",
)
async def get_inactive_jails(
request: Request,
_auth: AuthDep,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
) -> 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`.
"""
return await jail_config_service.list_inactive_jails(config_dir, socket_path)
@router.get(
"/pending-recovery",
response_model=PendingRecovery | None,
summary="Return active crash-recovery record if one exists",
)
async def get_pending_recovery(
_auth: AuthDep,
pending_recovery: PendingRecoveryDep,
) -> 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 pending_recovery
@router.get(
"/{name}",
response_model=JailConfigResponse,
summary="Return configuration for a single jail",
)
async def get_jail_config(
request: Request,
_auth: AuthDep,
socket_path: Fail2BanSocketDep,
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.
"""
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(
"/{name}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update jail configuration",
)
async def update_jail_config(
request: Request,
_auth: AuthDep,
socket_path: Fail2BanSocketDep,
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.
"""
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.post(
"/{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,
socket_path: Fail2BanSocketDep,
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.
"""
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(
"/{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,
socket_path: Fail2BanSocketDep,
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.
"""
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(
"/{name}/activate",
response_model=JailActivationResponse,
summary="Activate an inactive jail",
)
async def activate_jail(
app: AppDep,
_auth: AuthDep,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
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:
app: FastAPI application instance.
_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.
"""
req = body if body is not None else ActivateJailRequest()
activation_time = record_activation(app, name)
try:
result = await jail_config_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
return result
@router.post(
"/{name}/deactivate",
response_model=JailActivationResponse,
summary="Deactivate an active jail",
)
async def deactivate_jail(
_auth: AuthDep,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
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:
_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.
"""
try:
result = await jail_config_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
return result
@router.delete(
"/{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,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
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.
"""
try:
await jail_config_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(
"/{name}/validate",
response_model=JailValidationResult,
summary="Validate jail configuration before activation",
)
async def validate_jail(
request: Request,
_auth: AuthDep,
config_dir: Fail2BanConfigDirDep,
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.
"""
try:
return await jail_config_service.validate_jail_config(config_dir, name)
except JailNameError as exc:
raise _bad_request(str(exc)) from exc
@router.post(
"/{name}/rollback",
response_model=RollbackResponse,
summary="Disable a bad jail config and restart fail2ban",
)
async def rollback_jail(
_auth: AuthDep,
app: AppDep,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
start_cmd: Fail2BanStartCommandDep,
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:
_auth: Validated session.
app: FastAPI application instance.
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.
"""
start_cmd_parts: list[str] = start_cmd.split()
try:
result = await jail_config_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
if result.fail2ban_running:
clear_pending_recovery(app)
clear_activation_record(app)
return result
@router.post(
"/{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,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
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.
"""
try:
await filter_config_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
@router.post(
"/{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,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
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.
"""
try:
await action_config_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(
"/{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,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
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.
"""
try:
await action_config_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
# ---------------------------------------------------------------------------
# Filter discovery endpoints (Task 2.1)
# ---------------------------------------------------------------------------