Files
BanGUI/backend/app/services/action_config_service.py
2026-03-22 10:07:44 +01:00

1071 lines
35 KiB
Python

"""Action configuration management for BanGUI.
Handles parsing, validation, and lifecycle operations (create/update/delete)
for fail2ban action configurations.
"""
from __future__ import annotations
import asyncio
import configparser
import contextlib
import io
import os
import re
import tempfile
from pathlib import Path
import structlog
from app.models.config import (
ActionConfig,
ActionConfigUpdate,
ActionCreateRequest,
ActionListResponse,
ActionUpdateRequest,
AssignActionRequest,
)
from app.exceptions import JailNotFoundError
from app.utils.config_file_utils import (
_parse_jails_sync,
_get_active_jail_names,
)
from app.exceptions import ConfigWriteError, JailNotFoundInConfigError
from app.utils import conffile_parser
from app.utils.jail_utils import reload_jails
log: structlog.stdlib.BoundLogger = structlog.get_logger()
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
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."""
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_SOCKET_TIMEOUT: float = 10.0
# 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}$")
# Allowlist pattern for jail names used in path construction.
_SAFE_JAIL_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
# Sections that are not jail definitions.
_META_SECTIONS: frozenset[str] = frozenset({"INCLUDES", "DEFAULT"})
# True-ish values for the ``enabled`` key.
_TRUE_VALUES: frozenset[str] = frozenset({"true", "yes", "1"})
# False-ish values for the ``enabled`` key.
_FALSE_VALUES: frozenset[str] = frozenset({"false", "no", "0"})
# ---------------------------------------------------------------------------
# Helper exceptions
# ---------------------------------------------------------------------------
class JailNameError(Exception):
"""Raised when a jail name contains invalid characters."""
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _safe_jail_name(name: str) -> str:
"""Validate *name* and return it unchanged or raise :class:`JailNameError`.
Args:
name: Proposed jail name.
Returns:
The name unchanged if valid.
Raises:
JailNameError: If *name* contains unsafe characters.
"""
if not _SAFE_JAIL_NAME_RE.match(name):
raise JailNameError(
f"Jail 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_parser() -> configparser.RawConfigParser:
"""Create a :class:`configparser.RawConfigParser` for fail2ban configs.
Returns:
Parser with interpolation disabled and case-sensitive option names.
"""
parser = configparser.RawConfigParser(interpolation=None, strict=False)
# fail2ban keys are lowercase but preserve case to be safe.
parser.optionxform = str # type: ignore[assignment]
return parser
def _is_truthy(value: str) -> bool:
"""Return ``True`` if *value* is a fail2ban boolean true string.
Args:
value: Raw string from config (e.g. ``"true"``, ``"yes"``, ``"1"``).
Returns:
``True`` when the value represents enabled.
"""
return value.strip().lower() in _TRUE_VALUES
def _parse_multiline(raw: str) -> list[str]:
"""Split a multi-line INI value into individual non-blank lines.
Args:
raw: Raw multi-line string from configparser.
Returns:
List of stripped, non-empty, non-comment strings.
"""
result: list[str] = []
for line in raw.splitlines():
stripped = line.strip()
if stripped and not stripped.startswith("#"):
result.append(stripped)
return result
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
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 _extract_action_base_name(action_str: str) -> str | None:
"""Return the base action name from an action assignment string.
Returns ``None`` for complex fail2ban expressions that cannot be resolved
to a single filename (e.g. ``%(action_)s`` interpolations or multi-token
composite actions).
Args:
action_str: A single line from the jail's ``action`` setting.
Returns:
Simple base name suitable for a filesystem lookup, or ``None``.
"""
if "%" in action_str or "$" in action_str:
return None
base = action_str.split("[")[0].strip()
if _SAFE_ACTION_NAME_RE.match(base):
return base
return None
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
# ---------------------------------------------------------------------------
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
# ---------------------------------------------------------------------------
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 reload_jails(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 reload_jails(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.
JailNotFoundError: 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 reload_jails(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.
JailNotFoundError: 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 reload_jails(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,
)