Extract jail, filter, and action configuration management into separate domain-focused service modules: - jail_config_service.py: Jail activation, deactivation, validation, rollback - filter_config_service.py: Filter discovery, CRUD, assignment to jails - action_config_service.py: Action discovery, CRUD, assignment to jails Benefits: - Reduces monolithic 3100-line module into three focused modules - Improves readability and maintainability per domain - Clearer separation of concerns following single responsibility principle - Easier to test domain-specific functionality in isolation - Reduces coupling - each service only depends on its needed utilities Changes: - Create three new service modules under backend/app/services/ - Update backend/app/routers/config.py to import from new modules - Update exception and function imports to source from appropriate service - Update Architecture.md to reflect new service organization - All existing tests continue to pass with new module structure Relates to Task 4 of refactoring backlog in Docs/Tasks.md
1072 lines
35 KiB
Python
1072 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.services import jail_service
|
|
from app.services.config_file_service import (
|
|
_parse_jails_sync,
|
|
_get_active_jail_names,
|
|
ConfigWriteError,
|
|
JailNotFoundInConfigError,
|
|
)
|
|
from app.utils import conffile_parser
|
|
|
|
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 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.
|
|
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 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.
|
|
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 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,
|
|
)
|