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:
2026-03-13 19:12:31 +01:00
parent 2f60b0915e
commit f7cc130432
6 changed files with 2866 additions and 3 deletions

View File

@@ -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.

View File

@@ -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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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