- All backend routers moved to /api/v1/ prefix
- Frontend BASE_URL updated to /api/v1
- Setup redirect middleware updated to redirect to /api/v1/setup
- Health router path fixed: prefix=/api/v1/health, @router.get('')
- conftest.py: set server_status=online for test fixture
- Created Docs/API_VERSIONING.md with deprecation policy
- Updated Docs/Backend-Development.md with versioning section
- Updated Instructions.md curl examples
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
769 lines
24 KiB
Python
769 lines
24 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",
|
|
)
|
|
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",
|
|
)
|
|
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.
|
|
"""
|
|
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)],
|
|
)
|
|
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)],
|
|
)
|
|
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)],
|
|
)
|
|
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)],
|
|
)
|
|
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)],
|
|
)
|
|
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",
|
|
)
|
|
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",
|
|
dependencies=[Depends(_check_jail_create_rate_limit)],
|
|
)
|
|
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)],
|
|
)
|
|
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)],
|
|
)
|
|
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)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|