Files
BanGUI/backend/app/services/config_file_service.py
Lukas 4be2469f92 Implement tasks 1-3: sidebar order, jail activation rollback, pie chart colors
Task 1: Move Configuration to last position in sidebar NAV_ITEMS

Task 2: Add automatic rollback when jail activation fails
- Back up .local override file before writing
- Restore original file (or delete) on reload failure, health-check
  failure, or jail not appearing post-reload
- Return recovered=True/False in JailActivationResponse
- Show warning/critical banner in ActivateJailDialog based on recovery
- Add _restore_local_file_sync and _rollback_activation_async helpers
- Add 3 new tests: rollback on reload failure, health-check failure,
  and double failure (recovered=False)

Task 3: Color pie chart legend labels to match their slice color
- legendFormatter now returns ReactNode with span style={{ color }}
- Import LegendPayload from recharts/types/component/DefaultLegendContent
2026-03-14 21:16:58 +01:00

3132 lines
104 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Fail2ban jail configuration file parser and activator.
Parses the full set of fail2ban jail configuration files
(``jail.conf``, ``jail.local``, ``jail.d/*.conf``, ``jail.d/*.local``)
to discover all defined jails — both active and inactive — and provides
functions to activate or deactivate them by writing ``.local`` override
files.
Merge order (fail2ban convention):
1. ``jail.conf``
2. ``jail.local``
3. ``jail.d/*.conf`` (alphabetical)
4. ``jail.d/*.local`` (alphabetical)
Security note: the ``activate_jail`` and ``deactivate_jail`` callers must
supply a validated jail name. This module validates the name against an
allowlist pattern before constructing any filesystem paths to prevent
directory traversal.
"""
from __future__ import annotations
import asyncio
import configparser
import contextlib
import io
import os
import re
import tempfile
from pathlib import Path
from typing import Any
import structlog
from app.models.config import (
ActionConfig,
ActionConfigUpdate,
ActionCreateRequest,
ActionListResponse,
ActionUpdateRequest,
ActivateJailRequest,
AssignActionRequest,
AssignFilterRequest,
BantimeEscalation,
FilterConfig,
FilterConfigUpdate,
FilterCreateRequest,
FilterListResponse,
FilterUpdateRequest,
InactiveJail,
InactiveJailListResponse,
JailActivationResponse,
JailValidationIssue,
JailValidationResult,
RollbackResponse,
)
from app.services import conffile_parser, jail_service
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError
log: structlog.stdlib.BoundLogger = structlog.get_logger()
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_SOCKET_TIMEOUT: float = 10.0
# 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"})
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class JailNotFoundInConfigError(Exception):
"""Raised when the requested jail name is not defined in any config file."""
def __init__(self, name: str) -> None:
"""Initialise with the jail name that was not found.
Args:
name: The jail name that could not be located.
"""
self.name: str = name
super().__init__(f"Jail not found in config files: {name!r}")
class JailAlreadyActiveError(Exception):
"""Raised when trying to activate a jail that is already active."""
def __init__(self, name: str) -> None:
"""Initialise with the jail name.
Args:
name: The jail that is already active.
"""
self.name: str = name
super().__init__(f"Jail is already active: {name!r}")
class JailAlreadyInactiveError(Exception):
"""Raised when trying to deactivate a jail that is already inactive."""
def __init__(self, name: str) -> None:
"""Initialise with the jail name.
Args:
name: The jail that is already inactive.
"""
self.name: str = name
super().__init__(f"Jail is already inactive: {name!r}")
class JailNameError(Exception):
"""Raised when a jail name contains invalid characters."""
class ConfigWriteError(Exception):
"""Raised when writing a ``.local`` override file fails."""
class FilterNameError(Exception):
"""Raised when a filter name contains invalid characters."""
class FilterAlreadyExistsError(Exception):
"""Raised when trying to create a filter whose ``.conf`` or ``.local`` already exists."""
def __init__(self, name: str) -> None:
"""Initialise with the filter name that already exists.
Args:
name: The filter name that already exists.
"""
self.name: str = name
super().__init__(f"Filter already exists: {name!r}")
class FilterReadonlyError(Exception):
"""Raised when trying to delete a shipped ``.conf`` filter with no ``.local`` override."""
def __init__(self, name: str) -> None:
"""Initialise with the filter name that cannot be deleted.
Args:
name: The filter name that is read-only (shipped ``.conf`` only).
"""
self.name: str = name
super().__init__(
f"Filter {name!r} is a shipped default (.conf only); "
"only user-created .local files can be deleted."
)
class FilterInvalidRegexError(Exception):
"""Raised when a regex pattern fails to compile."""
def __init__(self, pattern: str, error: str) -> None:
"""Initialise with the invalid pattern and the compile error.
Args:
pattern: The regex string that failed to compile.
error: The ``re.error`` message.
"""
self.pattern: str = pattern
self.error: str = error
super().__init__(f"Invalid regex {pattern!r}: {error}")
# ---------------------------------------------------------------------------
# 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 _safe_filter_name(name: str) -> str:
"""Validate *name* and return it unchanged or raise :class:`FilterNameError`.
Args:
name: Proposed filter name (without extension).
Returns:
The name unchanged if valid.
Raises:
FilterNameError: If *name* contains unsafe characters.
"""
if not _SAFE_FILTER_NAME_RE.match(name):
raise FilterNameError(
f"Filter name {name!r} contains invalid characters. "
"Only alphanumeric characters, hyphens, underscores, and dots are "
"allowed; must start with an alphanumeric character."
)
return name
def _ordered_config_files(config_dir: Path) -> list[Path]:
"""Return all jail config files in fail2ban merge order.
Args:
config_dir: The fail2ban configuration root directory.
Returns:
List of paths in ascending priority order (later entries override
earlier ones).
"""
files: list[Path] = []
jail_conf = config_dir / "jail.conf"
if jail_conf.is_file():
files.append(jail_conf)
jail_local = config_dir / "jail.local"
if jail_local.is_file():
files.append(jail_local)
jail_d = config_dir / "jail.d"
if jail_d.is_dir():
files.extend(sorted(jail_d.glob("*.conf")))
files.extend(sorted(jail_d.glob("*.local")))
return files
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_int_safe(value: str) -> int | None:
"""Parse *value* as int, returning ``None`` on failure.
Args:
value: Raw string to parse.
Returns:
Integer value, or ``None``.
"""
try:
return int(value.strip())
except (ValueError, AttributeError):
return None
def _parse_time_to_seconds(value: str | None, default: int) -> int:
"""Convert a fail2ban time string (e.g. ``1h``, ``10m``, ``3600``) to seconds.
Supports the suffixes ``s`` (seconds), ``m`` (minutes), ``h`` (hours),
``d`` (days), ``w`` (weeks), and plain integers (already seconds).
``-1`` is treated as a permanent ban and returned as-is.
Args:
value: Raw time string from config, or ``None``.
default: Value to return when ``value`` is absent or unparseable.
Returns:
Duration in seconds, or ``-1`` for permanent, or ``default`` on failure.
"""
if not value:
return default
stripped = value.strip()
if stripped == "-1":
return -1
multipliers: dict[str, int] = {
"w": 604800,
"d": 86400,
"h": 3600,
"m": 60,
"s": 1,
}
for suffix, factor in multipliers.items():
if stripped.endswith(suffix) and len(stripped) > 1:
try:
return int(stripped[:-1]) * factor
except ValueError:
return default
try:
return int(stripped)
except ValueError:
return default
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
def _resolve_filter(raw_filter: str, jail_name: str, mode: str) -> str:
"""Resolve fail2ban variable placeholders in a filter string.
Handles the common default ``%(__name__)s[mode=%(mode)s]`` pattern that
fail2ban uses so the filter name displayed to the user is readable.
Args:
raw_filter: Raw ``filter`` value from config (may contain ``%()s``).
jail_name: The jail's section name, used to substitute ``%(__name__)s``.
mode: The jail's ``mode`` value, used to substitute ``%(mode)s``.
Returns:
Human-readable filter string.
"""
result = raw_filter.replace("%(__name__)s", jail_name)
result = result.replace("%(mode)s", mode)
return result
def _parse_jails_sync(
config_dir: Path,
) -> tuple[dict[str, dict[str, str]], dict[str, str]]:
"""Synchronously parse all jail configs and return merged definitions.
This is a CPU-bound / IO-bound sync function; callers must dispatch to
an executor for async use.
Args:
config_dir: The fail2ban configuration root directory.
Returns:
A two-tuple ``(jails, source_files)`` where:
- ``jails``: ``{jail_name: {key: value}}`` merged settings for each
jail with DEFAULT values already applied.
- ``source_files``: ``{jail_name: str(path)}`` path of the file that
last defined each jail section (for display in the UI).
"""
parser = _build_parser()
files = _ordered_config_files(config_dir)
# Track which file each section came from (last write wins).
source_files: dict[str, str] = {}
for path in files:
try:
single = _build_parser()
single.read(str(path), encoding="utf-8")
for section in single.sections():
if section not in _META_SECTIONS:
source_files[section] = str(path)
except (configparser.Error, OSError) as exc:
log.warning("jail_config_read_error", path=str(path), error=str(exc))
# Full merged parse: configparser applies DEFAULT values to every section.
try:
parser.read([str(p) for p in files], encoding="utf-8")
except configparser.Error as exc:
log.warning("jail_config_parse_error", error=str(exc))
jails: dict[str, dict[str, str]] = {}
for section in parser.sections():
if section in _META_SECTIONS:
continue
try:
# items() merges DEFAULT values automatically.
jails[section] = dict(parser.items(section))
except configparser.Error as exc:
log.warning(
"jail_section_parse_error", section=section, error=str(exc)
)
log.debug("jails_parsed", count=len(jails), config_dir=str(config_dir))
return jails, source_files
def _build_inactive_jail(
name: str,
settings: dict[str, str],
source_file: str,
) -> InactiveJail:
"""Construct an :class:`~app.models.config.InactiveJail` from raw settings.
Args:
name: Jail section name.
settings: Merged key→value dict (DEFAULT values already applied).
source_file: Path of the file that last defined this section.
Returns:
Populated :class:`~app.models.config.InactiveJail`.
"""
raw_filter = settings.get("filter", "")
mode = settings.get("mode", "normal")
filter_name = _resolve_filter(raw_filter, name, mode) if raw_filter else name
raw_action = settings.get("action", "")
actions = _parse_multiline(raw_action) if raw_action else []
raw_logpath = settings.get("logpath", "")
logpath = _parse_multiline(raw_logpath) if raw_logpath else []
enabled_raw = settings.get("enabled", "false")
enabled = _is_truthy(enabled_raw)
maxretry_raw = settings.get("maxretry", "")
maxretry = _parse_int_safe(maxretry_raw)
# Extended fields for full GUI display
ban_time_seconds = _parse_time_to_seconds(settings.get("bantime"), 600)
find_time_seconds = _parse_time_to_seconds(settings.get("findtime"), 600)
log_encoding = settings.get("logencoding") or "auto"
backend = settings.get("backend") or "auto"
date_pattern = settings.get("datepattern") or None
use_dns = settings.get("usedns") or "warn"
prefregex = settings.get("prefregex") or ""
fail_regex = _parse_multiline(settings.get("failregex", ""))
ignore_regex = _parse_multiline(settings.get("ignoreregex", ""))
# Ban-time escalation
esc_increment = _is_truthy(settings.get("bantime.increment", "false"))
esc_factor_raw = settings.get("bantime.factor")
esc_factor = float(esc_factor_raw) if esc_factor_raw else None
esc_formula = settings.get("bantime.formula") or None
esc_multipliers = settings.get("bantime.multipliers") or None
esc_max_raw = settings.get("bantime.maxtime")
esc_max_time = _parse_time_to_seconds(esc_max_raw, 0) if esc_max_raw else None
esc_rnd_raw = settings.get("bantime.rndtime")
esc_rnd_time = _parse_time_to_seconds(esc_rnd_raw, 0) if esc_rnd_raw else None
esc_overall = _is_truthy(settings.get("bantime.overalljails", "false"))
bantime_escalation = (
BantimeEscalation(
increment=esc_increment,
factor=esc_factor,
formula=esc_formula,
multipliers=esc_multipliers,
max_time=esc_max_time,
rnd_time=esc_rnd_time,
overall_jails=esc_overall,
)
if esc_increment
else None
)
return InactiveJail(
name=name,
filter=filter_name,
actions=actions,
port=settings.get("port") or None,
logpath=logpath,
bantime=settings.get("bantime") or None,
findtime=settings.get("findtime") or None,
maxretry=maxretry,
ban_time_seconds=ban_time_seconds,
find_time_seconds=find_time_seconds,
log_encoding=log_encoding,
backend=backend,
date_pattern=date_pattern,
use_dns=use_dns,
prefregex=prefregex,
fail_regex=fail_regex,
ignore_regex=ignore_regex,
bantime_escalation=bantime_escalation,
source_file=source_file,
enabled=enabled,
)
async def _get_active_jail_names(socket_path: str) -> set[str]:
"""Fetch the set of currently running jail names from fail2ban.
Returns an empty set gracefully if fail2ban is unreachable.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Returns:
Set of active jail names, or empty set on connection failure.
"""
try:
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
def _to_dict_inner(pairs: Any) -> dict[str, Any]:
if not isinstance(pairs, (list, tuple)):
return {}
result: dict[str, Any] = {}
for item in pairs:
try:
k, v = item
result[str(k)] = v
except (TypeError, ValueError):
pass
return result
def _ok(response: Any) -> Any:
code, data = response
if code != 0:
raise ValueError(f"fail2ban error {code}: {data!r}")
return data
status_raw = _ok(await client.send(["status"]))
status_dict = _to_dict_inner(status_raw)
jail_list_raw: str = str(status_dict.get("Jail list", "") or "").strip()
if not jail_list_raw:
return set()
return {j.strip() for j in jail_list_raw.split(",") if j.strip()}
except Fail2BanConnectionError:
log.warning("fail2ban_unreachable_during_inactive_list")
return set()
except Exception as exc: # noqa: BLE001
log.warning(
"fail2ban_status_error_during_inactive_list", error=str(exc)
)
return set()
# ---------------------------------------------------------------------------
# Validation helpers (Task 3)
# ---------------------------------------------------------------------------
# Seconds to wait between fail2ban liveness probes after a reload.
_POST_RELOAD_PROBE_INTERVAL: float = 2.0
# Maximum number of post-reload probe attempts (initial attempt + retries).
_POST_RELOAD_MAX_ATTEMPTS: int = 4
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 _validate_jail_config_sync(
config_dir: Path,
name: str,
) -> JailValidationResult:
"""Run synchronous pre-activation checks on a jail configuration.
Validates:
1. Filter file existence in ``filter.d/``.
2. Action file existence in ``action.d/`` (for resolvable action names).
3. Regex compilation for every ``failregex`` and ``ignoreregex`` pattern.
4. Log path existence on disk (generates warnings, not errors).
Args:
config_dir: The fail2ban configuration root directory.
name: Validated jail name.
Returns:
:class:`~app.models.config.JailValidationResult` with any issues found.
"""
issues: list[JailValidationIssue] = []
all_jails, _ = _parse_jails_sync(config_dir)
settings = all_jails.get(name)
if settings is None:
return JailValidationResult(
jail_name=name,
valid=False,
issues=[
JailValidationIssue(
field="name",
message=f"Jail {name!r} not found in config files.",
)
],
)
filter_d = config_dir / "filter.d"
action_d = config_dir / "action.d"
# 1. Filter existence check.
raw_filter = settings.get("filter", "")
if raw_filter:
mode = settings.get("mode", "normal")
resolved = _resolve_filter(raw_filter, name, mode)
base_filter = _extract_filter_base_name(resolved)
if base_filter:
conf_ok = (filter_d / f"{base_filter}.conf").is_file()
local_ok = (filter_d / f"{base_filter}.local").is_file()
if not conf_ok and not local_ok:
issues.append(
JailValidationIssue(
field="filter",
message=(
f"Filter file not found: filter.d/{base_filter}.conf"
" (or .local)"
),
)
)
# 2. Action existence check.
raw_action = settings.get("action", "")
if raw_action:
for action_line in _parse_multiline(raw_action):
action_name = _extract_action_base_name(action_line)
if action_name:
conf_ok = (action_d / f"{action_name}.conf").is_file()
local_ok = (action_d / f"{action_name}.local").is_file()
if not conf_ok and not local_ok:
issues.append(
JailValidationIssue(
field="action",
message=(
f"Action file not found: action.d/{action_name}.conf"
" (or .local)"
),
)
)
# 3. failregex compilation.
for pattern in _parse_multiline(settings.get("failregex", "")):
try:
re.compile(pattern)
except re.error as exc:
issues.append(
JailValidationIssue(
field="failregex",
message=f"Invalid regex pattern: {exc}",
)
)
# 4. ignoreregex compilation.
for pattern in _parse_multiline(settings.get("ignoreregex", "")):
try:
re.compile(pattern)
except re.error as exc:
issues.append(
JailValidationIssue(
field="ignoreregex",
message=f"Invalid regex pattern: {exc}",
)
)
# 5. Log path existence (warning only — paths may be created at runtime).
raw_logpath = settings.get("logpath", "")
if raw_logpath:
for log_path in _parse_multiline(raw_logpath):
# Skip glob patterns and fail2ban variable references.
if "*" in log_path or "?" in log_path or "%(" in log_path:
continue
if not Path(log_path).exists():
issues.append(
JailValidationIssue(
field="logpath",
message=f"Log file not found on disk: {log_path}",
)
)
valid = len(issues) == 0
log.debug(
"jail_validation_complete",
jail=name,
valid=valid,
issue_count=len(issues),
)
return JailValidationResult(jail_name=name, valid=valid, issues=issues)
async def _probe_fail2ban_running(socket_path: str) -> bool:
"""Return ``True`` if the fail2ban socket responds to a ping.
Args:
socket_path: Path to the fail2ban Unix domain socket.
Returns:
``True`` when fail2ban is reachable, ``False`` otherwise.
"""
try:
client = Fail2BanClient(socket_path=socket_path, timeout=5.0)
resp = await client.send(["ping"])
return isinstance(resp, (list, tuple)) and resp[0] == 0
except Exception: # noqa: BLE001
return False
async def _wait_for_fail2ban(
socket_path: str,
max_wait_seconds: float = 10.0,
poll_interval: float = 2.0,
) -> bool:
"""Poll the fail2ban socket until it responds or the timeout expires.
Args:
socket_path: Path to the fail2ban Unix domain socket.
max_wait_seconds: Total time budget in seconds.
poll_interval: Delay between probe attempts in seconds.
Returns:
``True`` if fail2ban came online within the budget.
"""
elapsed = 0.0
while elapsed < max_wait_seconds:
if await _probe_fail2ban_running(socket_path):
return True
await asyncio.sleep(poll_interval)
elapsed += poll_interval
return False
async def _start_daemon(start_cmd_parts: list[str]) -> bool:
"""Start the fail2ban daemon using *start_cmd_parts*.
Uses :func:`asyncio.create_subprocess_exec` (no shell interpretation)
to avoid command injection.
Args:
start_cmd_parts: Command and arguments, e.g.
``["fail2ban-client", "start"]``.
Returns:
``True`` when the process exited with code 0.
"""
if not start_cmd_parts:
log.warning("fail2ban_start_cmd_empty")
return False
try:
proc = await asyncio.create_subprocess_exec(
*start_cmd_parts,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await asyncio.wait_for(proc.wait(), timeout=30.0)
success = proc.returncode == 0
if not success:
log.warning(
"fail2ban_start_cmd_nonzero",
cmd=start_cmd_parts,
returncode=proc.returncode,
)
return success
except (TimeoutError, OSError) as exc:
log.warning("fail2ban_start_cmd_error", cmd=start_cmd_parts, error=str(exc))
return False
def _write_local_override_sync(
config_dir: Path,
jail_name: str,
enabled: bool,
overrides: dict[str, Any],
) -> None:
"""Write a ``jail.d/{name}.local`` file atomically.
Always writes to ``jail.d/{jail_name}.local``. If the file already
exists it is replaced entirely. The write is atomic: content is
written to a temp file first, then renamed into place.
Args:
config_dir: The fail2ban configuration root directory.
jail_name: Validated jail name (used as filename stem).
enabled: Value to write for ``enabled =``.
overrides: Optional setting overrides (bantime, findtime, maxretry,
port, logpath).
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"
lines: list[str] = [
"# Managed by BanGUI — do not edit manually",
"",
f"[{jail_name}]",
"",
f"enabled = {'true' if enabled else 'false'}",
# Provide explicit banaction defaults so fail2ban can resolve the
# %(banaction)s interpolation used in the built-in action_ chain.
"banaction = iptables-multiport",
"banaction_allports = iptables-allports",
]
if overrides.get("bantime") is not None:
lines.append(f"bantime = {overrides['bantime']}")
if overrides.get("findtime") is not None:
lines.append(f"findtime = {overrides['findtime']}")
if overrides.get("maxretry") is not None:
lines.append(f"maxretry = {overrides['maxretry']}")
if overrides.get("port") is not None:
lines.append(f"port = {overrides['port']}")
if overrides.get("logpath"):
paths: list[str] = overrides["logpath"]
if paths:
lines.append(f"logpath = {paths[0]}")
for p in paths[1:]:
lines.append(f" {p}")
content = "\n".join(lines) + "\n"
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:
# Clean up temp file if rename failed.
with contextlib.suppress(OSError):
os.unlink(tmp_name) # noqa: F821 — only reachable when tmp_name is set
raise ConfigWriteError(
f"Failed to write {local_path}: {exc}"
) from exc
log.info(
"jail_local_written",
jail=jail_name,
path=str(local_path),
enabled=enabled,
)
def _restore_local_file_sync(local_path: Path, original_content: bytes | None) -> None:
"""Restore a ``.local`` file to its pre-activation state.
If *original_content* is ``None``, the file is deleted (it did not exist
before the activation). Otherwise the original bytes are written back
atomically via a temp-file rename.
Args:
local_path: Absolute path to the ``.local`` file to restore.
original_content: Original raw bytes to write back, or ``None`` to
delete the file.
Raises:
ConfigWriteError: If the write or delete operation fails.
"""
if original_content is None:
try:
local_path.unlink(missing_ok=True)
except OSError as exc:
raise ConfigWriteError(
f"Failed to delete {local_path} during rollback: {exc}"
) from exc
return
tmp_name: str | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
dir=local_path.parent,
delete=False,
suffix=".tmp",
) as tmp:
tmp.write(original_content)
tmp_name = tmp.name
os.replace(tmp_name, local_path)
except OSError as exc:
with contextlib.suppress(OSError):
if tmp_name is not None:
os.unlink(tmp_name)
raise ConfigWriteError(
f"Failed to restore {local_path} during rollback: {exc}"
) from exc
def _validate_regex_patterns(patterns: list[str]) -> None:
"""Validate each pattern in *patterns* using Python's ``re`` module.
Args:
patterns: List of regex strings to validate.
Raises:
FilterInvalidRegexError: If any pattern fails to compile.
"""
for pattern in patterns:
try:
re.compile(pattern)
except re.error as exc:
raise FilterInvalidRegexError(pattern, str(exc)) from exc
def _write_filter_local_sync(filter_d: Path, name: str, content: str) -> None:
"""Write *content* to ``filter.d/{name}.local`` atomically.
The write is atomic: content is written to a temp file first, then
renamed into place. The ``filter.d/`` directory is created if absent.
Args:
filter_d: Path to the ``filter.d`` directory.
name: Validated filter base name (used as filename stem).
content: Full serialized filter content to write.
Raises:
ConfigWriteError: If writing fails.
"""
try:
filter_d.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise ConfigWriteError(
f"Cannot create filter.d directory: {exc}"
) from exc
local_path = filter_d / f"{name}.local"
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=filter_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("filter_local_written", filter=name, path=str(local_path))
def _set_jail_local_key_sync(
config_dir: Path,
jail_name: str,
key: str,
value: str,
) -> None:
"""Update ``jail.d/{jail_name}.local`` to set a single key in the jail section.
If the ``.local`` file already exists it is read, the key is updated (or
added), and the file is written back atomically without disturbing other
settings. If the file does not exist a new one is created containing
only the BanGUI header comment, the jail section, and the requested key.
Args:
config_dir: The fail2ban configuration root directory.
jail_name: Validated jail name (used as section name and filename stem).
key: Config key to set inside the jail section.
value: Config value to assign.
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)
parser.set(jail_name, key, value)
# Serialize: write a BanGUI header then the parser output.
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_local_key_set",
jail=jail_name,
key=key,
path=str(local_path),
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def list_inactive_jails(
config_dir: str,
socket_path: str,
) -> InactiveJailListResponse:
"""Return all jails defined in config files that are not currently active.
Parses ``jail.conf``, ``jail.local``, and ``jail.d/`` following the
fail2ban merge order. A jail is considered inactive when:
- Its merged ``enabled`` value is ``false`` (or absent, which defaults to
``false`` in fail2ban), **or**
- Its ``enabled`` value is ``true`` in config but fail2ban does not report
it as running.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
Returns:
:class:`~app.models.config.InactiveJailListResponse` with all
inactive jails.
"""
loop = asyncio.get_event_loop()
parsed_result: tuple[dict[str, dict[str, str]], dict[str, str]] = (
await loop.run_in_executor(None, _parse_jails_sync, Path(config_dir))
)
all_jails, source_files = parsed_result
active_names: set[str] = await _get_active_jail_names(socket_path)
inactive: list[InactiveJail] = []
for jail_name, settings in sorted(all_jails.items()):
if jail_name in active_names:
# fail2ban reports this jail as running — skip it.
continue
source = source_files.get(jail_name, config_dir)
inactive.append(_build_inactive_jail(jail_name, settings, source))
log.info(
"inactive_jails_listed",
total_defined=len(all_jails),
active=len(active_names),
inactive=len(inactive),
)
return InactiveJailListResponse(jails=inactive, total=len(inactive))
async def activate_jail(
config_dir: str,
socket_path: str,
name: str,
req: ActivateJailRequest,
) -> JailActivationResponse:
"""Enable an inactive jail and reload fail2ban.
Performs pre-activation validation, writes ``enabled = true`` (plus any
override values from *req*) to ``jail.d/{name}.local``, and triggers a
full fail2ban reload. After the reload a multi-attempt health probe
determines whether fail2ban (and the specific jail) are still running.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
name: Name of the jail to activate. Must exist in the parsed config.
req: Optional override values to write alongside ``enabled = true``.
Returns:
:class:`~app.models.config.JailActivationResponse` including
``fail2ban_running`` and ``validation_warnings`` fields.
Raises:
JailNameError: If *name* contains invalid characters.
JailNotFoundInConfigError: If *name* is not defined in any config file.
JailAlreadyActiveError: If fail2ban already reports *name* as running.
ConfigWriteError: If writing the ``.local`` file fails.
~app.utils.fail2ban_client.Fail2BanConnectionError: If the fail2ban
socket is unreachable during reload.
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
all_jails, _source_files = await loop.run_in_executor(
None, _parse_jails_sync, Path(config_dir)
)
if name not in all_jails:
raise JailNotFoundInConfigError(name)
active_names = await _get_active_jail_names(socket_path)
if name in active_names:
raise JailAlreadyActiveError(name)
# ---------------------------------------------------------------------- #
# Pre-activation validation — collect warnings but do not block #
# ---------------------------------------------------------------------- #
validation_result: JailValidationResult = await loop.run_in_executor(
None, _validate_jail_config_sync, Path(config_dir), name
)
warnings: list[str] = [f"{i.field}: {i.message}" for i in validation_result.issues]
if warnings:
log.warning(
"jail_activation_validation_warnings",
jail=name,
warnings=warnings,
)
# Block activation on critical validation failures (missing filter or logpath).
blocking = [i for i in validation_result.issues if i.field in ("filter", "logpath")]
if blocking:
log.warning(
"jail_activation_blocked",
jail=name,
issues=[f"{i.field}: {i.message}" for i in blocking],
)
return JailActivationResponse(
name=name,
active=False,
fail2ban_running=True,
validation_warnings=warnings,
message=(
f"Jail {name!r} cannot be activated: "
+ "; ".join(i.message for i in blocking)
),
)
overrides: dict[str, Any] = {
"bantime": req.bantime,
"findtime": req.findtime,
"maxretry": req.maxretry,
"port": req.port,
"logpath": req.logpath,
}
# ---------------------------------------------------------------------- #
# Backup the existing .local file (if any) before overwriting it so that #
# we can restore it if activation fails. #
# ---------------------------------------------------------------------- #
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
original_content: bytes | None = await loop.run_in_executor(
None,
lambda: local_path.read_bytes() if local_path.exists() else None,
)
await loop.run_in_executor(
None,
_write_local_override_sync,
Path(config_dir),
name,
True,
overrides,
)
# ---------------------------------------------------------------------- #
# Activation reload — if it fails, roll back immediately #
# ---------------------------------------------------------------------- #
try:
await jail_service.reload_all(socket_path, include_jails=[name])
except Exception as exc: # noqa: BLE001
log.warning("reload_after_activate_failed", jail=name, error=str(exc))
recovered = await _rollback_activation_async(
config_dir, name, socket_path, original_content
)
return JailActivationResponse(
name=name,
active=False,
fail2ban_running=False,
recovered=recovered,
validation_warnings=warnings,
message=(
f"Jail {name!r} activation failed during reload and the "
"configuration was "
+ ("automatically recovered." if recovered else "not recovered — manual intervention is required.")
),
)
# ---------------------------------------------------------------------- #
# Post-reload health probe with retries #
# ---------------------------------------------------------------------- #
fail2ban_running = False
for attempt in range(_POST_RELOAD_MAX_ATTEMPTS):
if attempt > 0:
await asyncio.sleep(_POST_RELOAD_PROBE_INTERVAL)
if await _probe_fail2ban_running(socket_path):
fail2ban_running = True
break
if not fail2ban_running:
log.warning(
"fail2ban_down_after_activate",
jail=name,
message="fail2ban socket unreachable after reload — initiating rollback.",
)
recovered = await _rollback_activation_async(
config_dir, name, socket_path, original_content
)
return JailActivationResponse(
name=name,
active=False,
fail2ban_running=False,
recovered=recovered,
validation_warnings=warnings,
message=(
f"Jail {name!r} activation failed: fail2ban stopped responding "
"after reload. The configuration was "
+ ("automatically recovered." if recovered else "not recovered — manual intervention is required.")
),
)
# Verify the jail actually started (config error may prevent it silently).
post_reload_names = await _get_active_jail_names(socket_path)
actually_running = name in post_reload_names
if not actually_running:
log.warning(
"jail_activation_unverified",
jail=name,
message="Jail did not appear in running jails — initiating rollback.",
)
recovered = await _rollback_activation_async(
config_dir, name, socket_path, original_content
)
return JailActivationResponse(
name=name,
active=False,
fail2ban_running=True,
recovered=recovered,
validation_warnings=warnings,
message=(
f"Jail {name!r} was written to config but did not start after "
"reload. The configuration was "
+ ("automatically recovered." if recovered else "not recovered — manual intervention is required.")
),
)
log.info("jail_activated", jail=name)
return JailActivationResponse(
name=name,
active=True,
fail2ban_running=True,
validation_warnings=warnings,
message=f"Jail {name!r} activated successfully.",
)
async def _rollback_activation_async(
config_dir: str,
name: str,
socket_path: str,
original_content: bytes | None,
) -> bool:
"""Restore the pre-activation ``.local`` file and reload fail2ban.
Called internally by :func:`activate_jail` when the activation fails after
the config file was already written. Tries to:
1. Restore the original file content (or delete the file if it was newly
created by the activation attempt).
2. Reload fail2ban so the daemon runs with the restored configuration.
3. Probe fail2ban to confirm it came back up.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
name: Name of the jail whose ``.local`` file should be restored.
socket_path: Path to the fail2ban Unix domain socket.
original_content: Raw bytes of the original ``.local`` file, or
``None`` if the file did not exist before the activation.
Returns:
``True`` if fail2ban is responsive again after the rollback, ``False``
if recovery also failed.
"""
loop = asyncio.get_event_loop()
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
# Step 1 — restore original file (or delete it).
try:
await loop.run_in_executor(
None, _restore_local_file_sync, local_path, original_content
)
log.info("jail_activation_rollback_file_restored", jail=name)
except ConfigWriteError as exc:
log.error(
"jail_activation_rollback_restore_failed", jail=name, error=str(exc)
)
return False
# Step 2 — reload fail2ban with the restored config.
try:
await jail_service.reload_all(socket_path)
log.info("jail_activation_rollback_reload_ok", jail=name)
except Exception as exc: # noqa: BLE001
log.warning(
"jail_activation_rollback_reload_failed", jail=name, error=str(exc)
)
return False
# Step 3 — wait for fail2ban to come back.
for attempt in range(_POST_RELOAD_MAX_ATTEMPTS):
if attempt > 0:
await asyncio.sleep(_POST_RELOAD_PROBE_INTERVAL)
if await _probe_fail2ban_running(socket_path):
log.info("jail_activation_rollback_recovered", jail=name)
return True
log.warning("jail_activation_rollback_still_down", jail=name)
return False
async def deactivate_jail(
config_dir: str,
socket_path: str,
name: str,
) -> JailActivationResponse:
"""Disable an active jail and reload fail2ban.
Writes ``enabled = false`` to ``jail.d/{name}.local`` and triggers a
full fail2ban reload so the jail stops immediately.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
name: Name of the jail to deactivate. Must exist in the parsed config.
Returns:
:class:`~app.models.config.JailActivationResponse`.
Raises:
JailNameError: If *name* contains invalid characters.
JailNotFoundInConfigError: If *name* is not defined in any config file.
JailAlreadyInactiveError: If fail2ban already reports *name* as not
running.
ConfigWriteError: If writing the ``.local`` file fails.
~app.utils.fail2ban_client.Fail2BanConnectionError: If the fail2ban
socket is unreachable during reload.
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
all_jails, _source_files = await loop.run_in_executor(
None, _parse_jails_sync, Path(config_dir)
)
if name not in all_jails:
raise JailNotFoundInConfigError(name)
active_names = await _get_active_jail_names(socket_path)
if name not in active_names:
raise JailAlreadyInactiveError(name)
await loop.run_in_executor(
None,
_write_local_override_sync,
Path(config_dir),
name,
False,
{},
)
try:
await jail_service.reload_all(socket_path, exclude_jails=[name])
except Exception as exc: # noqa: BLE001
log.warning("reload_after_deactivate_failed", jail=name, error=str(exc))
log.info("jail_deactivated", jail=name)
return JailActivationResponse(
name=name,
active=False,
message=f"Jail {name!r} deactivated successfully.",
)
async def validate_jail_config(
config_dir: str,
name: str,
) -> JailValidationResult:
"""Run pre-activation validation checks on a jail configuration.
Validates that referenced filter and action files exist in ``filter.d/``
and ``action.d/``, that all regex patterns compile, and that declared log
paths exist on disk.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
name: Name of the jail to validate.
Returns:
:class:`~app.models.config.JailValidationResult` with any issues found.
Raises:
JailNameError: If *name* contains invalid characters.
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
_validate_jail_config_sync,
Path(config_dir),
name,
)
async def rollback_jail(
config_dir: str,
socket_path: str,
name: str,
start_cmd_parts: list[str],
) -> RollbackResponse:
"""Disable a bad jail config and restart the fail2ban daemon.
Writes ``enabled = false`` to ``jail.d/{name}.local`` (works even when
fail2ban is down — only a file write), then attempts to start the daemon
with *start_cmd_parts*. Waits up to 10 seconds for the socket to respond.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
name: Name of the jail to disable.
start_cmd_parts: Argument list for the daemon start command, e.g.
``["fail2ban-client", "start"]``.
Returns:
:class:`~app.models.config.RollbackResponse`.
Raises:
JailNameError: If *name* contains invalid characters.
ConfigWriteError: If writing the ``.local`` file fails.
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
# Write enabled=false — this must succeed even when fail2ban is down.
await loop.run_in_executor(
None,
_write_local_override_sync,
Path(config_dir),
name,
False,
{},
)
log.info("jail_rolled_back_disabled", jail=name)
# Attempt to start the daemon.
started = await _start_daemon(start_cmd_parts)
log.info("jail_rollback_start_attempted", jail=name, start_ok=started)
# Wait for the socket to come back.
fail2ban_running = await _wait_for_fail2ban(
socket_path, max_wait_seconds=10.0, poll_interval=2.0
)
active_jails = 0
if fail2ban_running:
names = await _get_active_jail_names(socket_path)
active_jails = len(names)
if fail2ban_running:
log.info("jail_rollback_success", jail=name, active_jails=active_jails)
return RollbackResponse(
jail_name=name,
disabled=True,
fail2ban_running=True,
active_jails=active_jails,
message=(
f"Jail {name!r} disabled and fail2ban restarted successfully "
f"with {active_jails} active jail(s)."
),
)
log.warning("jail_rollback_fail2ban_still_down", jail=name)
return RollbackResponse(
jail_name=name,
disabled=True,
fail2ban_running=False,
active_jails=0,
message=(
f"Jail {name!r} was disabled but fail2ban did not come back online. "
"Check the fail2ban log for additional errors."
),
)
# ---------------------------------------------------------------------------
# Filter discovery helpers (Task 2.1)
# ---------------------------------------------------------------------------
# Allowlist pattern for filter names used in path construction.
_SAFE_FILTER_NAME_RE: re.Pattern[str] = re.compile(
r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
)
class FilterNotFoundError(Exception):
"""Raised when the requested filter name is not found in ``filter.d/``."""
def __init__(self, name: str) -> None:
"""Initialise with the filter name that was not found.
Args:
name: The filter name that could not be located.
"""
self.name: str = name
super().__init__(f"Filter not found: {name!r}")
def _extract_filter_base_name(filter_raw: str) -> str:
"""Extract the base filter name from a raw fail2ban filter string.
fail2ban jail configs may specify a filter with an optional mode suffix,
e.g. ``sshd``, ``sshd[mode=aggressive]``, or
``%(__name__)s[mode=%(mode)s]``. This function strips the ``[…]`` mode
block and any leading/trailing whitespace to return just the file-system
base name used to look up ``filter.d/{name}.conf``.
Args:
filter_raw: Raw ``filter`` value from a jail config (already
with ``%(__name__)s`` substituted by the caller).
Returns:
Base filter name, e.g. ``"sshd"``.
"""
bracket = filter_raw.find("[")
if bracket != -1:
return filter_raw[:bracket].strip()
return filter_raw.strip()
def _build_filter_to_jails_map(
all_jails: dict[str, dict[str, str]],
active_names: set[str],
) -> dict[str, list[str]]:
"""Return a mapping of filter base name → list of active jail names.
Iterates over every jail whose name is in *active_names*, resolves its
``filter`` config key, and records the jail against the base filter name.
Args:
all_jails: Merged jail config dict — ``{jail_name: {key: value}}``.
active_names: Set of jail names currently running in fail2ban.
Returns:
``{filter_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_filter = settings.get("filter", "")
mode = settings.get("mode", "normal")
resolved = _resolve_filter(raw_filter, jail_name, mode) if raw_filter else jail_name
base = _extract_filter_base_name(resolved)
if base:
mapping.setdefault(base, []).append(jail_name)
return mapping
def _parse_filters_sync(
filter_d: Path,
) -> list[tuple[str, str, str, bool, str]]:
"""Synchronously scan ``filter.d/`` and return per-filter tuples.
Each tuple contains:
- ``name`` — filter base name (``"sshd"``).
- ``filename`` — actual filename (``"sshd.conf"`` or ``"sshd.local"``).
- ``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) filters.
Also discovers ``.local``-only files (user-created filters with no
corresponding ``.conf``). These are returned with ``has_local = False``
and ``source_path`` pointing to the ``.local`` file itself.
Args:
filter_d: Path to the ``filter.d`` directory.
Returns:
List of ``(name, filename, content, has_local, source_path)`` tuples,
sorted by name.
"""
if not filter_d.is_dir():
log.warning("filter_d_not_found", path=str(filter_d))
return []
conf_names: set[str] = set()
results: list[tuple[str, str, str, bool, str]] = []
# ---- .conf-based filters (with optional .local override) ----------------
for conf_path in sorted(filter_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(
"filter_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")
# Append local content after conf so configparser reads local
# values last (higher priority).
content = content + "\n" + local_content
except OSError as exc:
log.warning(
"filter_local_read_error",
name=name,
path=str(local_path),
error=str(exc),
)
results.append((name, filename, content, has_local, str(conf_path)))
# ---- .local-only filters (user-created, no corresponding .conf) ----------
for local_path in sorted(filter_d.glob("*.local")):
if not local_path.is_file():
continue
name = local_path.stem
if name in conf_names:
# Already covered above as a .conf filter with a .local override.
continue
try:
content = local_path.read_text(encoding="utf-8")
except OSError as exc:
log.warning(
"filter_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("filters_scanned", count=len(results), filter_d=str(filter_d))
return results
# ---------------------------------------------------------------------------
# Public API — filter discovery (Task 2.1)
# ---------------------------------------------------------------------------
async def list_filters(
config_dir: str,
socket_path: str,
) -> FilterListResponse:
"""Return all available filters from ``filter.d/`` with active/inactive status.
Scans ``{config_dir}/filter.d/`` for ``.conf`` files, merges any
corresponding ``.local`` overrides, parses each file into a
:class:`~app.models.config.FilterConfig`, and cross-references with the
currently running jails to determine which filters are active.
A filter is considered *active* when its base name matches the ``filter``
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.FilterListResponse` with all filters
sorted alphabetically, active ones carrying non-empty
``used_by_jails`` lists.
"""
filter_d = Path(config_dir) / "filter.d"
loop = asyncio.get_event_loop()
# Run the synchronous scan in a thread-pool executor.
raw_filters: list[tuple[str, str, str, bool, str]] = await loop.run_in_executor(
None, _parse_filters_sync, filter_d
)
# Fetch active jail names and their configs concurrently.
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
filter_to_jails = _build_filter_to_jails_map(all_jails, active_names)
filters: list[FilterConfig] = []
for name, filename, content, has_local, source_path in raw_filters:
cfg = conffile_parser.parse_filter_file(
content, name=name, filename=filename
)
used_by = sorted(filter_to_jails.get(name, []))
filters.append(
FilterConfig(
name=cfg.name,
filename=cfg.filename,
before=cfg.before,
after=cfg.after,
variables=cfg.variables,
prefregex=cfg.prefregex,
failregex=cfg.failregex,
ignoreregex=cfg.ignoreregex,
maxlines=cfg.maxlines,
datepattern=cfg.datepattern,
journalmatch=cfg.journalmatch,
active=len(used_by) > 0,
used_by_jails=used_by,
source_file=source_path,
has_local_override=has_local,
)
)
log.info("filters_listed", total=len(filters), active=sum(1 for f in filters if f.active))
return FilterListResponse(filters=filters, total=len(filters))
async def get_filter(
config_dir: str,
socket_path: str,
name: str,
) -> FilterConfig:
"""Return a single filter from ``filter.d/`` with active/inactive status.
Reads ``{config_dir}/filter.d/{name}.conf``, merges any ``.local``
override, and enriches the parsed :class:`~app.models.config.FilterConfig`
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: Filter base name (e.g. ``"sshd"`` or ``"sshd.conf"``).
Returns:
:class:`~app.models.config.FilterConfig` with status fields populated.
Raises:
FilterNotFoundError: If no ``{name}.conf`` or ``{name}.local`` file
exists in ``filter.d/``.
"""
# Normalise — strip extension if provided (.conf=5 chars, .local=6 chars).
if name.endswith(".conf"):
base_name = name[:-5]
elif name.endswith(".local"):
base_name = name[:-6]
else:
base_name = name
filter_d = Path(config_dir) / "filter.d"
conf_path = filter_d / f"{base_name}.conf"
local_path = filter_d / f"{base_name}.local"
loop = asyncio.get_event_loop()
def _read() -> tuple[str, bool, str]:
"""Read filter 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(
"filter_local_read_error",
name=base_name,
path=str(local_path),
error=str(exc),
)
return content, has_local, str(conf_path)
elif has_local:
# Local-only filter: created by the user, no shipped .conf base.
content = local_path.read_text(encoding="utf-8")
return content, False, str(local_path)
else:
raise FilterNotFoundError(base_name)
content, has_local, source_path = await loop.run_in_executor(None, _read)
cfg = conffile_parser.parse_filter_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
filter_to_jails = _build_filter_to_jails_map(all_jails, active_names)
used_by = sorted(filter_to_jails.get(base_name, []))
log.info("filter_fetched", name=base_name, active=len(used_by) > 0)
return FilterConfig(
name=cfg.name,
filename=cfg.filename,
before=cfg.before,
after=cfg.after,
variables=cfg.variables,
prefregex=cfg.prefregex,
failregex=cfg.failregex,
ignoreregex=cfg.ignoreregex,
maxlines=cfg.maxlines,
datepattern=cfg.datepattern,
journalmatch=cfg.journalmatch,
active=len(used_by) > 0,
used_by_jails=used_by,
source_file=source_path,
has_local_override=has_local,
)
# ---------------------------------------------------------------------------
# Public API — filter write operations (Task 2.2)
# ---------------------------------------------------------------------------
async def update_filter(
config_dir: str,
socket_path: str,
name: str,
req: FilterUpdateRequest,
do_reload: bool = False,
) -> FilterConfig:
"""Update a filter's ``.local`` override with new regex/pattern 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 ``filter.d/{name}.local``. The
original ``.conf`` file is never modified.
All regex patterns in *req* are validated with Python's ``re`` module
before any write occurs.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
name: Filter base name (e.g. ``"sshd"`` or ``"sshd.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.FilterConfig` reflecting the updated state.
Raises:
FilterNameError: If *name* contains invalid characters.
FilterNotFoundError: If no ``{name}.conf`` or ``{name}.local`` exists.
FilterInvalidRegexError: If any supplied regex pattern is invalid.
ConfigWriteError: If writing the ``.local`` file fails.
"""
base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name
_safe_filter_name(base_name)
# Validate regex patterns before touching the filesystem.
patterns: list[str] = []
if req.failregex is not None:
patterns.extend(req.failregex)
if req.ignoreregex is not None:
patterns.extend(req.ignoreregex)
_validate_regex_patterns(patterns)
# Fetch the current merged config (raises FilterNotFoundError if absent).
current = await get_filter(config_dir, socket_path, base_name)
# Build a FilterConfigUpdate from the request fields.
update = FilterConfigUpdate(
failregex=req.failregex,
ignoreregex=req.ignoreregex,
datepattern=req.datepattern,
journalmatch=req.journalmatch,
)
merged = conffile_parser.merge_filter_update(current, update)
content = conffile_parser.serialize_filter_config(merged)
filter_d = Path(config_dir) / "filter.d"
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _write_filter_local_sync, filter_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_filter_update_failed",
filter=base_name,
error=str(exc),
)
log.info("filter_updated", filter=base_name, reload=do_reload)
return await get_filter(config_dir, socket_path, base_name)
async def create_filter(
config_dir: str,
socket_path: str,
req: FilterCreateRequest,
do_reload: bool = False,
) -> FilterConfig:
"""Create a brand-new user-defined filter in ``filter.d/{name}.local``.
No ``.conf`` is written; fail2ban loads ``.local`` files directly. If a
``.conf`` or ``.local`` file already exists for the requested name, a
:class:`FilterAlreadyExistsError` is raised.
All regex patterns are validated with Python's ``re`` module before
writing.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
req: Filter name and definition fields.
do_reload: When ``True``, trigger a full fail2ban reload after writing.
Returns:
:class:`~app.models.config.FilterConfig` for the newly created filter.
Raises:
FilterNameError: If ``req.name`` contains invalid characters.
FilterAlreadyExistsError: If a ``.conf`` or ``.local`` already exists.
FilterInvalidRegexError: If any regex pattern is invalid.
ConfigWriteError: If writing fails.
"""
_safe_filter_name(req.name)
filter_d = Path(config_dir) / "filter.d"
conf_path = filter_d / f"{req.name}.conf"
local_path = filter_d / f"{req.name}.local"
def _check_not_exists() -> None:
if conf_path.is_file() or local_path.is_file():
raise FilterAlreadyExistsError(req.name)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _check_not_exists)
# Validate regex patterns.
patterns: list[str] = list(req.failregex) + list(req.ignoreregex)
_validate_regex_patterns(patterns)
# Build a FilterConfig and serialise it.
cfg = FilterConfig(
name=req.name,
filename=f"{req.name}.local",
failregex=req.failregex,
ignoreregex=req.ignoreregex,
prefregex=req.prefregex,
datepattern=req.datepattern,
journalmatch=req.journalmatch,
)
content = conffile_parser.serialize_filter_config(cfg)
await loop.run_in_executor(None, _write_filter_local_sync, filter_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_filter_create_failed",
filter=req.name,
error=str(exc),
)
log.info("filter_created", filter=req.name, reload=do_reload)
# Re-fetch to get the canonical FilterConfig (source_file, active, etc.).
return await get_filter(config_dir, socket_path, req.name)
async def delete_filter(
config_dir: str,
name: str,
) -> None:
"""Delete a user-created filter's ``.local`` file.
Deletion rules:
- If only a ``.conf`` file exists (shipped default, no user override) →
:class:`FilterReadonlyError`.
- If a ``.local`` file exists (whether or not a ``.conf`` also exists) →
the ``.local`` file is deleted. The shipped ``.conf`` is never touched.
- If neither file exists → :class:`FilterNotFoundError`.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
name: Filter base name (e.g. ``"sshd"``).
Raises:
FilterNameError: If *name* contains invalid characters.
FilterNotFoundError: If no filter file is found for *name*.
FilterReadonlyError: If only a shipped ``.conf`` exists (no ``.local``).
ConfigWriteError: If deletion of the ``.local`` file fails.
"""
base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name
_safe_filter_name(base_name)
filter_d = Path(config_dir) / "filter.d"
conf_path = filter_d / f"{base_name}.conf"
local_path = filter_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 FilterNotFoundError(base_name)
if has_conf and not has_local:
# Shipped default — nothing user-writable to remove.
raise FilterReadonlyError(base_name)
try:
local_path.unlink()
except OSError as exc:
raise ConfigWriteError(
f"Failed to delete {local_path}: {exc}"
) from exc
log.info("filter_local_deleted", filter=base_name, path=str(local_path))
await loop.run_in_executor(None, _delete)
async def assign_filter_to_jail(
config_dir: str,
socket_path: str,
jail_name: str,
req: AssignFilterRequest,
do_reload: bool = False,
) -> None:
"""Assign a filter to a jail by updating the jail's ``.local`` file.
Writes ``filter = {req.filter_name}`` into the ``[{jail_name}]`` section
of ``jail.d/{jail_name}.local``. If the ``.local`` file already contains
other settings for this jail they are preserved.
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 filter name to assign.
do_reload: When ``True``, trigger a full fail2ban reload after writing.
Raises:
JailNameError: If *jail_name* contains invalid characters.
FilterNameError: If ``req.filter_name`` contains invalid characters.
JailNotFoundInConfigError: If *jail_name* is not defined in any config
file.
FilterNotFoundError: If ``req.filter_name`` does not exist in
``filter.d/``.
ConfigWriteError: If writing fails.
"""
_safe_jail_name(jail_name)
_safe_filter_name(req.filter_name)
loop = asyncio.get_event_loop()
# Verify the jail exists in config.
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)
# Verify the filter exists (conf or local).
filter_d = Path(config_dir) / "filter.d"
def _check_filter() -> None:
conf_exists = (filter_d / f"{req.filter_name}.conf").is_file()
local_exists = (filter_d / f"{req.filter_name}.local").is_file()
if not conf_exists and not local_exists:
raise FilterNotFoundError(req.filter_name)
await loop.run_in_executor(None, _check_filter)
await loop.run_in_executor(
None,
_set_jail_local_key_sync,
Path(config_dir),
jail_name,
"filter",
req.filter_name,
)
if do_reload:
try:
await jail_service.reload_all(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_assign_filter_failed",
jail=jail_name,
filter=req.filter_name,
error=str(exc),
)
log.info(
"filter_assigned_to_jail",
jail=jail_name,
filter=req.filter_name,
reload=do_reload,
)
# ---------------------------------------------------------------------------
# Action discovery helpers (Task 3.1)
# ---------------------------------------------------------------------------
# Allowlist pattern for action names used in path construction.
_SAFE_ACTION_NAME_RE: re.Pattern[str] = re.compile(
r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
)
class ActionNotFoundError(Exception):
"""Raised when the requested action name is not found in ``action.d/``."""
def __init__(self, name: str) -> None:
"""Initialise with the action name that was not found.
Args:
name: The action name that could not be located.
"""
self.name: str = name
super().__init__(f"Action not found: {name!r}")
class ActionAlreadyExistsError(Exception):
"""Raised when trying to create an action whose ``.conf`` or ``.local`` already exists."""
def __init__(self, name: str) -> None:
"""Initialise with the action name that already exists.
Args:
name: The action name that already exists.
"""
self.name: str = name
super().__init__(f"Action already exists: {name!r}")
class ActionReadonlyError(Exception):
"""Raised when trying to delete a shipped ``.conf`` action with no ``.local`` override."""
def __init__(self, name: str) -> None:
"""Initialise with the action name that cannot be deleted.
Args:
name: The action name that is read-only (shipped ``.conf`` only).
"""
self.name: str = name
super().__init__(
f"Action {name!r} is a shipped default (.conf only); "
"only user-created .local files can be deleted."
)
class ActionNameError(Exception):
"""Raised when an action name contains invalid characters."""
def _safe_action_name(name: str) -> str:
"""Validate *name* and return it unchanged or raise :class:`ActionNameError`.
Args:
name: Proposed action name (without extension).
Returns:
The name unchanged if valid.
Raises:
ActionNameError: If *name* contains unsafe characters.
"""
if not _SAFE_ACTION_NAME_RE.match(name):
raise ActionNameError(
f"Action name {name!r} contains invalid characters. "
"Only alphanumeric characters, hyphens, underscores, and dots are "
"allowed; must start with an alphanumeric character."
)
return name
def _build_action_to_jails_map(
all_jails: dict[str, dict[str, str]],
active_names: set[str],
) -> dict[str, list[str]]:
"""Return a mapping of action base name → list of active jail names.
Iterates over every jail whose name is in *active_names*, resolves each
entry in its ``action`` config key to an action base name (stripping
``[…]`` parameter blocks), and records the jail against each base name.
Args:
all_jails: Merged jail config dict — ``{jail_name: {key: value}}``.
active_names: Set of jail names currently running in fail2ban.
Returns:
``{action_base_name: [jail_name, …]}``.
"""
mapping: dict[str, list[str]] = {}
for jail_name, settings in all_jails.items():
if jail_name not in active_names:
continue
raw_action = settings.get("action", "")
if not raw_action:
continue
for line in raw_action.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
# Strip optional [key=value] parameter block to get the base name.
bracket = stripped.find("[")
base = stripped[:bracket].strip() if bracket != -1 else stripped
if base:
mapping.setdefault(base, []).append(jail_name)
return mapping
def _parse_actions_sync(
action_d: Path,
) -> list[tuple[str, str, str, bool, str]]:
"""Synchronously scan ``action.d/`` and return per-action tuples.
Each tuple contains:
- ``name`` — action base name (``"iptables"``).
- ``filename`` — actual filename (``"iptables.conf"``).
- ``content`` — merged file content (``conf`` overridden by ``local``).
- ``has_local`` — whether a ``.local`` override exists alongside a ``.conf``.
- ``source_path`` — absolute path to the primary (``conf``) source file, or
to the ``.local`` file for user-created (local-only) actions.
Also discovers ``.local``-only files (user-created actions with no
corresponding ``.conf``).
Args:
action_d: Path to the ``action.d`` directory.
Returns:
List of ``(name, filename, content, has_local, source_path)`` tuples,
sorted by name.
"""
if not action_d.is_dir():
log.warning("action_d_not_found", path=str(action_d))
return []
conf_names: set[str] = set()
results: list[tuple[str, str, str, bool, str]] = []
# ---- .conf-based actions (with optional .local override) ----------------
for conf_path in sorted(action_d.glob("*.conf")):
if not conf_path.is_file():
continue
name = conf_path.stem
filename = conf_path.name
conf_names.add(name)
local_path = conf_path.with_suffix(".local")
has_local = local_path.is_file()
try:
content = conf_path.read_text(encoding="utf-8")
except OSError as exc:
log.warning(
"action_read_error", name=name, path=str(conf_path), error=str(exc)
)
continue
if has_local:
try:
local_content = local_path.read_text(encoding="utf-8")
content = content + "\n" + local_content
except OSError as exc:
log.warning(
"action_local_read_error",
name=name,
path=str(local_path),
error=str(exc),
)
results.append((name, filename, content, has_local, str(conf_path)))
# ---- .local-only actions (user-created, no corresponding .conf) ----------
for local_path in sorted(action_d.glob("*.local")):
if not local_path.is_file():
continue
name = local_path.stem
if name in conf_names:
continue
try:
content = local_path.read_text(encoding="utf-8")
except OSError as exc:
log.warning(
"action_local_read_error",
name=name,
path=str(local_path),
error=str(exc),
)
continue
results.append((name, local_path.name, content, False, str(local_path)))
results.sort(key=lambda t: t[0])
log.debug("actions_scanned", count=len(results), action_d=str(action_d))
return results
def _append_jail_action_sync(
config_dir: Path,
jail_name: str,
action_entry: str,
) -> None:
"""Append an action entry to the ``action`` key in ``jail.d/{jail_name}.local``.
If the ``.local`` file already contains an ``action`` key under the jail
section, the new entry is appended as an additional line (multi-line
configparser format) unless it is already present. If no ``action`` key
exists, one is created.
Args:
config_dir: The fail2ban configuration root directory.
jail_name: Validated jail name.
action_entry: Full action string including any ``[…]`` parameters.
Raises:
ConfigWriteError: If writing fails.
"""
jail_d = config_dir / "jail.d"
try:
jail_d.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise ConfigWriteError(
f"Cannot create jail.d directory: {exc}"
) from exc
local_path = jail_d / f"{jail_name}.local"
parser = _build_parser()
if local_path.is_file():
try:
parser.read(str(local_path), encoding="utf-8")
except (configparser.Error, OSError) as exc:
log.warning(
"jail_local_read_for_update_error",
jail=jail_name,
error=str(exc),
)
if not parser.has_section(jail_name):
parser.add_section(jail_name)
existing_raw = parser.get(jail_name, "action") if parser.has_option(jail_name, "action") else ""
existing_lines = [
line.strip()
for line in existing_raw.splitlines()
if line.strip() and not line.strip().startswith("#")
]
# Extract base names from existing entries for duplicate checking.
def _base(entry: str) -> str:
bracket = entry.find("[")
return entry[:bracket].strip() if bracket != -1 else entry.strip()
new_base = _base(action_entry)
if not any(_base(e) == new_base for e in existing_lines):
existing_lines.append(action_entry)
if existing_lines:
# configparser multi-line: continuation lines start with whitespace.
new_value = existing_lines[0] + "".join(
f"\n {line}" for line in existing_lines[1:]
)
parser.set(jail_name, "action", new_value)
else:
parser.set(jail_name, "action", action_entry)
buf = io.StringIO()
buf.write("# Managed by BanGUI — do not edit manually\n\n")
parser.write(buf)
content = buf.getvalue()
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=jail_d,
delete=False,
suffix=".tmp",
) as tmp:
tmp.write(content)
tmp_name = tmp.name
os.replace(tmp_name, local_path)
except OSError as exc:
with contextlib.suppress(OSError):
os.unlink(tmp_name) # noqa: F821
raise ConfigWriteError(
f"Failed to write {local_path}: {exc}"
) from exc
log.info(
"jail_action_appended",
jail=jail_name,
action=action_entry,
path=str(local_path),
)
def _remove_jail_action_sync(
config_dir: Path,
jail_name: str,
action_name: str,
) -> None:
"""Remove an action entry from the ``action`` key in ``jail.d/{jail_name}.local``.
Reads the ``.local`` file, removes any ``action`` entries whose base name
matches *action_name*, and writes the result back atomically. If no
``.local`` file exists, this is a no-op.
Args:
config_dir: The fail2ban configuration root directory.
jail_name: Validated jail name.
action_name: Base name of the action to remove (without ``[…]``).
Raises:
ConfigWriteError: If writing fails.
"""
jail_d = config_dir / "jail.d"
local_path = jail_d / f"{jail_name}.local"
if not local_path.is_file():
return
parser = _build_parser()
try:
parser.read(str(local_path), encoding="utf-8")
except (configparser.Error, OSError) as exc:
log.warning(
"jail_local_read_for_update_error",
jail=jail_name,
error=str(exc),
)
return
if not parser.has_section(jail_name) or not parser.has_option(jail_name, "action"):
return
existing_raw = parser.get(jail_name, "action")
existing_lines = [
line.strip()
for line in existing_raw.splitlines()
if line.strip() and not line.strip().startswith("#")
]
def _base(entry: str) -> str:
bracket = entry.find("[")
return entry[:bracket].strip() if bracket != -1 else entry.strip()
filtered = [e for e in existing_lines if _base(e) != action_name]
if len(filtered) == len(existing_lines):
# Action was not found — silently return (idempotent).
return
if filtered:
new_value = filtered[0] + "".join(
f"\n {line}" for line in filtered[1:]
)
parser.set(jail_name, "action", new_value)
else:
parser.remove_option(jail_name, "action")
buf = io.StringIO()
buf.write("# Managed by BanGUI — do not edit manually\n\n")
parser.write(buf)
content = buf.getvalue()
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=jail_d,
delete=False,
suffix=".tmp",
) as tmp:
tmp.write(content)
tmp_name = tmp.name
os.replace(tmp_name, local_path)
except OSError as exc:
with contextlib.suppress(OSError):
os.unlink(tmp_name) # noqa: F821
raise ConfigWriteError(
f"Failed to write {local_path}: {exc}"
) from exc
log.info(
"jail_action_removed",
jail=jail_name,
action=action_name,
path=str(local_path),
)
def _write_action_local_sync(action_d: Path, name: str, content: str) -> None:
"""Write *content* to ``action.d/{name}.local`` atomically.
The write is atomic: content is written to a temp file first, then
renamed into place. The ``action.d/`` directory is created if absent.
Args:
action_d: Path to the ``action.d`` directory.
name: Validated action base name (used as filename stem).
content: Full serialized action content to write.
Raises:
ConfigWriteError: If writing fails.
"""
try:
action_d.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise ConfigWriteError(
f"Cannot create action.d directory: {exc}"
) from exc
local_path = action_d / f"{name}.local"
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=action_d,
delete=False,
suffix=".tmp",
) as tmp:
tmp.write(content)
tmp_name = tmp.name
os.replace(tmp_name, local_path)
except OSError as exc:
with contextlib.suppress(OSError):
os.unlink(tmp_name) # noqa: F821
raise ConfigWriteError(
f"Failed to write {local_path}: {exc}"
) from exc
log.info("action_local_written", action=name, path=str(local_path))
# ---------------------------------------------------------------------------
# Public API — action discovery (Task 3.1)
# ---------------------------------------------------------------------------
async def list_actions(
config_dir: str,
socket_path: str,
) -> ActionListResponse:
"""Return all available actions from ``action.d/`` with active/inactive status.
Scans ``{config_dir}/action.d/`` for ``.conf`` files, merges any
corresponding ``.local`` overrides, parses each file into an
:class:`~app.models.config.ActionConfig`, and cross-references with the
currently running jails to determine which actions are active.
An action is considered *active* when its base name appears in the
``action`` field of at least one currently running jail.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
Returns:
:class:`~app.models.config.ActionListResponse` with all actions
sorted alphabetically, active ones carrying non-empty
``used_by_jails`` lists.
"""
action_d = Path(config_dir) / "action.d"
loop = asyncio.get_event_loop()
raw_actions: list[tuple[str, str, str, bool, str]] = await loop.run_in_executor(
None, _parse_actions_sync, action_d
)
all_jails_result, active_names = await asyncio.gather(
loop.run_in_executor(None, _parse_jails_sync, Path(config_dir)),
_get_active_jail_names(socket_path),
)
all_jails, _source_files = all_jails_result
action_to_jails = _build_action_to_jails_map(all_jails, active_names)
actions: list[ActionConfig] = []
for name, filename, content, has_local, source_path in raw_actions:
cfg = conffile_parser.parse_action_file(
content, name=name, filename=filename
)
used_by = sorted(action_to_jails.get(name, []))
actions.append(
ActionConfig(
name=cfg.name,
filename=cfg.filename,
before=cfg.before,
after=cfg.after,
actionstart=cfg.actionstart,
actionstop=cfg.actionstop,
actioncheck=cfg.actioncheck,
actionban=cfg.actionban,
actionunban=cfg.actionunban,
actionflush=cfg.actionflush,
definition_vars=cfg.definition_vars,
init_vars=cfg.init_vars,
active=len(used_by) > 0,
used_by_jails=used_by,
source_file=source_path,
has_local_override=has_local,
)
)
log.info("actions_listed", total=len(actions), active=sum(1 for a in actions if a.active))
return ActionListResponse(actions=actions, total=len(actions))
async def get_action(
config_dir: str,
socket_path: str,
name: str,
) -> ActionConfig:
"""Return a single action from ``action.d/`` with active/inactive status.
Reads ``{config_dir}/action.d/{name}.conf``, merges any ``.local``
override, and enriches the parsed :class:`~app.models.config.ActionConfig`
with ``active``, ``used_by_jails``, ``source_file``, and
``has_local_override``.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
name: Action base name (e.g. ``"iptables"`` or ``"iptables.conf"``).
Returns:
:class:`~app.models.config.ActionConfig` with status fields populated.
Raises:
ActionNotFoundError: If no ``{name}.conf`` or ``{name}.local`` file
exists in ``action.d/``.
"""
if name.endswith(".conf"):
base_name = name[:-5]
elif name.endswith(".local"):
base_name = name[:-6]
else:
base_name = name
action_d = Path(config_dir) / "action.d"
conf_path = action_d / f"{base_name}.conf"
local_path = action_d / f"{base_name}.local"
loop = asyncio.get_event_loop()
def _read() -> tuple[str, bool, str]:
"""Read action content and return (content, has_local_override, source_path)."""
has_local = local_path.is_file()
if conf_path.is_file():
content = conf_path.read_text(encoding="utf-8")
if has_local:
try:
content += "\n" + local_path.read_text(encoding="utf-8")
except OSError as exc:
log.warning(
"action_local_read_error",
name=base_name,
path=str(local_path),
error=str(exc),
)
return content, has_local, str(conf_path)
elif has_local:
content = local_path.read_text(encoding="utf-8")
return content, False, str(local_path)
else:
raise ActionNotFoundError(base_name)
content, has_local, source_path = await loop.run_in_executor(None, _read)
cfg = conffile_parser.parse_action_file(
content, name=base_name, filename=f"{base_name}.conf"
)
all_jails_result, active_names = await asyncio.gather(
loop.run_in_executor(None, _parse_jails_sync, Path(config_dir)),
_get_active_jail_names(socket_path),
)
all_jails, _source_files = all_jails_result
action_to_jails = _build_action_to_jails_map(all_jails, active_names)
used_by = sorted(action_to_jails.get(base_name, []))
log.info("action_fetched", name=base_name, active=len(used_by) > 0)
return ActionConfig(
name=cfg.name,
filename=cfg.filename,
before=cfg.before,
after=cfg.after,
actionstart=cfg.actionstart,
actionstop=cfg.actionstop,
actioncheck=cfg.actioncheck,
actionban=cfg.actionban,
actionunban=cfg.actionunban,
actionflush=cfg.actionflush,
definition_vars=cfg.definition_vars,
init_vars=cfg.init_vars,
active=len(used_by) > 0,
used_by_jails=used_by,
source_file=source_path,
has_local_override=has_local,
)
# ---------------------------------------------------------------------------
# Public API — action write operations (Task 3.2)
# ---------------------------------------------------------------------------
async def update_action(
config_dir: str,
socket_path: str,
name: str,
req: ActionUpdateRequest,
do_reload: bool = False,
) -> ActionConfig:
"""Update an action's ``.local`` override with new lifecycle command values.
Reads the current merged configuration for *name* (``conf`` + any existing
``local``), applies the non-``None`` fields in *req* on top of it, and
writes the resulting definition to ``action.d/{name}.local``. The
original ``.conf`` file is never modified.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
name: Action base name (e.g. ``"iptables"`` or ``"iptables.conf"``).
req: Partial update — only non-``None`` fields are applied.
do_reload: When ``True``, trigger a full fail2ban reload after writing.
Returns:
:class:`~app.models.config.ActionConfig` reflecting the updated state.
Raises:
ActionNameError: If *name* contains invalid characters.
ActionNotFoundError: If no ``{name}.conf`` or ``{name}.local`` exists.
ConfigWriteError: If writing the ``.local`` file fails.
"""
base_name = name[:-5] if name.endswith((".conf", ".local")) else name
_safe_action_name(base_name)
current = await get_action(config_dir, socket_path, base_name)
update = ActionConfigUpdate(
actionstart=req.actionstart,
actionstop=req.actionstop,
actioncheck=req.actioncheck,
actionban=req.actionban,
actionunban=req.actionunban,
actionflush=req.actionflush,
definition_vars=req.definition_vars,
init_vars=req.init_vars,
)
merged = conffile_parser.merge_action_update(current, update)
content = conffile_parser.serialize_action_config(merged)
action_d = Path(config_dir) / "action.d"
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _write_action_local_sync, action_d, base_name, content)
if do_reload:
try:
await jail_service.reload_all(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_action_update_failed",
action=base_name,
error=str(exc),
)
log.info("action_updated", action=base_name, reload=do_reload)
return await get_action(config_dir, socket_path, base_name)
async def create_action(
config_dir: str,
socket_path: str,
req: ActionCreateRequest,
do_reload: bool = False,
) -> ActionConfig:
"""Create a brand-new user-defined action in ``action.d/{name}.local``.
No ``.conf`` is written; fail2ban loads ``.local`` files directly. If a
``.conf`` or ``.local`` file already exists for the requested name, an
:class:`ActionAlreadyExistsError` is raised.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
req: Action name and definition fields.
do_reload: When ``True``, trigger a full fail2ban reload after writing.
Returns:
:class:`~app.models.config.ActionConfig` for the newly created action.
Raises:
ActionNameError: If ``req.name`` contains invalid characters.
ActionAlreadyExistsError: If a ``.conf`` or ``.local`` already exists.
ConfigWriteError: If writing fails.
"""
_safe_action_name(req.name)
action_d = Path(config_dir) / "action.d"
conf_path = action_d / f"{req.name}.conf"
local_path = action_d / f"{req.name}.local"
def _check_not_exists() -> None:
if conf_path.is_file() or local_path.is_file():
raise ActionAlreadyExistsError(req.name)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _check_not_exists)
cfg = ActionConfig(
name=req.name,
filename=f"{req.name}.local",
actionstart=req.actionstart,
actionstop=req.actionstop,
actioncheck=req.actioncheck,
actionban=req.actionban,
actionunban=req.actionunban,
actionflush=req.actionflush,
definition_vars=req.definition_vars,
init_vars=req.init_vars,
)
content = conffile_parser.serialize_action_config(cfg)
await loop.run_in_executor(None, _write_action_local_sync, action_d, req.name, content)
if do_reload:
try:
await jail_service.reload_all(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_action_create_failed",
action=req.name,
error=str(exc),
)
log.info("action_created", action=req.name, reload=do_reload)
return await get_action(config_dir, socket_path, req.name)
async def delete_action(
config_dir: str,
name: str,
) -> None:
"""Delete a user-created action's ``.local`` file.
Deletion rules:
- If only a ``.conf`` file exists (shipped default, no user override) →
:class:`ActionReadonlyError`.
- If a ``.local`` file exists (whether or not a ``.conf`` also exists) →
only the ``.local`` file is deleted.
- If neither file exists → :class:`ActionNotFoundError`.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
name: Action base name (e.g. ``"iptables"``).
Raises:
ActionNameError: If *name* contains invalid characters.
ActionNotFoundError: If no action file is found for *name*.
ActionReadonlyError: If only a shipped ``.conf`` exists (no ``.local``).
ConfigWriteError: If deletion of the ``.local`` file fails.
"""
base_name = name[:-5] if name.endswith((".conf", ".local")) else name
_safe_action_name(base_name)
action_d = Path(config_dir) / "action.d"
conf_path = action_d / f"{base_name}.conf"
local_path = action_d / f"{base_name}.local"
loop = asyncio.get_event_loop()
def _delete() -> None:
has_conf = conf_path.is_file()
has_local = local_path.is_file()
if not has_conf and not has_local:
raise ActionNotFoundError(base_name)
if has_conf and not has_local:
raise ActionReadonlyError(base_name)
try:
local_path.unlink()
except OSError as exc:
raise ConfigWriteError(
f"Failed to delete {local_path}: {exc}"
) from exc
log.info("action_local_deleted", action=base_name, path=str(local_path))
await loop.run_in_executor(None, _delete)
async def assign_action_to_jail(
config_dir: str,
socket_path: str,
jail_name: str,
req: AssignActionRequest,
do_reload: bool = False,
) -> None:
"""Add an action to a jail by updating the jail's ``.local`` file.
Appends ``{req.action_name}[{params}]`` (or just ``{req.action_name}`` when
no params are given) to the ``action`` key in the ``[{jail_name}]`` section
of ``jail.d/{jail_name}.local``. If the action is already listed it is not
duplicated. If the ``.local`` file does not exist it is created.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
jail_name: Name of the jail to update.
req: Request containing the action name and optional parameters.
do_reload: When ``True``, trigger a full fail2ban reload after writing.
Raises:
JailNameError: If *jail_name* contains invalid characters.
ActionNameError: If ``req.action_name`` contains invalid characters.
JailNotFoundInConfigError: If *jail_name* is not defined in any config
file.
ActionNotFoundError: If ``req.action_name`` does not exist in
``action.d/``.
ConfigWriteError: If writing fails.
"""
_safe_jail_name(jail_name)
_safe_action_name(req.action_name)
loop = asyncio.get_event_loop()
all_jails, _src = await loop.run_in_executor(
None, _parse_jails_sync, Path(config_dir)
)
if jail_name not in all_jails:
raise JailNotFoundInConfigError(jail_name)
action_d = Path(config_dir) / "action.d"
def _check_action() -> None:
if (
not (action_d / f"{req.action_name}.conf").is_file()
and not (action_d / f"{req.action_name}.local").is_file()
):
raise ActionNotFoundError(req.action_name)
await loop.run_in_executor(None, _check_action)
# Build the action string with optional parameters.
if req.params:
param_str = ", ".join(f"{k}={v}" for k, v in sorted(req.params.items()))
action_entry = f"{req.action_name}[{param_str}]"
else:
action_entry = req.action_name
await loop.run_in_executor(
None,
_append_jail_action_sync,
Path(config_dir),
jail_name,
action_entry,
)
if do_reload:
try:
await jail_service.reload_all(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_assign_action_failed",
jail=jail_name,
action=req.action_name,
error=str(exc),
)
log.info(
"action_assigned_to_jail",
jail=jail_name,
action=req.action_name,
reload=do_reload,
)
async def remove_action_from_jail(
config_dir: str,
socket_path: str,
jail_name: str,
action_name: str,
do_reload: bool = False,
) -> None:
"""Remove an action from a jail's ``.local`` config.
Reads ``jail.d/{jail_name}.local``, removes the line(s) that reference
``{action_name}`` from the ``action`` key (including any ``[…]`` parameter
blocks), and writes the file back atomically.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
jail_name: Name of the jail to update.
action_name: Base name of the action to remove.
do_reload: When ``True``, trigger a full fail2ban reload after writing.
Raises:
JailNameError: If *jail_name* contains invalid characters.
ActionNameError: If *action_name* contains invalid characters.
JailNotFoundInConfigError: If *jail_name* is not defined in any config.
ConfigWriteError: If writing fails.
"""
_safe_jail_name(jail_name)
_safe_action_name(action_name)
loop = asyncio.get_event_loop()
all_jails, _src = await loop.run_in_executor(
None, _parse_jails_sync, Path(config_dir)
)
if jail_name not in all_jails:
raise JailNotFoundInConfigError(jail_name)
await loop.run_in_executor(
None,
_remove_jail_action_sync,
Path(config_dir),
jail_name,
action_name,
)
if do_reload:
try:
await jail_service.reload_all(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_remove_action_failed",
jail=jail_name,
action=action_name,
error=str(exc),
)
log.info(
"action_removed_from_jail",
jail=jail_name,
action=action_name,
reload=do_reload,
)