Files
BanGUI/backend/app/routers/jail_config.py
Lukas 8f26776bb3 docs: add OpenAPI responses={} to all router endpoints
Add explicit HTTP status code documentation to every endpoint
across 15 router files. Each endpoint now declares all possible
response codes (200/201/204/400/401/404/409/429/502/503) with
descriptions so frontend can distinguish error types.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 01:12:08 +02:00

878 lines
29 KiB
Python

from __future__ import annotations
import shlex
from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query, Request, status
from app.dependencies import (
AppDep,
AuthDep,
Fail2BanConfigDirDep,
Fail2BanSocketDep,
Fail2BanStartCommandDep,
GlobalRateLimiterDep,
HealthProbeDep,
PendingRecoveryDep,
)
from app.exceptions import BadRequestError
from app.mappers import config_mappers
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.path_utils import validate_log_path
from app.utils.constants import (
RATE_LIMIT_JAIL_ACTIVATE_REQUESTS,
RATE_LIMIT_JAIL_CREATE_REQUESTS,
RATE_LIMIT_JAIL_DEACTIVATE_REQUESTS,
RATE_LIMIT_JAIL_DELETE_REQUESTS,
RATE_LIMIT_JAIL_UPDATE_REQUESTS,
)
from app.utils.runtime_state import (
clear_activation_record,
clear_pending_recovery,
record_activation,
)
router: APIRouter = APIRouter(prefix="/jails", tags=["Jail Config"])
_MINUTE = 60
_JAIL_UPDATE_BUCKET = "jail:update"
_JAIL_CREATE_BUCKET = "jail:create"
_JAIL_DELETE_BUCKET = "jail:delete"
_JAIL_ACTIVATE_BUCKET = "jail:activate"
_JAIL_DEACTIVATE_BUCKET = "jail:deactivate"
def _check_jail_update_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for jail update operations."""
from app.utils.client_ip import get_client_ip
settings = request.app.state.settings
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
_JAIL_UPDATE_BUCKET, client_ip, RATE_LIMIT_JAIL_UPDATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"jail_update_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for jail update operations. Please try again later.",
retry_after_seconds=retry_after,
)
def _check_jail_create_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for jail create operations."""
from app.utils.client_ip import get_client_ip
settings = request.app.state.settings
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
_JAIL_CREATE_BUCKET, client_ip, RATE_LIMIT_JAIL_CREATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"jail_create_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for jail create operations. Please try again later.",
retry_after_seconds=retry_after,
)
def _check_jail_delete_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for jail delete operations."""
from app.utils.client_ip import get_client_ip
settings = request.app.state.settings
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
_JAIL_DELETE_BUCKET, client_ip, RATE_LIMIT_JAIL_DELETE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"jail_delete_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for jail delete operations. Please try again later.",
retry_after_seconds=retry_after,
)
def _check_jail_activate_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for jail activate operations."""
from app.utils.client_ip import get_client_ip
settings = request.app.state.settings
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
_JAIL_ACTIVATE_BUCKET, client_ip, RATE_LIMIT_JAIL_ACTIVATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"jail_activate_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for jail activate operations. Please try again later.",
retry_after_seconds=retry_after,
)
def _check_jail_deactivate_rate_limit(
request: Request,
rate_limiter: GlobalRateLimiterDep,
) -> None:
"""Check rate limit for jail deactivate operations."""
from app.utils.client_ip import get_client_ip
settings = request.app.state.settings
client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
_JAIL_DEACTIVATE_BUCKET, client_ip, RATE_LIMIT_JAIL_DEACTIVATE_REQUESTS, _MINUTE
)
if not is_allowed:
from app.exceptions import RateLimitError
import structlog
log = structlog.get_logger()
log.warning(
"jail_deactivate_rate_limit_exceeded",
client_ip=client_ip,
path=request.url.path,
retry_after=retry_after,
)
raise RateLimitError(
"Rate limit exceeded for jail deactivate operations. Please try again later.",
retry_after_seconds=retry_after,
)
_NamePath = Annotated[str, Path(description='Jail name as configured in fail2ban.')]
@router.get(
"",
response_model=JailConfigListResponse,
summary="List configuration for all active jails",
responses={
200: {"description": "Jail configs returned", "model": JailConfigListResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
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`.
"""
domain_result = await config_service.list_jail_configs(socket_path)
return config_mappers.map_domain_jail_config_list_to_response(domain_result)
@router.get(
"/inactive",
response_model=InactiveJailListResponse,
summary="List all inactive jails discovered in config files",
responses={
200: {"description": "Inactive jail list returned", "model": InactiveJailListResponse},
401: {"description": "Session missing, expired, or invalid"},
502: {"description": "fail2ban unreachable"},
},
)
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",
responses={
200: {"description": "Recovery record or null", "model": PendingRecovery},
401: {"description": "Session missing, expired, or invalid"},
},
)
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",
responses={
200: {"description": "Jail config returned", "model": JailConfigResponse},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
502: {"description": "fail2ban unreachable"},
},
)
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.
"""
domain_result = await config_service.get_jail_config(socket_path, name)
return config_mappers.map_domain_jail_config_to_response(domain_result)
@router.put(
"/{name}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update jail configuration",
dependencies=[Depends(_check_jail_update_rate_limit)],
responses={
204: {"description": "Jail config updated successfully"},
400: {"description": "Set command rejected or invalid regex"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
422: {"description": "Regex pattern failed to compile"},
429: {"description": "Rate limit exceeded for jail update operations"},
502: {"description": "fail2ban unreachable"},
},
)
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",
dependencies=[Depends(_check_jail_create_rate_limit)],
responses={
204: {"description": "Log path added successfully"},
400: {"description": "Command rejected or path invalid"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
429: {"description": "Rate limit exceeded for jail create operations"},
502: {"description": "fail2ban unreachable"},
},
)
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 or path is invalid.
HTTPException: 502 when fail2ban is unreachable.
"""
validate_log_path(body.log_path)
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",
dependencies=[Depends(_check_jail_delete_rate_limit)],
responses={
204: {"description": "Log path removed successfully"},
400: {"description": "Command rejected"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found"},
422: {"description": "Log path outside allowed directories"},
429: {"description": "Rate limit exceeded for jail delete operations"},
502: {"description": "fail2ban unreachable"},
},
)
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: 422 when the log path is outside allowed directories.
HTTPException: 404 when the jail does not exist.
HTTPException: 400 when the command is rejected.
HTTPException: 502 when fail2ban is unreachable.
"""
try:
validate_log_path(log_path)
except ValueError as e:
raise BadRequestError(str(e)) from e
await config_service.delete_log_path(socket_path, name, log_path)
@router.post(
"/{name}/activate",
response_model=JailActivationResponse,
summary="Activate an inactive jail",
dependencies=[Depends(_check_jail_activate_rate_limit)],
responses={
200: {"description": "Jail activated", "model": JailActivationResponse},
400: {"description": "Invalid jail name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found in config files"},
409: {"description": "Jail already active"},
429: {"description": "Rate limit exceeded for jail activate operations"},
502: {"description": "fail2ban unreachable"},
},
)
async def activate_jail(
app: AppDep,
_auth: AuthDep,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
health_probe: HealthProbeDep,
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.
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
health_probe: Injectable health probe function for checking fail2ban status.
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, health_probe=health_probe
)
if result.active:
record_activation(app, name)
return result
@router.post(
"/{name}/deactivate",
response_model=JailActivationResponse,
summary="Deactivate an active jail",
dependencies=[Depends(_check_jail_deactivate_rate_limit)],
responses={
200: {"description": "Jail deactivated", "model": JailActivationResponse},
400: {"description": "Invalid jail name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found in config files"},
409: {"description": "Jail already inactive"},
429: {"description": "Rate limit exceeded for jail deactivate operations"},
502: {"description": "fail2ban unreachable"},
},
)
async def deactivate_jail(
_auth: AuthDep,
config_dir: Fail2BanConfigDirDep,
socket_path: Fail2BanSocketDep,
health_probe: HealthProbeDep,
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.
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
health_probe: Injectable health probe function for checking fail2ban status.
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, health_probe=health_probe
)
return result
@router.delete(
"/{name}/local",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete the jail.d override file for an inactive jail",
responses={
204: {"description": "Override file deleted successfully"},
400: {"description": "Invalid jail name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found in config files"},
409: {"description": "Jail currently active"},
500: {"description": "File cannot be deleted"},
502: {"description": "fail2ban unreachable"},
},
)
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",
responses={
200: {"description": "Validation result", "model": JailValidationResult},
400: {"description": "Invalid jail name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found in config files"},
},
)
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",
responses={
200: {"description": "Rollback completed", "model": RollbackResponse},
400: {"description": "Invalid jail name"},
401: {"description": "Session missing, expired, or invalid"},
500: {"description": "Failed to write .local override file"},
},
)
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",
dependencies=[Depends(_check_jail_create_rate_limit)],
responses={
204: {"description": "Filter assigned successfully"},
400: {"description": "Invalid jail name or filter name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail or filter not found"},
429: {"description": "Rate limit exceeded for jail create operations"},
500: {"description": "Failed to write .local override file"},
},
)
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",
dependencies=[Depends(_check_jail_create_rate_limit)],
responses={
204: {"description": "Action added successfully"},
400: {"description": "Invalid jail name or action name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail or action not found"},
429: {"description": "Rate limit exceeded for jail create operations"},
500: {"description": "Failed to write .local override file"},
},
)
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",
dependencies=[Depends(_check_jail_delete_rate_limit)],
responses={
204: {"description": "Action removed successfully"},
400: {"description": "Invalid jail name or action name"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail not found in config files"},
429: {"description": "Rate limit exceeded for jail delete operations"},
500: {"description": "Failed to write .local override file"},
},
)
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)
# ---------------------------------------------------------------------------