Files
BanGUI/backend/app/routers/jail_config.py
Lukas 8698b89f6a TASK-010: Replace .split() with shlex.split() for fail2ban_start_command
- Add @field_validator for fail2ban_start_command to validate with shlex.split()
  at startup, catching misconfigured commands with mismatched quotes
- Replace .split() with shlex.split() in jail_config.py line 450
- Replace .split() with shlex.split() in config_misc.py line 154
- Update Backend-Development.md with configuration documentation explaining
  quoted path handling and common pitfalls
- Add comprehensive test suite (8 tests) covering valid commands, quoted paths,
  and mismatched quote errors

This fix ensures commands like '/opt/my tools/fail2ban-client' start are
correctly parsed as two tokens instead of three, preventing execution failures
when the path contains spaces.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-26 13:04:14 +02:00

574 lines
17 KiB
Python

from __future__ import annotations
import shlex
from typing import Annotated
from fastapi import APIRouter, Path, Query, Request, status
from app.dependencies import (
AppDep,
AuthDep,
Fail2BanConfigDirDep,
Fail2BanSocketDep,
Fail2BanStartCommandDep,
PendingRecoveryDep,
)
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.runtime_state import (
clear_activation_record,
clear_pending_recovery,
record_activation,
)
router: APIRouter = APIRouter(prefix="/jails", tags=["Jail Config"])
_NamePath = Annotated[str, Path(description='Jail name as configured in fail2ban.')]
@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`.
"""
return await config_service.list_jail_configs(socket_path)
@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.
"""
return await config_service.get_jail_config(socket_path, name)
@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.
"""
await config_service.update_jail_config(socket_path, name, body)
# ---------------------------------------------------------------------------
# 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.
"""
await config_service.add_log_path(socket_path, name, body)
@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.
"""
await config_service.delete_log_path(socket_path, name, log_path)
@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()
result = await jail_config_service.activate_jail(config_dir, socket_path, name, req)
if result.active:
record_activation(app, name)
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.
"""
result = await jail_config_service.deactivate_jail(config_dir, socket_path, name)
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.
"""
await jail_config_service.delete_jail_local_override(config_dir, socket_path, name)
# ---------------------------------------------------------------------------
# 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.
"""
return await jail_config_service.validate_jail_config(config_dir, name)
@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] = shlex.split(start_cmd)
result = await jail_config_service.rollback_jail(config_dir, socket_path, name, start_cmd_parts)
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.
"""
await filter_config_service.assign_filter_to_jail(config_dir, socket_path, name, body, do_reload=reload)
@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.
"""
await action_config_service.assign_action_to_jail(config_dir, socket_path, name, body, do_reload=reload)
@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.
"""
await action_config_service.remove_action_from_jail(
config_dir,
socket_path,
name,
action_name,
do_reload=reload,
)
# ---------------------------------------------------------------------------
# Filter discovery endpoints (Task 2.1)
# ---------------------------------------------------------------------------