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:
@@ -208,7 +208,7 @@ fail2ban ships with a large collection of filter definitions in `filter.d/` (ove
|
||||
|
||||
fail2ban ships with many action definitions in `action.d/` (iptables, firewalld, cloudflare, sendmail, etc.). Users need to see all available actions, understand which are in use, and assign them to jails.
|
||||
|
||||
### Task 3.1 — Backend: List All Available Actions with Active/Inactive Status
|
||||
### Task 3.1 — Backend: List All Available Actions with Active/Inactive Status ✅ DONE
|
||||
|
||||
**Goal:** Enumerate all action config files and mark each as active or inactive based on jail usage.
|
||||
|
||||
@@ -231,7 +231,7 @@ fail2ban ships with many action definitions in `action.d/` (iptables, firewalld,
|
||||
|
||||
---
|
||||
|
||||
### Task 3.2 — Backend: Activate and Edit Actions
|
||||
### Task 3.2 — Backend: Activate and Edit Actions ✅ DONE
|
||||
|
||||
**Goal:** Allow users to assign actions to jails, edit action definitions, and create new actions.
|
||||
|
||||
@@ -281,7 +281,7 @@ fail2ban ships with many action definitions in `action.d/` (iptables, firewalld,
|
||||
|
||||
---
|
||||
|
||||
### Task 3.4 — Tests: Action Discovery and Management
|
||||
### Task 3.4 — Tests: Action Discovery and Management ✅ DONE
|
||||
|
||||
**Goal:** Test coverage for action listing, editing, creation, and assignment.
|
||||
|
||||
|
||||
@@ -508,6 +508,33 @@ class ActionConfig(BaseModel):
|
||||
default_factory=dict,
|
||||
description="Runtime parameters that can be overridden per jail.",
|
||||
)
|
||||
# Active-status fields — populated by config_file_service.list_actions /
|
||||
# get_action; default to safe "inactive" values when not computed.
|
||||
active: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"``True`` when this action is referenced by at least one currently "
|
||||
"enabled (running) jail."
|
||||
),
|
||||
)
|
||||
used_by_jails: list[str] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Names of currently enabled jails that reference this action. "
|
||||
"Empty when ``active`` is ``False``."
|
||||
),
|
||||
)
|
||||
source_file: str = Field(
|
||||
default="",
|
||||
description="Absolute path to the ``.conf`` source file for this action.",
|
||||
)
|
||||
has_local_override: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"``True`` when a ``.local`` override file exists alongside the "
|
||||
"base ``.conf`` file."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ActionConfigUpdate(BaseModel):
|
||||
@@ -527,6 +554,110 @@ class ActionConfigUpdate(BaseModel):
|
||||
init_vars: dict[str, str] | None = Field(default=None)
|
||||
|
||||
|
||||
class ActionListResponse(BaseModel):
|
||||
"""Response for ``GET /api/config/actions``."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
actions: list[ActionConfig] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"All discovered actions, each annotated with active/inactive status "
|
||||
"and the jails that reference them."
|
||||
),
|
||||
)
|
||||
total: int = Field(..., ge=0, description="Total number of actions found.")
|
||||
|
||||
|
||||
class ActionUpdateRequest(BaseModel):
|
||||
"""Payload for ``PUT /api/config/actions/{name}``.
|
||||
|
||||
Accepts only the user-editable ``[Definition]`` lifecycle fields and
|
||||
``[Init]`` parameters. Fields left as ``None`` are not changed.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
actionstart: str | None = Field(
|
||||
default=None,
|
||||
description="Updated ``actionstart`` command. ``None`` = keep existing.",
|
||||
)
|
||||
actionstop: str | None = Field(
|
||||
default=None,
|
||||
description="Updated ``actionstop`` command. ``None`` = keep existing.",
|
||||
)
|
||||
actioncheck: str | None = Field(
|
||||
default=None,
|
||||
description="Updated ``actioncheck`` command. ``None`` = keep existing.",
|
||||
)
|
||||
actionban: str | None = Field(
|
||||
default=None,
|
||||
description="Updated ``actionban`` command. ``None`` = keep existing.",
|
||||
)
|
||||
actionunban: str | None = Field(
|
||||
default=None,
|
||||
description="Updated ``actionunban`` command. ``None`` = keep existing.",
|
||||
)
|
||||
actionflush: str | None = Field(
|
||||
default=None,
|
||||
description="Updated ``actionflush`` command. ``None`` = keep existing.",
|
||||
)
|
||||
definition_vars: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
description="Additional ``[Definition]`` variables to set. ``None`` = keep existing.",
|
||||
)
|
||||
init_vars: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
description="``[Init]`` parameters to set. ``None`` = keep existing.",
|
||||
)
|
||||
|
||||
|
||||
class ActionCreateRequest(BaseModel):
|
||||
"""Payload for ``POST /api/config/actions``.
|
||||
|
||||
Creates a new user-defined action at ``action.d/{name}.local``.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
name: str = Field(
|
||||
...,
|
||||
description="Action base name (e.g. ``my-custom-action``). Must not already exist.",
|
||||
)
|
||||
actionstart: str | None = Field(default=None, description="Command to execute at jail start.")
|
||||
actionstop: str | None = Field(default=None, description="Command to execute at jail stop.")
|
||||
actioncheck: str | None = Field(default=None, description="Command to execute before each ban.")
|
||||
actionban: str | None = Field(default=None, description="Command to execute to ban an IP.")
|
||||
actionunban: str | None = Field(default=None, description="Command to execute to unban an IP.")
|
||||
actionflush: str | None = Field(default=None, description="Command to flush all bans on shutdown.")
|
||||
definition_vars: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Additional ``[Definition]`` variables.",
|
||||
)
|
||||
init_vars: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="``[Init]`` runtime parameters.",
|
||||
)
|
||||
|
||||
|
||||
class AssignActionRequest(BaseModel):
|
||||
"""Payload for ``POST /api/config/jails/{jail_name}/action``."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
action_name: str = Field(
|
||||
...,
|
||||
description="Action base name to add to the jail (e.g. ``iptables-multiport``).",
|
||||
)
|
||||
params: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description=(
|
||||
"Optional per-jail action parameters written as "
|
||||
"``action_name[key=value, ...]`` in the jail config."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jail file config models (Task 6.1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -33,7 +33,13 @@ from typing import Any
|
||||
import structlog
|
||||
|
||||
from app.models.config import (
|
||||
ActionConfig,
|
||||
ActionConfigUpdate,
|
||||
ActionCreateRequest,
|
||||
ActionListResponse,
|
||||
ActionUpdateRequest,
|
||||
ActivateJailRequest,
|
||||
AssignActionRequest,
|
||||
AssignFilterRequest,
|
||||
FilterConfig,
|
||||
FilterConfigUpdate,
|
||||
@@ -1504,3 +1510,952 @@ async def assign_filter_to_jail(
|
||||
reload=do_reload,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action discovery helpers (Task 3.1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Allowlist pattern for action names used in path construction.
|
||||
_SAFE_ACTION_NAME_RE: re.Pattern[str] = re.compile(
|
||||
r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
|
||||
)
|
||||
|
||||
|
||||
class ActionNotFoundError(Exception):
|
||||
"""Raised when the requested action name is not found in ``action.d/``."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
"""Initialise with the action name that was not found.
|
||||
|
||||
Args:
|
||||
name: The action name that could not be located.
|
||||
"""
|
||||
self.name: str = name
|
||||
super().__init__(f"Action not found: {name!r}")
|
||||
|
||||
|
||||
class ActionAlreadyExistsError(Exception):
|
||||
"""Raised when trying to create an action whose ``.conf`` or ``.local`` already exists."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
"""Initialise with the action name that already exists.
|
||||
|
||||
Args:
|
||||
name: The action name that already exists.
|
||||
"""
|
||||
self.name: str = name
|
||||
super().__init__(f"Action already exists: {name!r}")
|
||||
|
||||
|
||||
class ActionReadonlyError(Exception):
|
||||
"""Raised when trying to delete a shipped ``.conf`` action with no ``.local`` override."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
"""Initialise with the action name that cannot be deleted.
|
||||
|
||||
Args:
|
||||
name: The action name that is read-only (shipped ``.conf`` only).
|
||||
"""
|
||||
self.name: str = name
|
||||
super().__init__(
|
||||
f"Action {name!r} is a shipped default (.conf only); "
|
||||
"only user-created .local files can be deleted."
|
||||
)
|
||||
|
||||
|
||||
class ActionNameError(Exception):
|
||||
"""Raised when an action name contains invalid characters."""
|
||||
|
||||
|
||||
def _safe_action_name(name: str) -> str:
|
||||
"""Validate *name* and return it unchanged or raise :class:`ActionNameError`.
|
||||
|
||||
Args:
|
||||
name: Proposed action name (without extension).
|
||||
|
||||
Returns:
|
||||
The name unchanged if valid.
|
||||
|
||||
Raises:
|
||||
ActionNameError: If *name* contains unsafe characters.
|
||||
"""
|
||||
if not _SAFE_ACTION_NAME_RE.match(name):
|
||||
raise ActionNameError(
|
||||
f"Action name {name!r} contains invalid characters. "
|
||||
"Only alphanumeric characters, hyphens, underscores, and dots are "
|
||||
"allowed; must start with an alphanumeric character."
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def _build_action_to_jails_map(
|
||||
all_jails: dict[str, dict[str, str]],
|
||||
active_names: set[str],
|
||||
) -> dict[str, list[str]]:
|
||||
"""Return a mapping of action base name → list of active jail names.
|
||||
|
||||
Iterates over every jail whose name is in *active_names*, resolves each
|
||||
entry in its ``action`` config key to an action base name (stripping
|
||||
``[…]`` parameter blocks), and records the jail against each base name.
|
||||
|
||||
Args:
|
||||
all_jails: Merged jail config dict — ``{jail_name: {key: value}}``.
|
||||
active_names: Set of jail names currently running in fail2ban.
|
||||
|
||||
Returns:
|
||||
``{action_base_name: [jail_name, …]}``.
|
||||
"""
|
||||
mapping: dict[str, list[str]] = {}
|
||||
for jail_name, settings in all_jails.items():
|
||||
if jail_name not in active_names:
|
||||
continue
|
||||
raw_action = settings.get("action", "")
|
||||
if not raw_action:
|
||||
continue
|
||||
for line in raw_action.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
# Strip optional [key=value] parameter block to get the base name.
|
||||
bracket = stripped.find("[")
|
||||
base = stripped[:bracket].strip() if bracket != -1 else stripped
|
||||
if base:
|
||||
mapping.setdefault(base, []).append(jail_name)
|
||||
return mapping
|
||||
|
||||
|
||||
def _parse_actions_sync(
|
||||
action_d: Path,
|
||||
) -> list[tuple[str, str, str, bool, str]]:
|
||||
"""Synchronously scan ``action.d/`` and return per-action tuples.
|
||||
|
||||
Each tuple contains:
|
||||
|
||||
- ``name`` — action base name (``"iptables"``).
|
||||
- ``filename`` — actual filename (``"iptables.conf"``).
|
||||
- ``content`` — merged file content (``conf`` overridden by ``local``).
|
||||
- ``has_local`` — whether a ``.local`` override exists alongside a ``.conf``.
|
||||
- ``source_path`` — absolute path to the primary (``conf``) source file, or
|
||||
to the ``.local`` file for user-created (local-only) actions.
|
||||
|
||||
Also discovers ``.local``-only files (user-created actions with no
|
||||
corresponding ``.conf``).
|
||||
|
||||
Args:
|
||||
action_d: Path to the ``action.d`` directory.
|
||||
|
||||
Returns:
|
||||
List of ``(name, filename, content, has_local, source_path)`` tuples,
|
||||
sorted by name.
|
||||
"""
|
||||
if not action_d.is_dir():
|
||||
log.warning("action_d_not_found", path=str(action_d))
|
||||
return []
|
||||
|
||||
conf_names: set[str] = set()
|
||||
results: list[tuple[str, str, str, bool, str]] = []
|
||||
|
||||
# ---- .conf-based actions (with optional .local override) ----------------
|
||||
for conf_path in sorted(action_d.glob("*.conf")):
|
||||
if not conf_path.is_file():
|
||||
continue
|
||||
name = conf_path.stem
|
||||
filename = conf_path.name
|
||||
conf_names.add(name)
|
||||
local_path = conf_path.with_suffix(".local")
|
||||
has_local = local_path.is_file()
|
||||
|
||||
try:
|
||||
content = conf_path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
log.warning(
|
||||
"action_read_error", name=name, path=str(conf_path), error=str(exc)
|
||||
)
|
||||
continue
|
||||
|
||||
if has_local:
|
||||
try:
|
||||
local_content = local_path.read_text(encoding="utf-8")
|
||||
content = content + "\n" + local_content
|
||||
except OSError as exc:
|
||||
log.warning(
|
||||
"action_local_read_error",
|
||||
name=name,
|
||||
path=str(local_path),
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
results.append((name, filename, content, has_local, str(conf_path)))
|
||||
|
||||
# ---- .local-only actions (user-created, no corresponding .conf) ----------
|
||||
for local_path in sorted(action_d.glob("*.local")):
|
||||
if not local_path.is_file():
|
||||
continue
|
||||
name = local_path.stem
|
||||
if name in conf_names:
|
||||
continue
|
||||
try:
|
||||
content = local_path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
log.warning(
|
||||
"action_local_read_error",
|
||||
name=name,
|
||||
path=str(local_path),
|
||||
error=str(exc),
|
||||
)
|
||||
continue
|
||||
results.append((name, local_path.name, content, False, str(local_path)))
|
||||
|
||||
results.sort(key=lambda t: t[0])
|
||||
log.debug("actions_scanned", count=len(results), action_d=str(action_d))
|
||||
return results
|
||||
|
||||
|
||||
def _append_jail_action_sync(
|
||||
config_dir: Path,
|
||||
jail_name: str,
|
||||
action_entry: str,
|
||||
) -> None:
|
||||
"""Append an action entry to the ``action`` key in ``jail.d/{jail_name}.local``.
|
||||
|
||||
If the ``.local`` file already contains an ``action`` key under the jail
|
||||
section, the new entry is appended as an additional line (multi-line
|
||||
configparser format) unless it is already present. If no ``action`` key
|
||||
exists, one is created.
|
||||
|
||||
Args:
|
||||
config_dir: The fail2ban configuration root directory.
|
||||
jail_name: Validated jail name.
|
||||
action_entry: Full action string including any ``[…]`` parameters.
|
||||
|
||||
Raises:
|
||||
ConfigWriteError: If writing fails.
|
||||
"""
|
||||
jail_d = config_dir / "jail.d"
|
||||
try:
|
||||
jail_d.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise ConfigWriteError(
|
||||
f"Cannot create jail.d directory: {exc}"
|
||||
) from exc
|
||||
|
||||
local_path = jail_d / f"{jail_name}.local"
|
||||
|
||||
parser = _build_parser()
|
||||
if local_path.is_file():
|
||||
try:
|
||||
parser.read(str(local_path), encoding="utf-8")
|
||||
except (configparser.Error, OSError) as exc:
|
||||
log.warning(
|
||||
"jail_local_read_for_update_error",
|
||||
jail=jail_name,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
if not parser.has_section(jail_name):
|
||||
parser.add_section(jail_name)
|
||||
|
||||
existing_raw = parser.get(jail_name, "action") if parser.has_option(jail_name, "action") else ""
|
||||
existing_lines = [
|
||||
line.strip()
|
||||
for line in existing_raw.splitlines()
|
||||
if line.strip() and not line.strip().startswith("#")
|
||||
]
|
||||
|
||||
# Extract base names from existing entries for duplicate checking.
|
||||
def _base(entry: str) -> str:
|
||||
bracket = entry.find("[")
|
||||
return entry[:bracket].strip() if bracket != -1 else entry.strip()
|
||||
|
||||
new_base = _base(action_entry)
|
||||
if not any(_base(e) == new_base for e in existing_lines):
|
||||
existing_lines.append(action_entry)
|
||||
|
||||
if existing_lines:
|
||||
# configparser multi-line: continuation lines start with whitespace.
|
||||
new_value = existing_lines[0] + "".join(
|
||||
f"\n {line}" for line in existing_lines[1:]
|
||||
)
|
||||
parser.set(jail_name, "action", new_value)
|
||||
else:
|
||||
parser.set(jail_name, "action", action_entry)
|
||||
|
||||
buf = io.StringIO()
|
||||
buf.write("# Managed by BanGUI — do not edit manually\n\n")
|
||||
parser.write(buf)
|
||||
content = buf.getvalue()
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=jail_d,
|
||||
delete=False,
|
||||
suffix=".tmp",
|
||||
) as tmp:
|
||||
tmp.write(content)
|
||||
tmp_name = tmp.name
|
||||
os.replace(tmp_name, local_path)
|
||||
except OSError as exc:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name) # noqa: F821
|
||||
raise ConfigWriteError(
|
||||
f"Failed to write {local_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
log.info(
|
||||
"jail_action_appended",
|
||||
jail=jail_name,
|
||||
action=action_entry,
|
||||
path=str(local_path),
|
||||
)
|
||||
|
||||
|
||||
def _remove_jail_action_sync(
|
||||
config_dir: Path,
|
||||
jail_name: str,
|
||||
action_name: str,
|
||||
) -> None:
|
||||
"""Remove an action entry from the ``action`` key in ``jail.d/{jail_name}.local``.
|
||||
|
||||
Reads the ``.local`` file, removes any ``action`` entries whose base name
|
||||
matches *action_name*, and writes the result back atomically. If no
|
||||
``.local`` file exists, this is a no-op.
|
||||
|
||||
Args:
|
||||
config_dir: The fail2ban configuration root directory.
|
||||
jail_name: Validated jail name.
|
||||
action_name: Base name of the action to remove (without ``[…]``).
|
||||
|
||||
Raises:
|
||||
ConfigWriteError: If writing fails.
|
||||
"""
|
||||
jail_d = config_dir / "jail.d"
|
||||
local_path = jail_d / f"{jail_name}.local"
|
||||
|
||||
if not local_path.is_file():
|
||||
return
|
||||
|
||||
parser = _build_parser()
|
||||
try:
|
||||
parser.read(str(local_path), encoding="utf-8")
|
||||
except (configparser.Error, OSError) as exc:
|
||||
log.warning(
|
||||
"jail_local_read_for_update_error",
|
||||
jail=jail_name,
|
||||
error=str(exc),
|
||||
)
|
||||
return
|
||||
|
||||
if not parser.has_section(jail_name) or not parser.has_option(jail_name, "action"):
|
||||
return
|
||||
|
||||
existing_raw = parser.get(jail_name, "action")
|
||||
existing_lines = [
|
||||
line.strip()
|
||||
for line in existing_raw.splitlines()
|
||||
if line.strip() and not line.strip().startswith("#")
|
||||
]
|
||||
|
||||
def _base(entry: str) -> str:
|
||||
bracket = entry.find("[")
|
||||
return entry[:bracket].strip() if bracket != -1 else entry.strip()
|
||||
|
||||
filtered = [e for e in existing_lines if _base(e) != action_name]
|
||||
|
||||
if len(filtered) == len(existing_lines):
|
||||
# Action was not found — silently return (idempotent).
|
||||
return
|
||||
|
||||
if filtered:
|
||||
new_value = filtered[0] + "".join(
|
||||
f"\n {line}" for line in filtered[1:]
|
||||
)
|
||||
parser.set(jail_name, "action", new_value)
|
||||
else:
|
||||
parser.remove_option(jail_name, "action")
|
||||
|
||||
buf = io.StringIO()
|
||||
buf.write("# Managed by BanGUI — do not edit manually\n\n")
|
||||
parser.write(buf)
|
||||
content = buf.getvalue()
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=jail_d,
|
||||
delete=False,
|
||||
suffix=".tmp",
|
||||
) as tmp:
|
||||
tmp.write(content)
|
||||
tmp_name = tmp.name
|
||||
os.replace(tmp_name, local_path)
|
||||
except OSError as exc:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name) # noqa: F821
|
||||
raise ConfigWriteError(
|
||||
f"Failed to write {local_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
log.info(
|
||||
"jail_action_removed",
|
||||
jail=jail_name,
|
||||
action=action_name,
|
||||
path=str(local_path),
|
||||
)
|
||||
|
||||
|
||||
def _write_action_local_sync(action_d: Path, name: str, content: str) -> None:
|
||||
"""Write *content* to ``action.d/{name}.local`` atomically.
|
||||
|
||||
The write is atomic: content is written to a temp file first, then
|
||||
renamed into place. The ``action.d/`` directory is created if absent.
|
||||
|
||||
Args:
|
||||
action_d: Path to the ``action.d`` directory.
|
||||
name: Validated action base name (used as filename stem).
|
||||
content: Full serialized action content to write.
|
||||
|
||||
Raises:
|
||||
ConfigWriteError: If writing fails.
|
||||
"""
|
||||
try:
|
||||
action_d.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise ConfigWriteError(
|
||||
f"Cannot create action.d directory: {exc}"
|
||||
) from exc
|
||||
|
||||
local_path = action_d / f"{name}.local"
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=action_d,
|
||||
delete=False,
|
||||
suffix=".tmp",
|
||||
) as tmp:
|
||||
tmp.write(content)
|
||||
tmp_name = tmp.name
|
||||
os.replace(tmp_name, local_path)
|
||||
except OSError as exc:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name) # noqa: F821
|
||||
raise ConfigWriteError(
|
||||
f"Failed to write {local_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
log.info("action_local_written", action=name, path=str(local_path))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — action discovery (Task 3.1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def list_actions(
|
||||
config_dir: str,
|
||||
socket_path: str,
|
||||
) -> ActionListResponse:
|
||||
"""Return all available actions from ``action.d/`` with active/inactive status.
|
||||
|
||||
Scans ``{config_dir}/action.d/`` for ``.conf`` files, merges any
|
||||
corresponding ``.local`` overrides, parses each file into an
|
||||
:class:`~app.models.config.ActionConfig`, and cross-references with the
|
||||
currently running jails to determine which actions are active.
|
||||
|
||||
An action is considered *active* when its base name appears in the
|
||||
``action`` field of at least one currently running jail.
|
||||
|
||||
Args:
|
||||
config_dir: Absolute path to the fail2ban configuration directory.
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.ActionListResponse` with all actions
|
||||
sorted alphabetically, active ones carrying non-empty
|
||||
``used_by_jails`` lists.
|
||||
"""
|
||||
action_d = Path(config_dir) / "action.d"
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
raw_actions: list[tuple[str, str, str, bool, str]] = await loop.run_in_executor(
|
||||
None, _parse_actions_sync, action_d
|
||||
)
|
||||
|
||||
all_jails_result, active_names = await asyncio.gather(
|
||||
loop.run_in_executor(None, _parse_jails_sync, Path(config_dir)),
|
||||
_get_active_jail_names(socket_path),
|
||||
)
|
||||
all_jails, _source_files = all_jails_result
|
||||
|
||||
action_to_jails = _build_action_to_jails_map(all_jails, active_names)
|
||||
|
||||
actions: list[ActionConfig] = []
|
||||
for name, filename, content, has_local, source_path in raw_actions:
|
||||
cfg = conffile_parser.parse_action_file(
|
||||
content, name=name, filename=filename
|
||||
)
|
||||
used_by = sorted(action_to_jails.get(name, []))
|
||||
actions.append(
|
||||
ActionConfig(
|
||||
name=cfg.name,
|
||||
filename=cfg.filename,
|
||||
before=cfg.before,
|
||||
after=cfg.after,
|
||||
actionstart=cfg.actionstart,
|
||||
actionstop=cfg.actionstop,
|
||||
actioncheck=cfg.actioncheck,
|
||||
actionban=cfg.actionban,
|
||||
actionunban=cfg.actionunban,
|
||||
actionflush=cfg.actionflush,
|
||||
definition_vars=cfg.definition_vars,
|
||||
init_vars=cfg.init_vars,
|
||||
active=len(used_by) > 0,
|
||||
used_by_jails=used_by,
|
||||
source_file=source_path,
|
||||
has_local_override=has_local,
|
||||
)
|
||||
)
|
||||
|
||||
log.info("actions_listed", total=len(actions), active=sum(1 for a in actions if a.active))
|
||||
return ActionListResponse(actions=actions, total=len(actions))
|
||||
|
||||
|
||||
async def get_action(
|
||||
config_dir: str,
|
||||
socket_path: str,
|
||||
name: str,
|
||||
) -> ActionConfig:
|
||||
"""Return a single action from ``action.d/`` with active/inactive status.
|
||||
|
||||
Reads ``{config_dir}/action.d/{name}.conf``, merges any ``.local``
|
||||
override, and enriches the parsed :class:`~app.models.config.ActionConfig`
|
||||
with ``active``, ``used_by_jails``, ``source_file``, and
|
||||
``has_local_override``.
|
||||
|
||||
Args:
|
||||
config_dir: Absolute path to the fail2ban configuration directory.
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Action base name (e.g. ``"iptables"`` or ``"iptables.conf"``).
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.ActionConfig` with status fields populated.
|
||||
|
||||
Raises:
|
||||
ActionNotFoundError: If no ``{name}.conf`` or ``{name}.local`` file
|
||||
exists in ``action.d/``.
|
||||
"""
|
||||
if name.endswith(".conf"):
|
||||
base_name = name[:-5]
|
||||
elif name.endswith(".local"):
|
||||
base_name = name[:-6]
|
||||
else:
|
||||
base_name = name
|
||||
|
||||
action_d = Path(config_dir) / "action.d"
|
||||
conf_path = action_d / f"{base_name}.conf"
|
||||
local_path = action_d / f"{base_name}.local"
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _read() -> tuple[str, bool, str]:
|
||||
"""Read action content and return (content, has_local_override, source_path)."""
|
||||
has_local = local_path.is_file()
|
||||
if conf_path.is_file():
|
||||
content = conf_path.read_text(encoding="utf-8")
|
||||
if has_local:
|
||||
try:
|
||||
content += "\n" + local_path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
log.warning(
|
||||
"action_local_read_error",
|
||||
name=base_name,
|
||||
path=str(local_path),
|
||||
error=str(exc),
|
||||
)
|
||||
return content, has_local, str(conf_path)
|
||||
elif has_local:
|
||||
content = local_path.read_text(encoding="utf-8")
|
||||
return content, False, str(local_path)
|
||||
else:
|
||||
raise ActionNotFoundError(base_name)
|
||||
|
||||
content, has_local, source_path = await loop.run_in_executor(None, _read)
|
||||
|
||||
cfg = conffile_parser.parse_action_file(
|
||||
content, name=base_name, filename=f"{base_name}.conf"
|
||||
)
|
||||
|
||||
all_jails_result, active_names = await asyncio.gather(
|
||||
loop.run_in_executor(None, _parse_jails_sync, Path(config_dir)),
|
||||
_get_active_jail_names(socket_path),
|
||||
)
|
||||
all_jails, _source_files = all_jails_result
|
||||
action_to_jails = _build_action_to_jails_map(all_jails, active_names)
|
||||
|
||||
used_by = sorted(action_to_jails.get(base_name, []))
|
||||
log.info("action_fetched", name=base_name, active=len(used_by) > 0)
|
||||
return ActionConfig(
|
||||
name=cfg.name,
|
||||
filename=cfg.filename,
|
||||
before=cfg.before,
|
||||
after=cfg.after,
|
||||
actionstart=cfg.actionstart,
|
||||
actionstop=cfg.actionstop,
|
||||
actioncheck=cfg.actioncheck,
|
||||
actionban=cfg.actionban,
|
||||
actionunban=cfg.actionunban,
|
||||
actionflush=cfg.actionflush,
|
||||
definition_vars=cfg.definition_vars,
|
||||
init_vars=cfg.init_vars,
|
||||
active=len(used_by) > 0,
|
||||
used_by_jails=used_by,
|
||||
source_file=source_path,
|
||||
has_local_override=has_local,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API — action write operations (Task 3.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def update_action(
|
||||
config_dir: str,
|
||||
socket_path: str,
|
||||
name: str,
|
||||
req: ActionUpdateRequest,
|
||||
do_reload: bool = False,
|
||||
) -> ActionConfig:
|
||||
"""Update an action's ``.local`` override with new lifecycle command values.
|
||||
|
||||
Reads the current merged configuration for *name* (``conf`` + any existing
|
||||
``local``), applies the non-``None`` fields in *req* on top of it, and
|
||||
writes the resulting definition to ``action.d/{name}.local``. The
|
||||
original ``.conf`` file is never modified.
|
||||
|
||||
Args:
|
||||
config_dir: Absolute path to the fail2ban configuration directory.
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Action base name (e.g. ``"iptables"`` or ``"iptables.conf"``).
|
||||
req: Partial update — only non-``None`` fields are applied.
|
||||
do_reload: When ``True``, trigger a full fail2ban reload after writing.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.ActionConfig` reflecting the updated state.
|
||||
|
||||
Raises:
|
||||
ActionNameError: If *name* contains invalid characters.
|
||||
ActionNotFoundError: If no ``{name}.conf`` or ``{name}.local`` exists.
|
||||
ConfigWriteError: If writing the ``.local`` file fails.
|
||||
"""
|
||||
base_name = name[:-5] if name.endswith((".conf", ".local")) else name
|
||||
_safe_action_name(base_name)
|
||||
|
||||
current = await get_action(config_dir, socket_path, base_name)
|
||||
|
||||
update = ActionConfigUpdate(
|
||||
actionstart=req.actionstart,
|
||||
actionstop=req.actionstop,
|
||||
actioncheck=req.actioncheck,
|
||||
actionban=req.actionban,
|
||||
actionunban=req.actionunban,
|
||||
actionflush=req.actionflush,
|
||||
definition_vars=req.definition_vars,
|
||||
init_vars=req.init_vars,
|
||||
)
|
||||
|
||||
merged = conffile_parser.merge_action_update(current, update)
|
||||
content = conffile_parser.serialize_action_config(merged)
|
||||
|
||||
action_d = Path(config_dir) / "action.d"
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _write_action_local_sync, action_d, base_name, content)
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_action_update_failed",
|
||||
action=base_name,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
log.info("action_updated", action=base_name, reload=do_reload)
|
||||
return await get_action(config_dir, socket_path, base_name)
|
||||
|
||||
|
||||
async def create_action(
|
||||
config_dir: str,
|
||||
socket_path: str,
|
||||
req: ActionCreateRequest,
|
||||
do_reload: bool = False,
|
||||
) -> ActionConfig:
|
||||
"""Create a brand-new user-defined action in ``action.d/{name}.local``.
|
||||
|
||||
No ``.conf`` is written; fail2ban loads ``.local`` files directly. If a
|
||||
``.conf`` or ``.local`` file already exists for the requested name, an
|
||||
:class:`ActionAlreadyExistsError` is raised.
|
||||
|
||||
Args:
|
||||
config_dir: Absolute path to the fail2ban configuration directory.
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
req: Action name and definition fields.
|
||||
do_reload: When ``True``, trigger a full fail2ban reload after writing.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.ActionConfig` for the newly created action.
|
||||
|
||||
Raises:
|
||||
ActionNameError: If ``req.name`` contains invalid characters.
|
||||
ActionAlreadyExistsError: If a ``.conf`` or ``.local`` already exists.
|
||||
ConfigWriteError: If writing fails.
|
||||
"""
|
||||
_safe_action_name(req.name)
|
||||
|
||||
action_d = Path(config_dir) / "action.d"
|
||||
conf_path = action_d / f"{req.name}.conf"
|
||||
local_path = action_d / f"{req.name}.local"
|
||||
|
||||
def _check_not_exists() -> None:
|
||||
if conf_path.is_file() or local_path.is_file():
|
||||
raise ActionAlreadyExistsError(req.name)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _check_not_exists)
|
||||
|
||||
cfg = ActionConfig(
|
||||
name=req.name,
|
||||
filename=f"{req.name}.local",
|
||||
actionstart=req.actionstart,
|
||||
actionstop=req.actionstop,
|
||||
actioncheck=req.actioncheck,
|
||||
actionban=req.actionban,
|
||||
actionunban=req.actionunban,
|
||||
actionflush=req.actionflush,
|
||||
definition_vars=req.definition_vars,
|
||||
init_vars=req.init_vars,
|
||||
)
|
||||
content = conffile_parser.serialize_action_config(cfg)
|
||||
|
||||
await loop.run_in_executor(None, _write_action_local_sync, action_d, req.name, content)
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_action_create_failed",
|
||||
action=req.name,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
log.info("action_created", action=req.name, reload=do_reload)
|
||||
return await get_action(config_dir, socket_path, req.name)
|
||||
|
||||
|
||||
async def delete_action(
|
||||
config_dir: str,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Delete a user-created action's ``.local`` file.
|
||||
|
||||
Deletion rules:
|
||||
- If only a ``.conf`` file exists (shipped default, no user override) →
|
||||
:class:`ActionReadonlyError`.
|
||||
- If a ``.local`` file exists (whether or not a ``.conf`` also exists) →
|
||||
only the ``.local`` file is deleted.
|
||||
- If neither file exists → :class:`ActionNotFoundError`.
|
||||
|
||||
Args:
|
||||
config_dir: Absolute path to the fail2ban configuration directory.
|
||||
name: Action base name (e.g. ``"iptables"``).
|
||||
|
||||
Raises:
|
||||
ActionNameError: If *name* contains invalid characters.
|
||||
ActionNotFoundError: If no action file is found for *name*.
|
||||
ActionReadonlyError: If only a shipped ``.conf`` exists (no ``.local``).
|
||||
ConfigWriteError: If deletion of the ``.local`` file fails.
|
||||
"""
|
||||
base_name = name[:-5] if name.endswith((".conf", ".local")) else name
|
||||
_safe_action_name(base_name)
|
||||
|
||||
action_d = Path(config_dir) / "action.d"
|
||||
conf_path = action_d / f"{base_name}.conf"
|
||||
local_path = action_d / f"{base_name}.local"
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _delete() -> None:
|
||||
has_conf = conf_path.is_file()
|
||||
has_local = local_path.is_file()
|
||||
|
||||
if not has_conf and not has_local:
|
||||
raise ActionNotFoundError(base_name)
|
||||
|
||||
if has_conf and not has_local:
|
||||
raise ActionReadonlyError(base_name)
|
||||
|
||||
try:
|
||||
local_path.unlink()
|
||||
except OSError as exc:
|
||||
raise ConfigWriteError(
|
||||
f"Failed to delete {local_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
log.info("action_local_deleted", action=base_name, path=str(local_path))
|
||||
|
||||
await loop.run_in_executor(None, _delete)
|
||||
|
||||
|
||||
async def assign_action_to_jail(
|
||||
config_dir: str,
|
||||
socket_path: str,
|
||||
jail_name: str,
|
||||
req: AssignActionRequest,
|
||||
do_reload: bool = False,
|
||||
) -> None:
|
||||
"""Add an action to a jail by updating the jail's ``.local`` file.
|
||||
|
||||
Appends ``{req.action_name}[{params}]`` (or just ``{req.action_name}`` when
|
||||
no params are given) to the ``action`` key in the ``[{jail_name}]`` section
|
||||
of ``jail.d/{jail_name}.local``. If the action is already listed it is not
|
||||
duplicated. If the ``.local`` file does not exist it is created.
|
||||
|
||||
Args:
|
||||
config_dir: Absolute path to the fail2ban configuration directory.
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
jail_name: Name of the jail to update.
|
||||
req: Request containing the action name and optional parameters.
|
||||
do_reload: When ``True``, trigger a full fail2ban reload after writing.
|
||||
|
||||
Raises:
|
||||
JailNameError: If *jail_name* contains invalid characters.
|
||||
ActionNameError: If ``req.action_name`` contains invalid characters.
|
||||
JailNotFoundInConfigError: If *jail_name* is not defined in any config
|
||||
file.
|
||||
ActionNotFoundError: If ``req.action_name`` does not exist in
|
||||
``action.d/``.
|
||||
ConfigWriteError: If writing fails.
|
||||
"""
|
||||
_safe_jail_name(jail_name)
|
||||
_safe_action_name(req.action_name)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
all_jails, _src = await loop.run_in_executor(
|
||||
None, _parse_jails_sync, Path(config_dir)
|
||||
)
|
||||
if jail_name not in all_jails:
|
||||
raise JailNotFoundInConfigError(jail_name)
|
||||
|
||||
action_d = Path(config_dir) / "action.d"
|
||||
|
||||
def _check_action() -> None:
|
||||
if (
|
||||
not (action_d / f"{req.action_name}.conf").is_file()
|
||||
and not (action_d / f"{req.action_name}.local").is_file()
|
||||
):
|
||||
raise ActionNotFoundError(req.action_name)
|
||||
|
||||
await loop.run_in_executor(None, _check_action)
|
||||
|
||||
# Build the action string with optional parameters.
|
||||
if req.params:
|
||||
param_str = ", ".join(f"{k}={v}" for k, v in sorted(req.params.items()))
|
||||
action_entry = f"{req.action_name}[{param_str}]"
|
||||
else:
|
||||
action_entry = req.action_name
|
||||
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
_append_jail_action_sync,
|
||||
Path(config_dir),
|
||||
jail_name,
|
||||
action_entry,
|
||||
)
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_assign_action_failed",
|
||||
jail=jail_name,
|
||||
action=req.action_name,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
log.info(
|
||||
"action_assigned_to_jail",
|
||||
jail=jail_name,
|
||||
action=req.action_name,
|
||||
reload=do_reload,
|
||||
)
|
||||
|
||||
|
||||
async def remove_action_from_jail(
|
||||
config_dir: str,
|
||||
socket_path: str,
|
||||
jail_name: str,
|
||||
action_name: str,
|
||||
do_reload: bool = False,
|
||||
) -> None:
|
||||
"""Remove an action from a jail's ``.local`` config.
|
||||
|
||||
Reads ``jail.d/{jail_name}.local``, removes the line(s) that reference
|
||||
``{action_name}`` from the ``action`` key (including any ``[…]`` parameter
|
||||
blocks), and writes the file back atomically.
|
||||
|
||||
Args:
|
||||
config_dir: Absolute path to the fail2ban configuration directory.
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
jail_name: Name of the jail to update.
|
||||
action_name: Base name of the action to remove.
|
||||
do_reload: When ``True``, trigger a full fail2ban reload after writing.
|
||||
|
||||
Raises:
|
||||
JailNameError: If *jail_name* contains invalid characters.
|
||||
ActionNameError: If *action_name* contains invalid characters.
|
||||
JailNotFoundInConfigError: If *jail_name* is not defined in any config.
|
||||
ConfigWriteError: If writing fails.
|
||||
"""
|
||||
_safe_jail_name(jail_name)
|
||||
_safe_action_name(action_name)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
all_jails, _src = await loop.run_in_executor(
|
||||
None, _parse_jails_sync, Path(config_dir)
|
||||
)
|
||||
if jail_name not in all_jails:
|
||||
raise JailNotFoundInConfigError(jail_name)
|
||||
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
_remove_jail_action_sync,
|
||||
Path(config_dir),
|
||||
jail_name,
|
||||
action_name,
|
||||
)
|
||||
|
||||
if do_reload:
|
||||
try:
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"reload_after_remove_action_failed",
|
||||
jail=jail_name,
|
||||
action=action_name,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
log.info(
|
||||
"action_removed_from_jail",
|
||||
jail=jail_name,
|
||||
action=action_name,
|
||||
reload=do_reload,
|
||||
)
|
||||
|
||||
|
||||
@@ -1289,3 +1289,425 @@ class TestAssignFilterToJail:
|
||||
).post("/api/config/jails/sshd/filter", json={"filter_name": "sshd"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Action router tests (Task 3.1 + 3.2)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestListActionsRouter:
|
||||
async def test_200_returns_action_list(self, config_client: AsyncClient) -> None:
|
||||
from app.models.config import ActionConfig, ActionListResponse
|
||||
|
||||
mock_action = ActionConfig(
|
||||
name="iptables",
|
||||
filename="iptables.conf",
|
||||
actionban="/sbin/iptables -I f2b-<name> 1 -s <ip> -j DROP",
|
||||
)
|
||||
mock_response = ActionListResponse(actions=[mock_action], total=1)
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.list_actions",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await config_client.get("/api/config/actions")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["actions"][0]["name"] == "iptables"
|
||||
|
||||
async def test_active_sorted_first(self, config_client: AsyncClient) -> None:
|
||||
from app.models.config import ActionConfig, ActionListResponse
|
||||
|
||||
inactive = ActionConfig(name="aaa", filename="aaa.conf", active=False)
|
||||
active = ActionConfig(name="zzz", filename="zzz.conf", active=True)
|
||||
mock_response = ActionListResponse(actions=[inactive, active], total=2)
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.list_actions",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await config_client.get("/api/config/actions")
|
||||
|
||||
data = resp.json()
|
||||
assert data["actions"][0]["name"] == "zzz" # active comes first
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).get("/api/config/actions")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetActionRouter:
|
||||
async def test_200_returns_action(self, config_client: AsyncClient) -> None:
|
||||
from app.models.config import ActionConfig
|
||||
|
||||
mock_action = ActionConfig(
|
||||
name="iptables",
|
||||
filename="iptables.conf",
|
||||
actionban="/sbin/iptables -I f2b-<name> 1 -s <ip> -j DROP",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.get_action",
|
||||
AsyncMock(return_value=mock_action),
|
||||
):
|
||||
resp = await config_client.get("/api/config/actions/iptables")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "iptables"
|
||||
|
||||
async def test_404_when_not_found(self, config_client: AsyncClient) -> None:
|
||||
from app.services.config_file_service import ActionNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.get_action",
|
||||
AsyncMock(side_effect=ActionNotFoundError("missing")),
|
||||
):
|
||||
resp = await config_client.get("/api/config/actions/missing")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).get("/api/config/actions/iptables")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdateActionRouter:
|
||||
async def test_200_returns_updated_action(self, config_client: AsyncClient) -> None:
|
||||
from app.models.config import ActionConfig
|
||||
|
||||
updated = ActionConfig(
|
||||
name="iptables",
|
||||
filename="iptables.local",
|
||||
actionban="echo ban",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.update_action",
|
||||
AsyncMock(return_value=updated),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
"/api/config/actions/iptables",
|
||||
json={"actionban": "echo ban"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["actionban"] == "echo ban"
|
||||
|
||||
async def test_404_when_not_found(self, config_client: AsyncClient) -> None:
|
||||
from app.services.config_file_service import ActionNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.update_action",
|
||||
AsyncMock(side_effect=ActionNotFoundError("missing")),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
"/api/config/actions/missing", json={}
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_400_for_bad_name(self, config_client: AsyncClient) -> None:
|
||||
from app.services.config_file_service import ActionNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.update_action",
|
||||
AsyncMock(side_effect=ActionNameError()),
|
||||
):
|
||||
resp = await config_client.put(
|
||||
"/api/config/actions/badname", json={}
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).put("/api/config/actions/iptables", json={})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreateActionRouter:
|
||||
async def test_201_returns_created_action(self, config_client: AsyncClient) -> None:
|
||||
from app.models.config import ActionConfig
|
||||
|
||||
created = ActionConfig(
|
||||
name="custom",
|
||||
filename="custom.local",
|
||||
actionban="echo ban",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.create_action",
|
||||
AsyncMock(return_value=created),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/actions",
|
||||
json={"name": "custom", "actionban": "echo ban"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["name"] == "custom"
|
||||
|
||||
async def test_409_when_already_exists(self, config_client: AsyncClient) -> None:
|
||||
from app.services.config_file_service import ActionAlreadyExistsError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.create_action",
|
||||
AsyncMock(side_effect=ActionAlreadyExistsError("iptables")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/actions",
|
||||
json={"name": "iptables"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
|
||||
async def test_400_for_bad_name(self, config_client: AsyncClient) -> None:
|
||||
from app.services.config_file_service import ActionNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.create_action",
|
||||
AsyncMock(side_effect=ActionNameError()),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/actions",
|
||||
json={"name": "badname"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).post("/api/config/actions", json={"name": "x"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDeleteActionRouter:
|
||||
async def test_204_on_delete(self, config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.delete_action",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await config_client.delete("/api/config/actions/custom")
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
async def test_404_when_not_found(self, config_client: AsyncClient) -> None:
|
||||
from app.services.config_file_service import ActionNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.delete_action",
|
||||
AsyncMock(side_effect=ActionNotFoundError("missing")),
|
||||
):
|
||||
resp = await config_client.delete("/api/config/actions/missing")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_409_when_readonly(self, config_client: AsyncClient) -> None:
|
||||
from app.services.config_file_service import ActionReadonlyError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.delete_action",
|
||||
AsyncMock(side_effect=ActionReadonlyError("iptables")),
|
||||
):
|
||||
resp = await config_client.delete("/api/config/actions/iptables")
|
||||
|
||||
assert resp.status_code == 409
|
||||
|
||||
async def test_400_for_bad_name(self, config_client: AsyncClient) -> None:
|
||||
from app.services.config_file_service import ActionNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.delete_action",
|
||||
AsyncMock(side_effect=ActionNameError()),
|
||||
):
|
||||
resp = await config_client.delete("/api/config/actions/badname")
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).delete("/api/config/actions/iptables")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAssignActionToJailRouter:
|
||||
async def test_204_on_success(self, config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.assign_action_to_jail",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/jails/sshd/action",
|
||||
json={"action_name": "iptables"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
async def test_404_when_jail_not_found(self, config_client: AsyncClient) -> None:
|
||||
from app.services.config_file_service import JailNotFoundInConfigError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.assign_action_to_jail",
|
||||
AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/jails/missing/action",
|
||||
json={"action_name": "iptables"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_404_when_action_not_found(self, config_client: AsyncClient) -> None:
|
||||
from app.services.config_file_service import ActionNotFoundError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.assign_action_to_jail",
|
||||
AsyncMock(side_effect=ActionNotFoundError("missing")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/jails/sshd/action",
|
||||
json={"action_name": "missing"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_400_for_bad_jail_name(self, config_client: AsyncClient) -> None:
|
||||
from app.services.config_file_service import JailNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.assign_action_to_jail",
|
||||
AsyncMock(side_effect=JailNameError()),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/jails/badjailname/action",
|
||||
json={"action_name": "iptables"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_400_for_bad_action_name(self, config_client: AsyncClient) -> None:
|
||||
from app.services.config_file_service import ActionNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.assign_action_to_jail",
|
||||
AsyncMock(side_effect=ActionNameError()),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/jails/sshd/action",
|
||||
json={"action_name": "badaction"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_reload_param_passed(self, config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.assign_action_to_jail",
|
||||
AsyncMock(return_value=None),
|
||||
) as mock_assign:
|
||||
resp = await config_client.post(
|
||||
"/api/config/jails/sshd/action?reload=true",
|
||||
json={"action_name": "iptables"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
assert mock_assign.call_args.kwargs.get("do_reload") is True
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).post("/api/config/jails/sshd/action", json={"action_name": "iptables"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestRemoveActionFromJailRouter:
|
||||
async def test_204_on_success(self, config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.remove_action_from_jail",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
resp = await config_client.delete(
|
||||
"/api/config/jails/sshd/action/iptables"
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
|
||||
async def test_404_when_jail_not_found(self, config_client: AsyncClient) -> None:
|
||||
from app.services.config_file_service import JailNotFoundInConfigError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.remove_action_from_jail",
|
||||
AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
|
||||
):
|
||||
resp = await config_client.delete(
|
||||
"/api/config/jails/missing/action/iptables"
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_400_for_bad_jail_name(self, config_client: AsyncClient) -> None:
|
||||
from app.services.config_file_service import JailNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.remove_action_from_jail",
|
||||
AsyncMock(side_effect=JailNameError()),
|
||||
):
|
||||
resp = await config_client.delete(
|
||||
"/api/config/jails/badjailname/action/iptables"
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_400_for_bad_action_name(self, config_client: AsyncClient) -> None:
|
||||
from app.services.config_file_service import ActionNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.remove_action_from_jail",
|
||||
AsyncMock(side_effect=ActionNameError()),
|
||||
):
|
||||
resp = await config_client.delete(
|
||||
"/api/config/jails/sshd/action/badactionname"
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_reload_param_passed(self, config_client: AsyncClient) -> None:
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.remove_action_from_jail",
|
||||
AsyncMock(return_value=None),
|
||||
) as mock_rm:
|
||||
resp = await config_client.delete(
|
||||
"/api/config/jails/sshd/action/iptables?reload=true"
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
assert mock_rm.call_args.kwargs.get("do_reload") is True
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).delete("/api/config/jails/sshd/action/iptables")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user