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>
358 lines
12 KiB
Python
358 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, Path, Query, Request, status
|
|
|
|
from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep, GlobalRateLimiterDep
|
|
from app.models.config import (
|
|
ActionConfig,
|
|
ActionCreateRequest,
|
|
ActionListResponse,
|
|
ActionUpdateRequest,
|
|
)
|
|
from app.services import action_config_service
|
|
from app.utils.constants import (
|
|
RATE_LIMIT_ACTION_CREATE_REQUESTS,
|
|
RATE_LIMIT_ACTION_DELETE_REQUESTS,
|
|
RATE_LIMIT_ACTION_UPDATE_REQUESTS,
|
|
)
|
|
|
|
router: APIRouter = APIRouter(prefix="/actions", tags=["Action Config"])
|
|
|
|
_MINUTE = 60
|
|
|
|
_ACTION_UPDATE_BUCKET = "action:update"
|
|
_ACTION_CREATE_BUCKET = "action:create"
|
|
_ACTION_DELETE_BUCKET = "action:delete"
|
|
|
|
|
|
def _check_action_update_rate_limit(
|
|
request: Request,
|
|
rate_limiter: GlobalRateLimiterDep,
|
|
) -> None:
|
|
"""Check rate limit for action 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(
|
|
_ACTION_UPDATE_BUCKET, client_ip, RATE_LIMIT_ACTION_UPDATE_REQUESTS, _MINUTE
|
|
)
|
|
if not is_allowed:
|
|
from app.exceptions import RateLimitError
|
|
import structlog
|
|
|
|
log = structlog.get_logger()
|
|
log.warning(
|
|
"action_update_rate_limit_exceeded",
|
|
client_ip=client_ip,
|
|
path=request.url.path,
|
|
retry_after=retry_after,
|
|
)
|
|
raise RateLimitError(
|
|
"Rate limit exceeded for action update operations. Please try again later.",
|
|
retry_after_seconds=retry_after,
|
|
)
|
|
|
|
|
|
def _check_action_create_rate_limit(
|
|
request: Request,
|
|
rate_limiter: GlobalRateLimiterDep,
|
|
) -> None:
|
|
"""Check rate limit for action 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(
|
|
_ACTION_CREATE_BUCKET, client_ip, RATE_LIMIT_ACTION_CREATE_REQUESTS, _MINUTE
|
|
)
|
|
if not is_allowed:
|
|
from app.exceptions import RateLimitError
|
|
import structlog
|
|
|
|
log = structlog.get_logger()
|
|
log.warning(
|
|
"action_create_rate_limit_exceeded",
|
|
client_ip=client_ip,
|
|
path=request.url.path,
|
|
retry_after=retry_after,
|
|
)
|
|
raise RateLimitError(
|
|
"Rate limit exceeded for action create operations. Please try again later.",
|
|
retry_after_seconds=retry_after,
|
|
)
|
|
|
|
|
|
def _check_action_delete_rate_limit(
|
|
request: Request,
|
|
rate_limiter: GlobalRateLimiterDep,
|
|
) -> None:
|
|
"""Check rate limit for action 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(
|
|
_ACTION_DELETE_BUCKET, client_ip, RATE_LIMIT_ACTION_DELETE_REQUESTS, _MINUTE
|
|
)
|
|
if not is_allowed:
|
|
from app.exceptions import RateLimitError
|
|
import structlog
|
|
|
|
log = structlog.get_logger()
|
|
log.warning(
|
|
"action_delete_rate_limit_exceeded",
|
|
client_ip=client_ip,
|
|
path=request.url.path,
|
|
retry_after=retry_after,
|
|
)
|
|
raise RateLimitError(
|
|
"Rate limit exceeded for action delete operations. Please try again later.",
|
|
retry_after_seconds=retry_after,
|
|
)
|
|
|
|
|
|
_ActionNamePath = Annotated[
|
|
str,
|
|
Path(description='Action base name, e.g. ``iptables`` or ``iptables.conf``.'),
|
|
]
|
|
|
|
_NamePath = Annotated[
|
|
str,
|
|
Path(description='Jail name as configured in fail2ban.'),
|
|
]
|
|
|
|
@router.get(
|
|
"",
|
|
response_model=ActionListResponse,
|
|
summary="List all available actions with active/inactive status",
|
|
responses={
|
|
200: {"description": "Action list returned", "model": ActionListResponse},
|
|
401: {"description": "Session missing, expired, or invalid"},
|
|
502: {"description": "fail2ban unreachable"},
|
|
},
|
|
)
|
|
async def list_actions(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
config_dir: Fail2BanConfigDirDep,
|
|
socket_path: Fail2BanSocketDep,
|
|
) -> ActionListResponse:
|
|
"""Return all actions discovered in ``action.d/`` with active/inactive status.
|
|
|
|
Scans ``{config_dir}/action.d/`` for ``.conf`` files, merges any
|
|
corresponding ``.local`` overrides, and cross-references each action's
|
|
name against the ``action`` fields of currently running jails to determine
|
|
whether it is active.
|
|
|
|
Active actions (those used by at least one running jail) are sorted to the
|
|
top of the list; inactive actions follow. Both groups are sorted
|
|
alphabetically within themselves.
|
|
|
|
Args:
|
|
request: FastAPI request object.
|
|
_auth: Validated session — enforces authentication.
|
|
|
|
Returns:
|
|
:class:`~app.models.config.ActionListResponse` with all discovered
|
|
actions.
|
|
"""
|
|
result = await action_config_service.list_actions(config_dir, socket_path)
|
|
result.actions.sort(key=lambda a: (not a.active, a.name.lower()))
|
|
return result
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
"/{name}",
|
|
response_model=ActionConfig,
|
|
summary="Return full parsed detail for a single action",
|
|
responses={
|
|
200: {"description": "Action config returned", "model": ActionConfig},
|
|
401: {"description": "Session missing, expired, or invalid"},
|
|
404: {"description": "Action not found in action.d/"},
|
|
502: {"description": "fail2ban unreachable"},
|
|
},
|
|
)
|
|
async def get_action(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
config_dir: Fail2BanConfigDirDep,
|
|
socket_path: Fail2BanSocketDep,
|
|
name: _ActionNamePath,
|
|
) -> ActionConfig:
|
|
"""Return the full parsed configuration and active/inactive status for one action.
|
|
|
|
Reads ``{config_dir}/action.d/{name}.conf``, merges any corresponding
|
|
``.local`` override, and annotates the result with ``active``,
|
|
``used_by_jails``, ``source_file``, and ``has_local_override``.
|
|
|
|
Args:
|
|
request: FastAPI request object.
|
|
_auth: Validated session — enforces authentication.
|
|
name: Action base name (with or without ``.conf`` extension).
|
|
|
|
Returns:
|
|
:class:`~app.models.config.ActionConfig`.
|
|
|
|
Raises:
|
|
HTTPException: 404 if the action is not found in ``action.d/``.
|
|
"""
|
|
return await action_config_service.get_action(config_dir, socket_path, name)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Action write endpoints (Task 3.2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
@router.put(
|
|
"/{name}",
|
|
response_model=ActionConfig,
|
|
summary="Update an action's .local override with new lifecycle command values",
|
|
dependencies=[Depends(_check_action_update_rate_limit)],
|
|
responses={
|
|
200: {"description": "Action updated", "model": ActionConfig},
|
|
400: {"description": "Invalid action name"},
|
|
401: {"description": "Session missing, expired, or invalid"},
|
|
404: {"description": "Action not found"},
|
|
429: {"description": "Rate limit exceeded for action update operations"},
|
|
500: {"description": "Failed to write .local file"},
|
|
},
|
|
)
|
|
async def update_action(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
config_dir: Fail2BanConfigDirDep,
|
|
socket_path: Fail2BanSocketDep,
|
|
name: _ActionNamePath,
|
|
body: ActionUpdateRequest,
|
|
reload: bool = Query(default=False, description="Reload fail2ban after writing."),
|
|
) -> ActionConfig:
|
|
"""Update an action's ``[Definition]`` fields by writing a ``.local`` override.
|
|
|
|
Only non-``null`` fields in the request body are written. The original
|
|
``.conf`` file is never modified.
|
|
|
|
Args:
|
|
request: FastAPI request object.
|
|
_auth: Validated session.
|
|
name: Action base name (with or without ``.conf`` extension).
|
|
body: Partial update — lifecycle commands and ``[Init]`` parameters.
|
|
reload: When ``true``, trigger a fail2ban reload after writing.
|
|
|
|
Returns:
|
|
Updated :class:`~app.models.config.ActionConfig`.
|
|
|
|
Raises:
|
|
HTTPException: 400 if *name* contains invalid characters.
|
|
HTTPException: 404 if the action does not exist.
|
|
HTTPException: 500 if writing the ``.local`` file fails.
|
|
"""
|
|
return await action_config_service.update_action(config_dir, socket_path, name, body, do_reload=reload)
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=ActionConfig,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Create a new user-defined action",
|
|
dependencies=[Depends(_check_action_create_rate_limit)],
|
|
responses={
|
|
201: {"description": "Action created", "model": ActionConfig},
|
|
400: {"description": "Invalid action name"},
|
|
401: {"description": "Session missing, expired, or invalid"},
|
|
409: {"description": "Action already exists"},
|
|
429: {"description": "Rate limit exceeded for action create operations"},
|
|
500: {"description": "Failed to write .local file"},
|
|
},
|
|
)
|
|
async def create_action(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
config_dir: Fail2BanConfigDirDep,
|
|
socket_path: Fail2BanSocketDep,
|
|
body: ActionCreateRequest,
|
|
reload: bool = Query(default=False, description="Reload fail2ban after creating."),
|
|
) -> ActionConfig:
|
|
"""Create a new user-defined action at ``action.d/{name}.local``.
|
|
|
|
Returns 409 if a ``.conf`` or ``.local`` for the requested name already
|
|
exists.
|
|
|
|
Args:
|
|
request: FastAPI request object.
|
|
_auth: Validated session.
|
|
body: Action name and ``[Definition]`` lifecycle fields.
|
|
reload: When ``true``, trigger a fail2ban reload after creating.
|
|
|
|
Returns:
|
|
:class:`~app.models.config.ActionConfig` for the new action.
|
|
|
|
Raises:
|
|
HTTPException: 400 if the name contains invalid characters.
|
|
HTTPException: 409 if the action already exists.
|
|
HTTPException: 500 if writing fails.
|
|
"""
|
|
return await action_config_service.create_action(config_dir, socket_path, body, do_reload=reload)
|
|
|
|
|
|
|
|
|
|
@router.delete(
|
|
"/{name}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
summary="Delete a user-created action's .local file",
|
|
dependencies=[Depends(_check_action_delete_rate_limit)],
|
|
responses={
|
|
204: {"description": "Action deleted successfully"},
|
|
400: {"description": "Invalid action name"},
|
|
401: {"description": "Session missing, expired, or invalid"},
|
|
404: {"description": "Action not found"},
|
|
409: {"description": "Action is a shipped default (conf-only)"},
|
|
429: {"description": "Rate limit exceeded for action delete operations"},
|
|
500: {"description": "Failed to delete .local file"},
|
|
},
|
|
)
|
|
async def delete_action(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
config_dir: Fail2BanConfigDirDep,
|
|
name: _ActionNamePath,
|
|
) -> None:
|
|
"""Delete a user-created action's ``.local`` override file.
|
|
|
|
Shipped ``.conf``-only actions cannot be deleted (returns 409). When
|
|
both a ``.conf`` and a ``.local`` exist, only the ``.local`` is removed.
|
|
|
|
Args:
|
|
request: FastAPI request object.
|
|
_auth: Validated session.
|
|
name: Action base name.
|
|
|
|
Raises:
|
|
HTTPException: 400 if *name* contains invalid characters.
|
|
HTTPException: 404 if the action does not exist.
|
|
HTTPException: 409 if the action is a shipped default (conf-only).
|
|
HTTPException: 500 if deletion fails.
|
|
"""
|
|
await action_config_service.delete_action(config_dir, name)
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# fail2ban log viewer endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|