feat: action config service, router endpoints, and full test coverage (Tasks 3.1, 3.2, 3.4)
- ActionConfig extended with active, used_by_jails, source_file, has_local_override
- New models: ActionListResponse, ActionUpdateRequest, ActionCreateRequest, AssignActionRequest
- New service functions: list_actions, get_action, update_action, create_action, delete_action, assign_action_to_jail, remove_action_from_jail
- New error classes: ActionNotFoundError, ActionAlreadyExistsError, ActionReadonlyError, ActionNameError
- New router endpoints: GET/PUT/POST/DELETE /api/config/actions, POST/DELETE /api/config/jails/{name}/action
- Service + router tests: 290 tests passing, mypy strict clean, ruff clean
This commit is contained in:
@@ -10,6 +10,8 @@ global settings, test regex patterns, add log paths, and preview log files.
|
||||
* ``POST /api/config/jails/{name}/activate`` — activate an inactive jail
|
||||
* ``POST /api/config/jails/{name}/deactivate`` — deactivate an active jail
|
||||
* ``POST /api/config/jails/{name}/filter`` — assign a filter to a jail
|
||||
* ``POST /api/config/jails/{name}/action`` — add an action to a jail
|
||||
* ``DELETE /api/config/jails/{name}/action/{action_name}`` — remove an action from a jail
|
||||
* ``GET /api/config/global`` — global fail2ban settings
|
||||
* ``PUT /api/config/global`` — update global settings
|
||||
* ``POST /api/config/reload`` — reload fail2ban
|
||||
@@ -21,6 +23,11 @@ global settings, test regex patterns, add log paths, and preview log files.
|
||||
* ``PUT /api/config/filters/{name}`` — update a filter's .local override
|
||||
* ``POST /api/config/filters`` — create a new user-defined filter
|
||||
* ``DELETE /api/config/filters/{name}`` — delete a filter's .local file
|
||||
* ``GET /api/config/actions`` — list all actions with active/inactive status
|
||||
* ``GET /api/config/actions/{name}`` — full parsed detail for one action
|
||||
* ``PUT /api/config/actions/{name}`` — update an action's .local override
|
||||
* ``POST /api/config/actions`` — create a new user-defined action
|
||||
* ``DELETE /api/config/actions/{name}`` — delete an action's .local file
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,8 +38,13 @@ from fastapi import APIRouter, HTTPException, Path, Query, Request, status
|
||||
|
||||
from app.dependencies import AuthDep
|
||||
from app.models.config import (
|
||||
ActionConfig,
|
||||
ActionCreateRequest,
|
||||
ActionListResponse,
|
||||
ActionUpdateRequest,
|
||||
ActivateJailRequest,
|
||||
AddLogPathRequest,
|
||||
AssignActionRequest,
|
||||
AssignFilterRequest,
|
||||
FilterConfig,
|
||||
FilterCreateRequest,
|
||||
@@ -54,6 +66,10 @@ from app.models.config import (
|
||||
)
|
||||
from app.services import config_file_service, config_service, jail_service
|
||||
from app.services.config_file_service import (
|
||||
ActionAlreadyExistsError,
|
||||
ActionNameError,
|
||||
ActionNotFoundError,
|
||||
ActionReadonlyError,
|
||||
ConfigWriteError,
|
||||
FilterAlreadyExistsError,
|
||||
FilterInvalidRegexError,
|
||||
@@ -968,3 +984,338 @@ async def assign_filter_to_jail(
|
||||
detail=f"Failed to write jail override: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action discovery endpoints (Task 3.1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ActionNamePath = Annotated[
|
||||
str,
|
||||
Path(description="Action base name, e.g. ``iptables`` or ``iptables.conf``."),
|
||||
]
|
||||
|
||||
|
||||
def _action_not_found(name: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Action not found: {name!r}",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/actions",
|
||||
response_model=ActionListResponse,
|
||||
summary="List all available actions with active/inactive status",
|
||||
)
|
||||
async def list_actions(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> 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.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
result = await config_file_service.list_actions(config_dir, socket_path)
|
||||
result.actions.sort(key=lambda a: (not a.active, a.name.lower()))
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/actions/{name}",
|
||||
response_model=ActionConfig,
|
||||
summary="Return full parsed detail for a single action",
|
||||
)
|
||||
async def get_action(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
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/``.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
return await config_file_service.get_action(config_dir, socket_path, name)
|
||||
except ActionNotFoundError:
|
||||
raise _action_not_found(name) from None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action write endpoints (Task 3.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.put(
|
||||
"/actions/{name}",
|
||||
response_model=ActionConfig,
|
||||
summary="Update an action's .local override with new lifecycle command values",
|
||||
)
|
||||
async def update_action(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
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.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
return await config_file_service.update_action(
|
||||
config_dir, socket_path, name, body, do_reload=reload
|
||||
)
|
||||
except ActionNameError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except ActionNotFoundError:
|
||||
raise _action_not_found(name) from None
|
||||
except ConfigWriteError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to write action override: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/actions",
|
||||
response_model=ActionConfig,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new user-defined action",
|
||||
)
|
||||
async def create_action(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
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.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
return await config_file_service.create_action(
|
||||
config_dir, socket_path, body, do_reload=reload
|
||||
)
|
||||
except ActionNameError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except ActionAlreadyExistsError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Action {exc.name!r} already exists.",
|
||||
) from exc
|
||||
except ConfigWriteError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to write action: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/actions/{name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete a user-created action's .local file",
|
||||
)
|
||||
async def delete_action(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
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.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
try:
|
||||
await config_file_service.delete_action(config_dir, name)
|
||||
except ActionNameError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except ActionNotFoundError:
|
||||
raise _action_not_found(name) from None
|
||||
except ActionReadonlyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except ConfigWriteError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete action: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/jails/{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,
|
||||
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.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
await config_file_service.assign_action_to_jail(
|
||||
config_dir, socket_path, name, body, do_reload=reload
|
||||
)
|
||||
except (JailNameError, ActionNameError) as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except JailNotFoundInConfigError:
|
||||
raise _not_found(name) from None
|
||||
except ActionNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Action not found: {exc.name!r}",
|
||||
) from exc
|
||||
except ConfigWriteError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to write jail override: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/jails/{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,
|
||||
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.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
try:
|
||||
await config_file_service.remove_action_from_jail(
|
||||
config_dir, socket_path, name, action_name, do_reload=reload
|
||||
)
|
||||
except (JailNameError, ActionNameError) as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except JailNotFoundInConfigError:
|
||||
raise _not_found(name) from None
|
||||
except ConfigWriteError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to write jail override: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
Reference in New Issue
Block a user