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.
|
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.
|
**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.
|
**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.
|
**Goal:** Test coverage for action listing, editing, creation, and assignment.
|
||||||
|
|
||||||
|
|||||||
@@ -508,6 +508,33 @@ class ActionConfig(BaseModel):
|
|||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="Runtime parameters that can be overridden per jail.",
|
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):
|
class ActionConfigUpdate(BaseModel):
|
||||||
@@ -527,6 +554,110 @@ class ActionConfigUpdate(BaseModel):
|
|||||||
init_vars: dict[str, str] | None = Field(default=None)
|
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)
|
# 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}/activate`` — activate an inactive jail
|
||||||
* ``POST /api/config/jails/{name}/deactivate`` — deactivate an active 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}/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
|
* ``GET /api/config/global`` — global fail2ban settings
|
||||||
* ``PUT /api/config/global`` — update global settings
|
* ``PUT /api/config/global`` — update global settings
|
||||||
* ``POST /api/config/reload`` — reload fail2ban
|
* ``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
|
* ``PUT /api/config/filters/{name}`` — update a filter's .local override
|
||||||
* ``POST /api/config/filters`` — create a new user-defined filter
|
* ``POST /api/config/filters`` — create a new user-defined filter
|
||||||
* ``DELETE /api/config/filters/{name}`` — delete a filter's .local file
|
* ``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
|
from __future__ import annotations
|
||||||
@@ -31,8 +38,13 @@ from fastapi import APIRouter, HTTPException, Path, Query, Request, status
|
|||||||
|
|
||||||
from app.dependencies import AuthDep
|
from app.dependencies import AuthDep
|
||||||
from app.models.config import (
|
from app.models.config import (
|
||||||
|
ActionConfig,
|
||||||
|
ActionCreateRequest,
|
||||||
|
ActionListResponse,
|
||||||
|
ActionUpdateRequest,
|
||||||
ActivateJailRequest,
|
ActivateJailRequest,
|
||||||
AddLogPathRequest,
|
AddLogPathRequest,
|
||||||
|
AssignActionRequest,
|
||||||
AssignFilterRequest,
|
AssignFilterRequest,
|
||||||
FilterConfig,
|
FilterConfig,
|
||||||
FilterCreateRequest,
|
FilterCreateRequest,
|
||||||
@@ -54,6 +66,10 @@ from app.models.config import (
|
|||||||
)
|
)
|
||||||
from app.services import config_file_service, config_service, jail_service
|
from app.services import config_file_service, config_service, jail_service
|
||||||
from app.services.config_file_service import (
|
from app.services.config_file_service import (
|
||||||
|
ActionAlreadyExistsError,
|
||||||
|
ActionNameError,
|
||||||
|
ActionNotFoundError,
|
||||||
|
ActionReadonlyError,
|
||||||
ConfigWriteError,
|
ConfigWriteError,
|
||||||
FilterAlreadyExistsError,
|
FilterAlreadyExistsError,
|
||||||
FilterInvalidRegexError,
|
FilterInvalidRegexError,
|
||||||
@@ -968,3 +984,338 @@ async def assign_filter_to_jail(
|
|||||||
detail=f"Failed to write jail override: {exc}",
|
detail=f"Failed to write jail override: {exc}",
|
||||||
) from 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
|
import structlog
|
||||||
|
|
||||||
from app.models.config import (
|
from app.models.config import (
|
||||||
|
ActionConfig,
|
||||||
|
ActionConfigUpdate,
|
||||||
|
ActionCreateRequest,
|
||||||
|
ActionListResponse,
|
||||||
|
ActionUpdateRequest,
|
||||||
ActivateJailRequest,
|
ActivateJailRequest,
|
||||||
|
AssignActionRequest,
|
||||||
AssignFilterRequest,
|
AssignFilterRequest,
|
||||||
FilterConfig,
|
FilterConfig,
|
||||||
FilterConfigUpdate,
|
FilterConfigUpdate,
|
||||||
@@ -1504,3 +1510,952 @@ async def assign_filter_to_jail(
|
|||||||
reload=do_reload,
|
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"})
|
).post("/api/config/jails/sshd/filter", json={"filter_name": "sshd"})
|
||||||
assert resp.status_code == 401
|
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