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 from app.utils.logging_compat import get_logger log = get_logger(__name__) 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 from app.utils.logging_compat import get_logger log = get_logger(__name__) 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 from app.utils.logging_compat import get_logger log = get_logger(__name__) 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 # ---------------------------------------------------------------------------