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