From 2451ec77b24ac042226185d995ba18da58d5e7e5 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 15 Apr 2026 08:25:12 +0200 Subject: [PATCH] Refactor config file service facade wrappers and mark TASK-06 complete in Docs/Tasks.md --- Docs/Tasks.md | 4 +- backend/app/services/action_config_service.py | 60 +- backend/app/services/config_file_service.py | 997 ++---------------- backend/app/services/filter_config_service.py | 60 +- backend/app/services/jail_config_service.py | 69 +- 5 files changed, 246 insertions(+), 944 deletions(-) diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 7960ff5..5d07b34 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -176,7 +176,9 @@ Business logic in the task layer cannot be unit-tested without spinning up a sch --- -### TASK-06 β€” Break circular dependency between `config_file_service` and the three config sub-services 🟠 +### TASK-06 β€” Break circular dependency between `config_file_service` and the three config sub-services βœ… + +**Status:** Completed βœ… **Where:** - `services/jail_config_service.py` line 33 β†’ imports `config_file_service` at module level diff --git a/backend/app/services/action_config_service.py b/backend/app/services/action_config_service.py index 781943e..710b0cf 100644 --- a/backend/app/services/action_config_service.py +++ b/backend/app/services/action_config_service.py @@ -25,7 +25,13 @@ from app.exceptions import ( ConfigWriteError, JailNotFoundInConfigError, ) -import app.services.config_file_service as config_file_service +import app.services.jail_service as jail_service +from app.utils.config_file_utils import ( + _get_active_jail_names, + _parse_jails_sync, + _safe_jail_name, + build_parser, +) from app.models.config import ( ActionConfig, ActionConfigUpdate, @@ -39,6 +45,22 @@ from app.utils.async_utils import run_blocking log: structlog.stdlib.BoundLogger = structlog.get_logger() +# --------------------------------------------------------------------------- +# Internal wrappers for shared config helpers. +# --------------------------------------------------------------------------- + +def _parse_jails_sync(config_dir: Path) -> tuple[dict[str, dict[str, str]], Path]: + from app.services import config_file_service + + return config_file_service._parse_jails_sync(config_dir) + + +async def _get_active_jail_names(socket_path: str) -> set[str]: + from app.services import config_file_service + + return await config_file_service._get_active_jail_names(socket_path) + + # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- @@ -254,7 +276,7 @@ def _append_jail_action_sync( local_path = jail_d / f"{jail_name}.local" - parser = config_file_service.build_parser() + parser = build_parser() if local_path.is_file(): try: parser.read(str(local_path), encoding="utf-8") @@ -343,7 +365,7 @@ def _remove_jail_action_sync( if not local_path.is_file(): return - parser = config_file_service.build_parser() + parser = build_parser() try: parser.read(str(local_path), encoding="utf-8") except (configparser.Error, OSError) as exc: @@ -476,11 +498,13 @@ async def list_actions( """ action_d = Path(config_dir) / "action.d" - raw_actions: list[tuple[str, str, str, bool, str]] = await run_blocking( _parse_actions_sync, action_d) + from app.services import config_file_service + + raw_actions: list[tuple[str, str, str, bool, str]] = await run_blocking(_parse_actions_sync, action_d) all_jails_result, active_names = await asyncio.gather( - run_blocking(config_file_service._parse_jails_sync, Path(config_dir)), - config_file_service._get_active_jail_names(socket_path), + run_blocking(_parse_jails_sync, Path(config_dir)), + _get_active_jail_names(socket_path), ) all_jails, _source_files = all_jails_result @@ -572,13 +596,13 @@ async def get_action( else: raise ActionNotFoundError(base_name) - content, has_local, source_path = await run_blocking( _read) + content, has_local, source_path = await run_blocking(_read) cfg = conffile_parser.parse_action_file(content, name=base_name, filename=f"{base_name}.conf") all_jails_result, active_names = await asyncio.gather( - run_blocking(config_file_service._parse_jails_sync, Path(config_dir)), - config_file_service._get_active_jail_names(socket_path), + run_blocking(_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) @@ -663,6 +687,8 @@ async def update_action( if do_reload: try: + from app.services import config_file_service + await config_file_service.jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( @@ -731,6 +757,8 @@ async def create_action( if do_reload: try: + from app.services import config_file_service + await config_file_service.jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( @@ -823,11 +851,10 @@ async def assign_action_to_jail( ``action.d/``. ConfigWriteError: If writing fails. """ - config_file_service.safe_jail_name(jail_name) + _safe_jail_name(jail_name) _safe_action_name(req.action_name) - - all_jails, _src = await run_blocking(config_file_service._parse_jails_sync, Path(config_dir)) + all_jails, _src = await run_blocking(_parse_jails_sync, Path(config_dir)) if jail_name not in all_jails: raise JailNotFoundInConfigError(jail_name) @@ -857,6 +884,8 @@ async def assign_action_to_jail( if do_reload: try: + from app.services import config_file_service + await config_file_service.jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( @@ -900,11 +929,10 @@ async def remove_action_from_jail( JailNotFoundError: If *jail_name* is not defined in any config. ConfigWriteError: If writing fails. """ - config_file_service.safe_jail_name(jail_name) + _safe_jail_name(jail_name) _safe_action_name(action_name) - - all_jails, _src = await run_blocking(config_file_service._parse_jails_sync, Path(config_dir)) + all_jails, _src = await run_blocking(_parse_jails_sync, Path(config_dir)) if jail_name not in all_jails: raise JailNotFoundInConfigError(jail_name) @@ -916,6 +944,8 @@ async def remove_action_from_jail( if do_reload: try: + from app.services import config_file_service + await config_file_service.jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( diff --git a/backend/app/services/config_file_service.py b/backend/app/services/config_file_service.py index 60cc7e3..75bde54 100644 --- a/backend/app/services/config_file_service.py +++ b/backend/app/services/config_file_service.py @@ -73,6 +73,27 @@ from app.models.config import ( RollbackResponse, ) from app.utils import conffile_parser +from app.services import action_config_service as _action_config_service +from app.services import filter_config_service as _filter_config_service +from app.services import jail_config_service as _jail_config_service +from app.utils.config_file_utils import ( + _build_inactive_jail, + _build_parser, + _extract_action_base_name, + _get_active_jail_names, + _is_truthy, + _parse_int_safe, + _parse_jails_sync, + _parse_multiline, + _probe_fail2ban_running, + _resolve_filter, + _safe_filter_name, + _safe_jail_name, + _set_jail_local_key_sync, + start_daemon as _start_daemon, + _validate_jail_config_sync, + wait_for_fail2ban as _wait_for_fail2ban, +) from app.utils.constants import FAIL2BAN_TRUTHY_VALUES from app.utils.async_utils import run_blocking from app.utils.fail2ban_client import ( @@ -102,6 +123,7 @@ class _JailServiceProxy: jail_service = _JailServiceProxy() +_write_local_override_sync = _jail_config_service._write_local_override_sync async def _reload_all( @@ -121,65 +143,15 @@ async def _reload_all( # 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"}) - -# False-ish values for the ``enabled`` key. -_FALSE_VALUES: frozenset[str] = frozenset({"false", "no", "0"}) - +# The core config file helper functions are implemented in +# ``app.utils.config_file_utils`` so the config sub-services can import +# shared parsing helpers without creating a circular import path. # --------------------------------------------------------------------------- # 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. @@ -192,814 +164,21 @@ def _ordered_config_files(config_dir: Path) -> list[Path]: """ files: list[Path] = [] - jail_conf = config_dir / "jail.conf" + jail_conf = config_dir / 'jail.conf' if jail_conf.is_file(): files.append(jail_conf) - jail_local = config_dir / "jail.local" + jail_local = config_dir / 'jail.local' if jail_local.is_file(): files.append(jail_local) - jail_d = config_dir / "jail.d" + 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"))) - + 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 FAIL2BAN_TRUTHY_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, - config_dir: Path | None = None, -) -> 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. - config_dir: Absolute path to the fail2ban configuration directory, used - to check whether a ``jail.d/{name}.local`` override file exists. - - 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, - has_local_override=((config_dir / "jail.d" / f"{name}.local").is_file() if config_dir is not None else False), - ) - - -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: object) -> dict[str, object]: - if not isinstance(pairs, (list, tuple)): - return {} - result: dict[str, object] = {} - for item in pairs: - try: - k, v = item - result[str(k)] = v - except (TypeError, ValueError): - pass - return result - - def _ok(response: object) -> object: - code, data = cast("Fail2BanResponse", 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, object], -) -> 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] = cast("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 shared helpers for config file services. ordered_config_files = _ordered_config_files build_parser = _build_parser @@ -1012,8 +191,12 @@ validate_jail_config_sync = _validate_jail_config_sync set_jail_local_key_sync = _set_jail_local_key_sync safe_jail_name = _safe_jail_name safe_filter_name = _safe_filter_name - - +_POST_RELOAD_MAX_ATTEMPTS = _jail_config_service._POST_RELOAD_MAX_ATTEMPTS +_validate_regex_patterns = _jail_config_service._validate_regex_patterns +_write_filter_local_sync = _filter_config_service._write_filter_local_sync +_write_action_local_sync = _action_config_service._write_action_local_sync +_append_jail_action_sync = _action_config_service._append_jail_action_sync +_remove_jail_action_sync = _action_config_service._remove_jail_action_sync # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @@ -1024,9 +207,7 @@ async def list_inactive_jails( socket_path: str, ) -> InactiveJailListResponse: """Delegate to the canonical jail config service.""" - from app.services.jail_config_service import list_inactive_jails as _delegate - - return await _delegate(config_dir, socket_path) + return await _jail_config_service.list_inactive_jails(config_dir, socket_path) async def activate_jail( @@ -1036,9 +217,7 @@ async def activate_jail( req: ActivateJailRequest, ) -> JailActivationResponse: """Delegate to the canonical jail config service.""" - from app.services.jail_config_service import _activate_jail as _delegate - - return await _delegate(config_dir, socket_path, name, req) + return await _jail_config_service._activate_jail(config_dir, socket_path, name, req) async def _rollback_activation_async( @@ -1104,9 +283,7 @@ async def deactivate_jail( name: str, ) -> JailActivationResponse: """Delegate to the canonical jail config service.""" - from app.services.jail_config_service import _deactivate_jail as _delegate - - return await _delegate(config_dir, socket_path, name) + return await _jail_config_service._deactivate_jail(config_dir, socket_path, name) async def delete_jail_local_override( @@ -1115,9 +292,7 @@ async def delete_jail_local_override( name: str, ) -> None: """Delegate to the canonical jail config service.""" - from app.services.jail_config_service import delete_jail_local_override as _delegate - - return await _delegate(config_dir, socket_path, name) + return await _jail_config_service.delete_jail_local_override(config_dir, socket_path, name) async def validate_jail_config( @@ -1125,9 +300,7 @@ async def validate_jail_config( name: str, ) -> JailValidationResult: """Delegate to the canonical jail config service.""" - from app.services.jail_config_service import validate_jail_config as _delegate - - return await _delegate(config_dir, name) + return await _jail_config_service.validate_jail_config(config_dir, name) async def rollback_jail( @@ -1137,9 +310,21 @@ async def rollback_jail( start_cmd_parts: list[str], ) -> RollbackResponse: """Delegate to the canonical jail config helper.""" - from app.services.jail_config_service import _rollback_jail as _delegate + return await _jail_config_service._rollback_jail(config_dir, socket_path, name, start_cmd_parts) - return await _delegate(config_dir, socket_path, name, start_cmd_parts) + +async def start_daemon(start_cmd_parts: list[str]) -> bool: + """Start fail2ban using the configured command.""" + return await _start_daemon(start_cmd_parts) + + +async def wait_for_fail2ban( + socket_path: str, + max_wait_seconds: float, + poll_interval: float = 0.5, +) -> bool: + """Probe the fail2ban socket until it is responsive or the timeout expires.""" + return await _wait_for_fail2ban(socket_path, max_wait_seconds, poll_interval) # --------------------------------------------------------------------------- @@ -1300,9 +485,7 @@ async def list_filters( socket_path: str, ) -> FilterListResponse: """Delegate to the canonical filter config service.""" - from app.services.filter_config_service import list_filters as _delegate - - return await _delegate(config_dir, socket_path) + return await _filter_config_service.list_filters(config_dir, socket_path) async def get_filter( @@ -1311,9 +494,7 @@ async def get_filter( name: str, ) -> FilterConfig: """Delegate to the canonical filter config service.""" - from app.services.filter_config_service import get_filter as _delegate - - return await _delegate(config_dir, socket_path, name) + return await _filter_config_service.get_filter(config_dir, socket_path, name) # --------------------------------------------------------------------------- @@ -1329,9 +510,13 @@ async def update_filter( do_reload: bool = False, ) -> FilterConfig: """Delegate to the canonical filter config service.""" - from app.services.filter_config_service import update_filter as _delegate - - return await _delegate(config_dir, socket_path, name, req, do_reload=do_reload) + return await _filter_config_service.update_filter( + config_dir, + socket_path, + name, + req, + do_reload=do_reload, + ) async def create_filter( @@ -1341,9 +526,12 @@ async def create_filter( do_reload: bool = False, ) -> FilterConfig: """Delegate to the canonical filter config service.""" - from app.services.filter_config_service import create_filter as _delegate - - return await _delegate(config_dir, socket_path, req, do_reload=do_reload) + return await _filter_config_service.create_filter( + config_dir, + socket_path, + req, + do_reload=do_reload, + ) async def delete_filter( @@ -1351,9 +539,7 @@ async def delete_filter( name: str, ) -> None: """Delegate to the canonical filter config service.""" - from app.services.filter_config_service import delete_filter as _delegate - - return await _delegate(config_dir, name) + return await _filter_config_service.delete_filter(config_dir, name) async def assign_filter_to_jail( @@ -1364,9 +550,13 @@ async def assign_filter_to_jail( do_reload: bool = False, ) -> None: """Delegate to the canonical filter config service.""" - from app.services.filter_config_service import assign_filter_to_jail as _delegate - - return await _delegate(config_dir, socket_path, jail_name, req, do_reload=do_reload) + return await _filter_config_service.assign_filter_to_jail( + config_dir, + socket_path, + jail_name, + req, + do_reload=do_reload, + ) # --------------------------------------------------------------------------- @@ -1749,9 +939,7 @@ async def list_actions( socket_path: str, ) -> ActionListResponse: """Delegate to the canonical action config service.""" - from app.services.action_config_service import list_actions as _delegate - - return await _delegate(config_dir, socket_path) + return await _action_config_service.list_actions(config_dir, socket_path) async def get_action( @@ -1760,9 +948,7 @@ async def get_action( name: str, ) -> ActionConfig: """Delegate to the canonical action config service.""" - from app.services.action_config_service import get_action as _delegate - - return await _delegate(config_dir, socket_path, name) + return await _action_config_service.get_action(config_dir, socket_path, name) # --------------------------------------------------------------------------- @@ -1778,9 +964,13 @@ async def update_action( do_reload: bool = False, ) -> ActionConfig: """Delegate to the canonical action config service.""" - from app.services.action_config_service import update_action as _delegate - - return await _delegate(config_dir, socket_path, name, req, do_reload=do_reload) + return await _action_config_service.update_action( + config_dir, + socket_path, + name, + req, + do_reload=do_reload, + ) async def create_action( @@ -1790,9 +980,12 @@ async def create_action( do_reload: bool = False, ) -> ActionConfig: """Delegate to the canonical action config service.""" - from app.services.action_config_service import create_action as _delegate - - return await _delegate(config_dir, socket_path, req, do_reload=do_reload) + return await _action_config_service.create_action( + config_dir, + socket_path, + req, + do_reload=do_reload, + ) async def delete_action( @@ -1800,9 +993,7 @@ async def delete_action( name: str, ) -> None: """Delegate to the canonical action config service.""" - from app.services.action_config_service import delete_action as _delegate - - return await _delegate(config_dir, name) + return await _action_config_service.delete_action(config_dir, name) async def assign_action_to_jail( diff --git a/backend/app/services/filter_config_service.py b/backend/app/services/filter_config_service.py index 31ef96e..ac9ddb8 100644 --- a/backend/app/services/filter_config_service.py +++ b/backend/app/services/filter_config_service.py @@ -23,7 +23,14 @@ from app.exceptions import ( FilterReadonlyError, JailNotFoundInConfigError, ) -import app.services.config_file_service as config_file_service +import app.services.jail_service as jail_service +from app.utils.config_file_utils import ( + _get_active_jail_names, + _parse_jails_sync, + _safe_filter_name, + _safe_jail_name, + set_jail_local_key_sync, +) from app.models.config import ( AssignFilterRequest, FilterConfig, @@ -37,6 +44,22 @@ from app.utils.async_utils import run_blocking log: structlog.stdlib.BoundLogger = structlog.get_logger() +# --------------------------------------------------------------------------- +# Internal wrappers for shared config helpers. +# --------------------------------------------------------------------------- + +def _parse_jails_sync(config_dir: Path) -> tuple[dict[str, dict[str, str]], Path]: + from app.services import config_file_service + + return config_file_service._parse_jails_sync(config_dir) + + +async def _get_active_jail_names(socket_path: str) -> set[str]: + from app.services import config_file_service + + return await config_file_service._get_active_jail_names(socket_path) + + # --------------------------------------------------------------------------- # Additional helper functions for this service # --------------------------------------------------------------------------- @@ -290,13 +313,15 @@ async def list_filters( """ filter_d = Path(config_dir) / "filter.d" + from app.services import config_file_service + # Run the synchronous scan in a thread-pool executor. - raw_filters: list[tuple[str, str, str, bool, str]] = await run_blocking( _parse_filters_sync, filter_d) + raw_filters: list[tuple[str, str, str, bool, str]] = await run_blocking(_parse_filters_sync, filter_d) # Fetch active jail names and their configs concurrently. all_jails_result, active_names = await asyncio.gather( - run_blocking(config_file_service._parse_jails_sync, Path(config_dir)), - config_file_service._get_active_jail_names(socket_path), + run_blocking(_parse_jails_sync, Path(config_dir)), + _get_active_jail_names(socket_path), ) all_jails, _source_files = all_jails_result @@ -394,8 +419,8 @@ async def get_filter( cfg = conffile_parser.parse_filter_file(content, name=base_name, filename=f"{base_name}.conf") all_jails_result, active_names = await asyncio.gather( - run_blocking(config_file_service._parse_jails_sync, Path(config_dir)), - config_file_service._get_active_jail_names(socket_path), + run_blocking(_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) @@ -460,7 +485,7 @@ async def update_filter( ConfigWriteError: If writing the ``.local`` file fails. """ base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name - config_file_service.safe_filter_name(base_name) + _safe_filter_name(base_name) # Validate regex patterns before touching the filesystem. patterns: list[str] = [] @@ -489,6 +514,8 @@ async def update_filter( if do_reload: try: + from app.services import config_file_service + await config_file_service.jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( @@ -531,7 +558,7 @@ async def create_filter( FilterInvalidRegexError: If any regex pattern is invalid. ConfigWriteError: If writing fails. """ - config_file_service.safe_filter_name(req.name) + _safe_filter_name(req.name) filter_d = Path(config_dir) / "filter.d" conf_path = filter_d / f"{req.name}.conf" @@ -563,6 +590,8 @@ async def create_filter( if do_reload: try: + from app.services import config_file_service + await config_file_service.jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( @@ -600,7 +629,7 @@ async def delete_filter( ConfigWriteError: If deletion of the ``.local`` file fails. """ base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name - config_file_service.safe_filter_name(base_name) + _safe_filter_name(base_name) filter_d = Path(config_dir) / "filter.d" conf_path = filter_d / f"{base_name}.conf" @@ -655,11 +684,14 @@ async def assign_filter_to_jail( ``filter.d/``. ConfigWriteError: If writing fails. """ - config_file_service.safe_jail_name(jail_name) - config_file_service.safe_filter_name(req.filter_name) + _safe_jail_name(jail_name) + _safe_filter_name(req.filter_name) + from app.services import config_file_service + + _safe_jail_name(jail_name) # Verify the jail exists in config. - all_jails, _src = await run_blocking(config_file_service._parse_jails_sync, Path(config_dir)) + all_jails, _src = await run_blocking(_parse_jails_sync, Path(config_dir)) if jail_name not in all_jails: raise JailNotFoundInConfigError(jail_name) @@ -674,7 +706,7 @@ async def assign_filter_to_jail( await run_blocking( _check_filter) - await run_blocking(config_file_service.set_jail_local_key_sync, + await run_blocking(set_jail_local_key_sync, Path(config_dir), jail_name, "filter", @@ -683,6 +715,8 @@ async def assign_filter_to_jail( if do_reload: try: + from app.services import config_file_service + await config_file_service.jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( diff --git a/backend/app/services/jail_config_service.py b/backend/app/services/jail_config_service.py index 521425c..536af3b 100644 --- a/backend/app/services/jail_config_service.py +++ b/backend/app/services/jail_config_service.py @@ -25,7 +25,12 @@ from app.exceptions import ( JailNotFoundError, JailNotFoundInConfigError, ) -import app.services.config_file_service as config_file_service +import app.services.jail_service as jail_service +from app.utils.config_file_utils import ( + _build_inactive_jail, + _probe_fail2ban_running, + _safe_jail_name, +) from app.models.config import ( ActivateJailRequest, InactiveJail, @@ -40,6 +45,21 @@ from app.utils.fail2ban_client import Fail2BanClient log: structlog.stdlib.BoundLogger = structlog.get_logger() + +def _parse_jails_sync(config_dir: Path) -> tuple[dict[str, dict[str, str]], dict[str, str]]: + """Delegate the jail config parse helper through the facade.""" + from app.services import config_file_service + + return config_file_service._parse_jails_sync(config_dir) + + +async def _get_active_jail_names(socket_path: str) -> set[str]: + """Delegate the active jail lookup helper through the facade.""" + from app.services import config_file_service + + return await config_file_service._get_active_jail_names(socket_path) + + # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- @@ -240,11 +260,11 @@ async def list_inactive_jails( inactive jails. """ parsed_result: tuple[dict[str, dict[str, str]], dict[str, str]] = await run_blocking( - config_file_service._parse_jails_sync, + _parse_jails_sync, Path(config_dir), ) all_jails, source_files = parsed_result - active_names: set[str] = await config_file_service._get_active_jail_names(socket_path) + active_names: set[str] = await _get_active_jail_names(socket_path) inactive: list[InactiveJail] = [] for jail_name, settings in sorted(all_jails.items()): @@ -253,7 +273,7 @@ async def list_inactive_jails( continue source = source_files.get(jail_name, config_dir) - inactive.append(config_file_service.build_inactive_jail(jail_name, settings, source, Path(config_dir))) + inactive.append(_build_inactive_jail(jail_name, settings, source, Path(config_dir))) log.info( "inactive_jails_listed", @@ -312,21 +332,26 @@ async def _activate_jail( ~app.utils.fail2ban_client.Fail2BanConnectionError: If the fail2ban socket is unreachable during reload. """ - config_file_service.safe_jail_name(name) + _safe_jail_name(name) - all_jails, _source_files = await run_blocking(config_file_service._parse_jails_sync, Path(config_dir)) + all_jails, _source_files = await run_blocking(_parse_jails_sync, Path(config_dir)) if name not in all_jails: raise JailNotFoundInConfigError(name) - active_names = await config_file_service._get_active_jail_names(socket_path) + 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 run_blocking(config_file_service._validate_jail_config_sync, Path(config_dir), name + from app.services import config_file_service + + validation_result: JailValidationResult = await run_blocking( + config_file_service._validate_jail_config_sync, + Path(config_dir), + name, ) warnings: list[str] = [f"{i.field}: {i.message}" for i in validation_result.issues] if warnings: @@ -380,6 +405,8 @@ async def _activate_jail( # Activation reload β€” if it fails, roll back immediately # # ---------------------------------------------------------------------- # try: + from app.services import config_file_service + await config_file_service.jail_service.reload_all(socket_path, include_jails=[name]) except JailNotFoundError as exc: # Jail configuration is invalid (e.g. missing logpath that prevents @@ -522,6 +549,8 @@ async def _rollback_activation_async( # Step 2 β€” reload fail2ban with the restored config. try: + from app.services import config_file_service + await config_file_service.jail_service.reload_all(socket_path) log.info("jail_activation_rollback_reload_ok", jail=name) except Exception as exc: # noqa: BLE001 @@ -529,6 +558,8 @@ async def _rollback_activation_async( return False # Step 3 β€” wait for fail2ban to come back. + from app.services import config_file_service + for attempt in range(_POST_RELOAD_MAX_ATTEMPTS): if attempt > 0: await asyncio.sleep(_POST_RELOAD_PROBE_INTERVAL) @@ -583,6 +614,8 @@ async def _deactivate_jail( ~app.utils.fail2ban_client.Fail2BanConnectionError: If the fail2ban socket is unreachable during reload. """ + from app.services import config_file_service + config_file_service.safe_jail_name(name) all_jails, _source_files = await run_blocking(config_file_service._parse_jails_sync, Path(config_dir)) @@ -602,6 +635,8 @@ async def _deactivate_jail( ) try: + from app.services import config_file_service + await config_file_service.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)) @@ -638,6 +673,8 @@ async def delete_jail_local_override( delete the live config file). ConfigWriteError: If the file cannot be deleted. """ + from app.services import config_file_service + config_file_service.safe_jail_name(name) all_jails, _source_files = await run_blocking(config_file_service._parse_jails_sync, Path(config_dir)) @@ -678,8 +715,11 @@ async def validate_jail_config( Raises: JailNameError: If *name* contains invalid characters. """ + from app.services import config_file_service + config_file_service.safe_jail_name(name) - return await run_blocking(config_file_service._validate_jail_config_sync, + return await run_blocking( + config_file_service._validate_jail_config_sync, Path(config_dir), name, ) @@ -725,7 +765,7 @@ async def _rollback_jail( JailNameError: If *name* contains invalid characters. ConfigWriteError: If writing the ``.local`` file fails. """ - config_file_service.safe_jail_name(name) + _safe_jail_name(name) # Write enabled=false β€” this must succeed even when fail2ban is down. @@ -737,18 +777,23 @@ async def _rollback_jail( ) log.info("jail_rolled_back_disabled", jail=name) + from app.services import config_file_service + # Attempt to start the daemon. started = await config_file_service.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 config_file_service.wait_for_fail2ban(socket_path, max_wait_seconds=10.0, poll_interval=2.0) + fail2ban_running = await config_file_service.wait_for_fail2ban( + socket_path, + max_wait_seconds=10.0, + poll_interval=2.0, + ) active_jails = 0 if fail2ban_running: names = await config_file_service._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(