From cdb0c3681ecbcc8bf0b18bbb9a4c76b40c93cd14 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 15 Apr 2026 21:16:00 +0200 Subject: [PATCH] Task 3: remove config_file_service facade, update direct imports and tests --- Docs/Architekture.md | 12 +- Docs/Tasks.md | 2 + backend/app/models/config.py | 8 +- backend/app/routers/config_misc.py | 7 +- backend/app/services/action_config_service.py | 30 +- backend/app/services/config_file_service.py | 1136 ----------------- backend/app/services/filter_config_service.py | 29 +- backend/app/services/jail_config_service.py | 75 +- backend/tests/test_routers/test_config.py | 12 +- .../test_services/test_config_file_service.py | 654 +++++----- 10 files changed, 385 insertions(+), 1580 deletions(-) delete mode 100644 backend/app/services/config_file_service.py diff --git a/Docs/Architekture.md b/Docs/Architekture.md index 586a022..0413f98 100644 --- a/Docs/Architekture.md +++ b/Docs/Architekture.md @@ -20,14 +20,14 @@ BanGUI is a two-tier web application with a clear separation between frontend an ┌─────────────────────────────┼────────────────────────────────────┐ │ Server │ │ ┌──────────────────────────┴─────────────────────────────────┐ │ -│ │ Backend (FastAPI) │ │ +│ │ Backend (FastAPI) │ │ │ │ Python 3.12+ · Async · Pydantic v2 · structlog │ │ │ └─────┬──────────────┬──────────────┬────────────────────────┘ │ -│ │ │ │ │ -│ ┌─────┴─────┐ ┌─────┴─────┐ ┌────┴─────┐ │ -│ │ SQLite │ │ fail2ban │ │ External │ │ -│ │ (App DB) │ │ (Socket) │ │ APIs │ │ -│ └───────────┘ └───────────┘ └──────────┘ │ +│ │ │ │ │ +│ ┌─────┴─────┐ ┌─────┴─────┐ ┌────┴─────┐ │ +│ │ SQLite │ │ fail2ban │ │ External │ │ +│ │ (App DB) │ │ (Socket) │ │ APIs │ │ +│ └───────────┘ └───────────┘ └──────────┘ │ └──────────────────────────────────────────────────────────────────┘ ``` diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 67aee8c..e977bce 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -62,6 +62,8 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue. **Docs changes needed:** Update `Docs/Refactoring.md` and `Docs/Architekture.md` if it mentions this service. +**Status:** Completed ✅ + **Why this is needed:** Pure delegation façades add indirection with no abstraction benefit and obscure the true dependencies of the system. The hidden circular dependency via lazy imports is a structural risk — a refactor inside any of the three sub-services could easily break the cycle in unexpected ways. --- diff --git a/backend/app/models/config.py b/backend/app/models/config.py index 96890d7..2c055be 100644 --- a/backend/app/models/config.py +++ b/backend/app/models/config.py @@ -283,8 +283,8 @@ class FilterConfig(BaseModel): The ``active``, ``used_by_jails``, ``source_file``, and ``has_local_override`` fields are populated by - :func:`~app.services.config_file_service.list_filters` and - :func:`~app.services.config_file_service.get_filter`. When the model is + :func:`~app.services.filter_config_service.list_filters` and + :func:`~app.services.filter_config_service.get_filter`. When the model is returned from the raw file-based endpoints (``/filters/{name}/parsed``), these fields carry their default values. """ @@ -326,7 +326,7 @@ class FilterConfig(BaseModel): default=None, description="Systemd journal match expression.", ) - # Active-status fields — populated by config_file_service.list_filters / + # Active-status fields — populated by filter_config_service.list_filters / # get_filter; default to safe "inactive" values when not computed. active: bool = Field( default=False, @@ -512,7 +512,7 @@ class ActionConfig(BaseModel): default_factory=dict, description="Runtime parameters that can be overridden per jail.", ) - # Active-status fields — populated by config_file_service.list_actions / + # Active-status fields — populated by action_config_service.list_actions / # get_action; default to safe "inactive" values when not computed. active: bool = Field( default=False, diff --git a/backend/app/routers/config_misc.py b/backend/app/routers/config_misc.py index 7e62422..167d9ca 100644 --- a/backend/app/routers/config_misc.py +++ b/backend/app/routers/config_misc.py @@ -19,7 +19,8 @@ from app.models.config import ( RegexTestResponse, ServiceStatusResponse, ) -from app.services import config_file_service, config_service, jail_service, log_service, setup_service +from app.services import config_service, jail_service, log_service, setup_service +from app.utils.config_file_utils import start_daemon, wait_for_fail2ban from app.exceptions import Fail2BanConnectionError log: structlog.stdlib.BoundLogger = structlog.get_logger() @@ -191,10 +192,10 @@ async def restart_fail2ban( raise _bad_gateway(exc) from exc # Step 2: start the daemon via subprocess. - await config_file_service.start_daemon(start_cmd_parts) + await start_daemon(start_cmd_parts) # Step 3: probe the socket until fail2ban is responsive or the budget expires. - fail2ban_running: bool = await config_file_service.wait_for_fail2ban(socket_path, max_wait_seconds=10.0) + fail2ban_running: bool = await wait_for_fail2ban(socket_path, max_wait_seconds=10.0) if not fail2ban_running: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, diff --git a/backend/app/services/action_config_service.py b/backend/app/services/action_config_service.py index 710b0cf..01bb8a0 100644 --- a/backend/app/services/action_config_service.py +++ b/backend/app/services/action_config_service.py @@ -27,8 +27,8 @@ from app.exceptions import ( ) import app.services.jail_service as jail_service from app.utils.config_file_utils import ( - _get_active_jail_names, - _parse_jails_sync, + _get_active_jail_names as _config_file_get_active_jail_names, + _parse_jails_sync as _config_file_parse_jails_sync, _safe_jail_name, build_parser, ) @@ -50,15 +50,11 @@ log: structlog.stdlib.BoundLogger = structlog.get_logger() # --------------------------------------------------------------------------- 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) + return _config_file_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) + return await _config_file_get_active_jail_names(socket_path) # --------------------------------------------------------------------------- @@ -498,8 +494,6 @@ async def list_actions( """ action_d = Path(config_dir) / "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( @@ -687,9 +681,7 @@ 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) + await jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( "reload_after_action_update_failed", @@ -757,9 +749,7 @@ 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) + await jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( "reload_after_action_create_failed", @@ -884,9 +874,7 @@ 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) + await jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( "reload_after_assign_action_failed", @@ -944,9 +932,7 @@ 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) + await jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( "reload_after_remove_action_failed", diff --git a/backend/app/services/config_file_service.py b/backend/app/services/config_file_service.py deleted file mode 100644 index 75bde54..0000000 --- a/backend/app/services/config_file_service.py +++ /dev/null @@ -1,1136 +0,0 @@ -"""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 cast - -import structlog - -from app.exceptions import ( - ActionAlreadyExistsError, - ActionNameError, - ActionNotFoundError, - ActionReadonlyError, - ConfigWriteError, - FilterAlreadyExistsError, - FilterInvalidRegexError, - FilterNameError, - FilterNotFoundError, - FilterReadonlyError, - JailAlreadyActiveError, - JailAlreadyInactiveError, - JailNameError, - JailNotFoundError, - JailNotFoundInConfigError, -) -import app.services.jail_service as _jail_service_module -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.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 ( - Fail2BanClient, - Fail2BanConnectionError, - Fail2BanResponse, -) - -log: structlog.stdlib.BoundLogger = structlog.get_logger() - - -# Proxy object for jail reload operations. Tests can patch -# app.services.config_file_service.jail_service.reload_all as needed. -class _JailServiceProxy: - async def reload_all( - self, - socket_path: str, - include_jails: list[str] | None = None, - exclude_jails: list[str] | None = None, - ) -> None: - kwargs: dict[str, list[str]] = {} - if include_jails is not None: - kwargs["include_jails"] = include_jails - if exclude_jails is not None: - kwargs["exclude_jails"] = exclude_jails - await _jail_service_module.reload_all(socket_path, **kwargs) - - -jail_service = _JailServiceProxy() -_write_local_override_sync = _jail_config_service._write_local_override_sync - - -async def _reload_all( - socket_path: str, - include_jails: list[str] | None = None, - exclude_jails: list[str] | None = None, -) -> None: - """Reload fail2ban jails using the configured hook or default helper.""" - await jail_service.reload_all( - socket_path, - include_jails=include_jails, - exclude_jails=exclude_jails, - ) - - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -# 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 _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 - - -# Public shared helpers for config file services. -ordered_config_files = _ordered_config_files -build_parser = _build_parser -is_truthy = _is_truthy -parse_multiline = _parse_multiline -parse_jails_sync = _parse_jails_sync -build_inactive_jail = _build_inactive_jail -get_active_jail_names = _get_active_jail_names -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 -# --------------------------------------------------------------------------- - - -async def list_inactive_jails( - config_dir: str, - socket_path: str, -) -> InactiveJailListResponse: - """Delegate to the canonical jail config service.""" - return await _jail_config_service.list_inactive_jails(config_dir, socket_path) - - -async def activate_jail( - config_dir: str, - socket_path: str, - name: str, - req: ActivateJailRequest, -) -> JailActivationResponse: - """Delegate to the canonical jail config service.""" - return await _jail_config_service._activate_jail(config_dir, socket_path, name, req) - - -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. - """ - local_path = Path(config_dir) / "jail.d" / f"{name}.local" - - # Step 1 — restore original file (or delete it). - try: - await run_blocking(_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 _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: - """Delegate to the canonical jail config service.""" - return await _jail_config_service._deactivate_jail(config_dir, socket_path, name) - - -async def delete_jail_local_override( - config_dir: str, - socket_path: str, - name: str, -) -> None: - """Delegate to the canonical jail config service.""" - return await _jail_config_service.delete_jail_local_override(config_dir, socket_path, name) - - -async def validate_jail_config( - config_dir: str, - name: str, -) -> JailValidationResult: - """Delegate to the canonical jail config service.""" - return await _jail_config_service.validate_jail_config(config_dir, name) - - -async def rollback_jail( - config_dir: str, - socket_path: str, - name: str, - start_cmd_parts: list[str], -) -> RollbackResponse: - """Delegate to the canonical jail config helper.""" - return await _jail_config_service._rollback_jail(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) - - -# --------------------------------------------------------------------------- -# 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}$") - - -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: - """Delegate to the canonical filter config service.""" - return await _filter_config_service.list_filters(config_dir, socket_path) - - -async def get_filter( - config_dir: str, - socket_path: str, - name: str, -) -> FilterConfig: - """Delegate to the canonical filter config service.""" - return await _filter_config_service.get_filter(config_dir, socket_path, name) - - -# --------------------------------------------------------------------------- -# 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: - """Delegate to the canonical filter config service.""" - return await _filter_config_service.update_filter( - config_dir, - socket_path, - name, - req, - do_reload=do_reload, - ) - - -async def create_filter( - config_dir: str, - socket_path: str, - req: FilterCreateRequest, - do_reload: bool = False, -) -> FilterConfig: - """Delegate to the canonical filter config service.""" - return await _filter_config_service.create_filter( - config_dir, - socket_path, - req, - do_reload=do_reload, - ) - - -async def delete_filter( - config_dir: str, - name: str, -) -> None: - """Delegate to the canonical filter config service.""" - return await _filter_config_service.delete_filter(config_dir, name) - - -async def assign_filter_to_jail( - config_dir: str, - socket_path: str, - jail_name: str, - req: AssignFilterRequest, - do_reload: bool = False, -) -> None: - """Delegate to the canonical filter config service.""" - return await _filter_config_service.assign_filter_to_jail( - config_dir, - socket_path, - jail_name, - req, - do_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}$") - - -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: - """Delegate to the canonical action config service.""" - return await _action_config_service.list_actions(config_dir, socket_path) - - -async def get_action( - config_dir: str, - socket_path: str, - name: str, -) -> ActionConfig: - """Delegate to the canonical action config service.""" - return await _action_config_service.get_action(config_dir, socket_path, name) - - -# --------------------------------------------------------------------------- -# 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: - """Delegate to the canonical action config service.""" - return await _action_config_service.update_action( - config_dir, - socket_path, - name, - req, - do_reload=do_reload, - ) - - -async def create_action( - config_dir: str, - socket_path: str, - req: ActionCreateRequest, - do_reload: bool = False, -) -> ActionConfig: - """Delegate to the canonical action config service.""" - return await _action_config_service.create_action( - config_dir, - socket_path, - req, - do_reload=do_reload, - ) - - -async def delete_action( - config_dir: str, - name: str, -) -> None: - """Delegate to the canonical action config service.""" - return await _action_config_service.delete_action(config_dir, name) - - -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) - - all_jails, _src = await run_blocking(_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 run_blocking(_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 run_blocking( - _append_jail_action_sync, - Path(config_dir), - jail_name, - action_entry, - ) - - if do_reload: - try: - await _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) - - all_jails, _src = await run_blocking(_parse_jails_sync, Path(config_dir)) - if jail_name not in all_jails: - raise JailNotFoundInConfigError(jail_name) - - await run_blocking( - _remove_jail_action_sync, - Path(config_dir), - jail_name, - action_name, - ) - - if do_reload: - try: - await _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, - ) diff --git a/backend/app/services/filter_config_service.py b/backend/app/services/filter_config_service.py index ac9ddb8..6e5ca68 100644 --- a/backend/app/services/filter_config_service.py +++ b/backend/app/services/filter_config_service.py @@ -25,8 +25,8 @@ from app.exceptions import ( ) import app.services.jail_service as jail_service from app.utils.config_file_utils import ( - _get_active_jail_names, - _parse_jails_sync, + _get_active_jail_names as _config_file_get_active_jail_names, + _parse_jails_sync as _config_file_parse_jails_sync, _safe_filter_name, _safe_jail_name, set_jail_local_key_sync, @@ -49,15 +49,11 @@ log: structlog.stdlib.BoundLogger = structlog.get_logger() # --------------------------------------------------------------------------- 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) + return _config_file_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) + return await _config_file_get_active_jail_names(socket_path) # --------------------------------------------------------------------------- @@ -84,7 +80,7 @@ def _resolve_filter(raw_filter: str, jail_name: str, mode: str) -> str: return result # --------------------------------------------------------------------------- -# Internal helpers - from config_file_service for local use +# Internal helpers imported from the shared config helper module. # --------------------------------------------------------------------------- @@ -313,8 +309,6 @@ 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) @@ -514,9 +508,7 @@ 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) + await jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( "reload_after_filter_update_failed", @@ -590,9 +582,7 @@ 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) + await jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( "reload_after_filter_create_failed", @@ -686,7 +676,6 @@ async def assign_filter_to_jail( """ _safe_jail_name(jail_name) _safe_filter_name(req.filter_name) - from app.services import config_file_service _safe_jail_name(jail_name) @@ -715,9 +704,7 @@ 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) + await jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( "reload_after_assign_filter_failed", diff --git a/backend/app/services/jail_config_service.py b/backend/app/services/jail_config_service.py index 536af3b..335359b 100644 --- a/backend/app/services/jail_config_service.py +++ b/backend/app/services/jail_config_service.py @@ -28,8 +28,13 @@ from app.exceptions import ( import app.services.jail_service as jail_service from app.utils.config_file_utils import ( _build_inactive_jail, + _parse_jails_sync as _config_file_parse_jails_sync, + _get_active_jail_names as _config_file_get_active_jail_names, _probe_fail2ban_running, _safe_jail_name, + _validate_jail_config_sync as _config_file_validate_jail_config_sync, + start_daemon, + wait_for_fail2ban, ) from app.models.config import ( ActivateJailRequest, @@ -47,17 +52,13 @@ 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) + """Parse jail config files using the shared config helper.""" + return _config_file_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) + """Return the currently active jail names from fail2ban.""" + return await _config_file_get_active_jail_names(socket_path) # --------------------------------------------------------------------------- @@ -228,8 +229,8 @@ def _validate_regex_patterns(patterns: list[str]) -> None: raise FilterInvalidRegexError(pattern, str(exc)) from exc -# Shared functions from config_file_service are imported directly from the -# canonical shared helper module. +# Shared functions from the legacy config helper are imported directly from +# the canonical shared helper module. # --------------------------------------------------------------------------- @@ -346,10 +347,8 @@ async def _activate_jail( # ---------------------------------------------------------------------- # # Pre-activation validation — collect warnings but do not block # # ---------------------------------------------------------------------- # - from app.services import config_file_service - validation_result: JailValidationResult = await run_blocking( - config_file_service._validate_jail_config_sync, + _config_file_validate_jail_config_sync, Path(config_dir), name, ) @@ -405,9 +404,7 @@ 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]) + await jail_service.reload_all(socket_path, include_jails=[name]) except JailNotFoundError as exc: # Jail configuration is invalid (e.g. missing logpath that prevents # fail2ban from loading the jail). Roll back and provide a specific error. @@ -453,7 +450,7 @@ async def _activate_jail( for attempt in range(_POST_RELOAD_MAX_ATTEMPTS): if attempt > 0: await asyncio.sleep(_POST_RELOAD_PROBE_INTERVAL) - if await config_file_service._probe_fail2ban_running(socket_path): + if await _probe_fail2ban_running(socket_path): fail2ban_running = True break @@ -478,7 +475,7 @@ async def _activate_jail( ) # Verify the jail actually started (config error may prevent it silently). - post_reload_names = await config_file_service._get_active_jail_names(socket_path) + post_reload_names = await _get_active_jail_names(socket_path) actually_running = name in post_reload_names if not actually_running: log.warning( @@ -549,21 +546,17 @@ 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) + 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. - 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) - if await config_file_service._probe_fail2ban_running(socket_path): + if await _probe_fail2ban_running(socket_path): log.info("jail_activation_rollback_recovered", jail=name) return True @@ -614,16 +607,14 @@ async def _deactivate_jail( ~app.utils.fail2ban_client.Fail2BanConnectionError: If the fail2ban socket is unreachable during reload. """ - from app.services import config_file_service + _safe_jail_name(name) - config_file_service.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(_config_file_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 not in active_names: raise JailAlreadyInactiveError(name) @@ -635,9 +626,7 @@ 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]) + 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)) @@ -673,16 +662,14 @@ 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 + _safe_jail_name(name) - config_file_service.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(_config_file_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) @@ -715,11 +702,9 @@ 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) + _safe_jail_name(name) return await run_blocking( - config_file_service._validate_jail_config_sync, + _config_file_validate_jail_config_sync, Path(config_dir), name, ) @@ -777,14 +762,12 @@ 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) + 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 config_file_service.wait_for_fail2ban( + fail2ban_running = await wait_for_fail2ban( socket_path, max_wait_seconds=10.0, poll_interval=2.0, @@ -792,7 +775,7 @@ async def _rollback_jail( active_jails = 0 if fail2ban_running: - names = await config_file_service._get_active_jail_names(socket_path) + 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) diff --git a/backend/tests/test_routers/test_config.py b/backend/tests/test_routers/test_config.py index 1c34b82..40a8284 100644 --- a/backend/tests/test_routers/test_config.py +++ b/backend/tests/test_routers/test_config.py @@ -412,11 +412,11 @@ class TestRestartFail2ban: AsyncMock(return_value=None), ), patch( - "app.routers.config_misc.config_file_service.start_daemon", + "app.routers.config_misc.start_daemon", AsyncMock(return_value=True), ), patch( - "app.routers.config_misc.config_file_service.wait_for_fail2ban", + "app.routers.config_misc.wait_for_fail2ban", AsyncMock(return_value=True), ), ): @@ -432,11 +432,11 @@ class TestRestartFail2ban: AsyncMock(return_value=None), ), patch( - "app.routers.config_misc.config_file_service.start_daemon", + "app.routers.config_misc.start_daemon", AsyncMock(return_value=True), ), patch( - "app.routers.config_misc.config_file_service.wait_for_fail2ban", + "app.routers.config_misc.wait_for_fail2ban", AsyncMock(return_value=False), ), ): @@ -477,11 +477,11 @@ class TestRestartFail2ban: AsyncMock(return_value=None), ), patch( - "app.routers.config_misc.config_file_service.start_daemon", + "app.routers.config_misc.start_daemon", mock_start, ), patch( - "app.routers.config_misc.config_file_service.wait_for_fail2ban", + "app.routers.config_misc.wait_for_fail2ban", AsyncMock(return_value=True), ), ): diff --git a/backend/tests/test_services/test_config_file_service.py b/backend/tests/test_services/test_config_file_service.py index 26b7918..667ebc7 100644 --- a/backend/tests/test_services/test_config_file_service.py +++ b/backend/tests/test_services/test_config_file_service.py @@ -7,26 +7,10 @@ from unittest.mock import AsyncMock, patch import pytest -from app.services.config_file_service import ( - JailAlreadyActiveError, - JailAlreadyInactiveError, - JailNameError, - JailNotFoundInConfigError, - _build_inactive_jail, - _extract_action_base_name, - _extract_filter_base_name, - _ordered_config_files, - _parse_jails_sync, - _resolve_filter, - _safe_jail_name, - _validate_jail_config_sync, - _write_local_override_sync, - activate_jail, - deactivate_jail, - list_inactive_jails, - rollback_jail, - validate_jail_config, -) +from app.exceptions import (JailAlreadyActiveError, JailAlreadyInactiveError, JailNameError, JailNotFoundInConfigError) +from app.services.jail_config_service import (_write_local_override_sync, activate_jail, deactivate_jail, list_inactive_jails, rollback_jail, validate_jail_config) +from app.utils.config_file_utils import (_build_inactive_jail, _extract_action_base_name, _ordered_config_files, _parse_jails_sync, _resolve_filter, _safe_jail_name, _validate_jail_config_sync) +from app.services.filter_config_service import (_extract_filter_base_name) # --------------------------------------------------------------------------- # Helpers @@ -392,7 +376,7 @@ class TestListInactiveJails: _write(tmp_path / "jail.conf", JAIL_CONF) # sshd is enabled=true; apache-auth is enabled=false with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value={"sshd"}), ): result = await list_inactive_jails(str(tmp_path), "/fake.sock") @@ -404,7 +388,7 @@ class TestListInactiveJails: async def test_total_matches_jails_count(self, tmp_path: Path) -> None: _write(tmp_path / "jail.conf", JAIL_CONF) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value={"sshd"}), ): result = await list_inactive_jails(str(tmp_path), "/fake.sock") @@ -413,7 +397,7 @@ class TestListInactiveJails: async def test_empty_config_dir(self, tmp_path: Path) -> None: with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await list_inactive_jails(str(tmp_path), "/fake.sock") @@ -424,7 +408,7 @@ class TestListInactiveJails: async def test_all_active_returns_empty(self, tmp_path: Path) -> None: _write(tmp_path / "jail.conf", JAIL_CONF) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value={"sshd", "apache-auth"}), ): result = await list_inactive_jails(str(tmp_path), "/fake.sock") @@ -436,7 +420,7 @@ class TestListInactiveJails: # so every config-defined jail appears as inactive. _write(tmp_path / "jail.conf", JAIL_CONF) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await list_inactive_jails(str(tmp_path), "/fake.sock") @@ -452,7 +436,7 @@ class TestListInactiveJails: local.parent.mkdir(parents=True, exist_ok=True) local.write_text("[apache-auth]\nenabled = false\n") with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await list_inactive_jails(str(tmp_path), "/fake.sock") @@ -463,7 +447,7 @@ class TestListInactiveJails: """has_local_override is False when no jail.d .local file exists.""" _write(tmp_path / "jail.conf", JAIL_CONF) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await list_inactive_jails(str(tmp_path), "/fake.sock") @@ -478,11 +462,11 @@ class TestListInactiveJails: @pytest.mark.asyncio class TestDeleteJailLocalOverride: - """Tests for :func:`~app.services.config_file_service.delete_jail_local_override`.""" + """Tests for :func:`~app.services.jail_config_service.delete_jail_local_override`.""" async def test_deletes_local_file(self, tmp_path: Path) -> None: """delete_jail_local_override removes the jail.d/.local file.""" - from app.services.config_file_service import delete_jail_local_override + from app.services.jail_config_service import (delete_jail_local_override) _write(tmp_path / "jail.conf", JAIL_CONF) local = tmp_path / "jail.d" / "apache-auth.local" @@ -490,7 +474,7 @@ class TestDeleteJailLocalOverride: local.write_text("[apache-auth]\nenabled = false\n") with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): await delete_jail_local_override(str(tmp_path), "/fake.sock", "apache-auth") @@ -499,11 +483,11 @@ class TestDeleteJailLocalOverride: async def test_no_error_when_local_file_missing(self, tmp_path: Path) -> None: """delete_jail_local_override succeeds silently when no .local file exists.""" - from app.services.config_file_service import delete_jail_local_override + from app.services.jail_config_service import (delete_jail_local_override) _write(tmp_path / "jail.conf", JAIL_CONF) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): # Must not raise even though there is no .local file. @@ -511,15 +495,13 @@ class TestDeleteJailLocalOverride: async def test_raises_jail_not_found(self, tmp_path: Path) -> None: """delete_jail_local_override raises JailNotFoundInConfigError for unknown jail.""" - from app.services.config_file_service import ( - JailNotFoundInConfigError, - delete_jail_local_override, - ) + from app.exceptions import (JailNotFoundInConfigError) + from app.services.jail_config_service import (delete_jail_local_override) _write(tmp_path / "jail.conf", JAIL_CONF) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), pytest.raises(JailNotFoundInConfigError), @@ -528,10 +510,8 @@ class TestDeleteJailLocalOverride: async def test_raises_jail_already_active(self, tmp_path: Path) -> None: """delete_jail_local_override raises JailAlreadyActiveError when jail is running.""" - from app.services.config_file_service import ( - JailAlreadyActiveError, - delete_jail_local_override, - ) + from app.exceptions import (JailAlreadyActiveError) + from app.services.jail_config_service import (delete_jail_local_override) _write(tmp_path / "jail.conf", JAIL_CONF) local = tmp_path / "jail.d" / "sshd.local" @@ -539,7 +519,7 @@ class TestDeleteJailLocalOverride: local.write_text("[sshd]\nenabled = false\n") with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value={"sshd"}), ), pytest.raises(JailAlreadyActiveError), @@ -548,10 +528,8 @@ class TestDeleteJailLocalOverride: async def test_raises_jail_name_error(self, tmp_path: Path) -> None: """delete_jail_local_override raises JailNameError for invalid jail names.""" - from app.services.config_file_service import ( - JailNameError, - delete_jail_local_override, - ) + from app.exceptions import (JailNameError) + from app.services.jail_config_service import (delete_jail_local_override) with pytest.raises(JailNameError): await delete_jail_local_override(str(tmp_path), "/fake.sock", "../evil") @@ -571,16 +549,16 @@ class TestActivateJail: req = ActivateJailRequest() with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(side_effect=[set(), {"apache-auth"}]), ), - patch("app.services.config_file_service.jail_service") as mock_js, + patch("app.services.jail_config_service.jail_service") as mock_js, patch( - "app.services.config_file_service._probe_fail2ban_running", + "app.services.jail_config_service._probe_fail2ban_running", new=AsyncMock(return_value=True), ), patch( - "app.services.config_file_service._validate_jail_config_sync", + "app.services.jail_config_service._config_file_validate_jail_config_sync", return_value=JailValidationResult(jail_name="apache-auth", valid=True), ), ): @@ -600,7 +578,7 @@ class TestActivateJail: req = ActivateJailRequest() with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), pytest.raises(JailNotFoundInConfigError), @@ -614,7 +592,7 @@ class TestActivateJail: req = ActivateJailRequest() with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value={"sshd"}), ), pytest.raises(JailAlreadyActiveError), @@ -635,17 +613,17 @@ class TestActivateJail: req = ActivateJailRequest(bantime="2h", maxretry=3) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", # First call: pre-activation (not active); second: post-reload (started). new=AsyncMock(side_effect=[set(), {"apache-auth"}]), ), - patch("app.services.config_file_service.jail_service") as mock_js, + patch("app.services.jail_config_service.jail_service") as mock_js, patch( - "app.services.config_file_service._probe_fail2ban_running", + "app.services.jail_config_service._probe_fail2ban_running", new=AsyncMock(return_value=True), ), patch( - "app.services.config_file_service._validate_jail_config_sync", + "app.services.jail_config_service._config_file_validate_jail_config_sync", return_value=JailValidationResult(jail_name="apache-auth", valid=True), ), ): @@ -668,10 +646,10 @@ class TestDeactivateJail: _write(tmp_path / "jail.conf", JAIL_CONF) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value={"sshd"}), ), - patch("app.services.config_file_service.jail_service") as mock_js, + patch("app.services.jail_config_service.jail_service") as mock_js, ): mock_js.reload_all = AsyncMock() result = await deactivate_jail(str(tmp_path), "/fake.sock", "sshd") @@ -685,7 +663,7 @@ class TestDeactivateJail: _write(tmp_path / "jail.conf", JAIL_CONF) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value={"sshd"}), ), pytest.raises(JailNotFoundInConfigError), @@ -696,7 +674,7 @@ class TestDeactivateJail: _write(tmp_path / "jail.conf", JAIL_CONF) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), pytest.raises(JailAlreadyInactiveError), @@ -715,19 +693,19 @@ class TestDeactivateJail: class TestBuildFilterToJailsMap: def test_active_jail_maps_to_filter(self) -> None: - from app.services.config_file_service import _build_filter_to_jails_map + from app.services.filter_config_service import (_build_filter_to_jails_map) result = _build_filter_to_jails_map({"sshd": {"filter": "sshd"}}, {"sshd"}) assert result == {"sshd": ["sshd"]} def test_inactive_jail_not_included(self) -> None: - from app.services.config_file_service import _build_filter_to_jails_map + from app.services.filter_config_service import (_build_filter_to_jails_map) result = _build_filter_to_jails_map({"apache-auth": {"filter": "apache-auth"}}, set()) assert result == {} def test_multiple_jails_sharing_filter(self) -> None: - from app.services.config_file_service import _build_filter_to_jails_map + from app.services.filter_config_service import (_build_filter_to_jails_map) all_jails = { "sshd": {"filter": "sshd"}, @@ -737,13 +715,13 @@ class TestBuildFilterToJailsMap: assert sorted(result["sshd"]) == ["sshd", "sshd-ddos"] def test_mode_suffix_stripped(self) -> None: - from app.services.config_file_service import _build_filter_to_jails_map + from app.services.filter_config_service import (_build_filter_to_jails_map) result = _build_filter_to_jails_map({"sshd": {"filter": "sshd[mode=aggressive]"}}, {"sshd"}) assert "sshd" in result def test_missing_filter_key_falls_back_to_jail_name(self) -> None: - from app.services.config_file_service import _build_filter_to_jails_map + from app.services.filter_config_service import (_build_filter_to_jails_map) # When jail has no "filter" key the code falls back to the jail name. result = _build_filter_to_jails_map({"sshd": {}}, {"sshd"}) @@ -763,13 +741,13 @@ ignoreregex = class TestParseFiltersSync: def test_returns_empty_for_missing_dir(self, tmp_path: Path) -> None: - from app.services.config_file_service import _parse_filters_sync + from app.services.filter_config_service import (_parse_filters_sync) result = _parse_filters_sync(tmp_path / "nonexistent") assert result == [] def test_single_filter_returned(self, tmp_path: Path) -> None: - from app.services.config_file_service import _parse_filters_sync + from app.services.filter_config_service import (_parse_filters_sync) filter_d = tmp_path / "filter.d" _write(filter_d / "nginx.conf", _FILTER_CONF) @@ -785,7 +763,7 @@ class TestParseFiltersSync: assert source_path.endswith("nginx.conf") def test_local_override_detected(self, tmp_path: Path) -> None: - from app.services.config_file_service import _parse_filters_sync + from app.services.filter_config_service import (_parse_filters_sync) filter_d = tmp_path / "filter.d" _write(filter_d / "nginx.conf", _FILTER_CONF) @@ -797,7 +775,7 @@ class TestParseFiltersSync: assert has_local is True def test_local_content_appended_to_content(self, tmp_path: Path) -> None: - from app.services.config_file_service import _parse_filters_sync + from app.services.filter_config_service import (_parse_filters_sync) filter_d = tmp_path / "filter.d" _write(filter_d / "nginx.conf", _FILTER_CONF) @@ -809,7 +787,7 @@ class TestParseFiltersSync: assert "local tweak" in content def test_sorted_alphabetically(self, tmp_path: Path) -> None: - from app.services.config_file_service import _parse_filters_sync + from app.services.filter_config_service import (_parse_filters_sync) filter_d = tmp_path / "filter.d" for name in ("zzz", "aaa", "mmm"): @@ -829,14 +807,14 @@ class TestParseFiltersSync: @pytest.mark.asyncio class TestListFilters: async def test_returns_all_filters(self, tmp_path: Path) -> None: - from app.services.config_file_service import list_filters + from app.services.filter_config_service import (list_filters) filter_d = tmp_path / "filter.d" _write(filter_d / "sshd.conf", _FILTER_CONF) _write(filter_d / "nginx.conf", _FILTER_CONF) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await list_filters(str(tmp_path), "/fake.sock") @@ -847,14 +825,14 @@ class TestListFilters: assert "nginx" in names async def test_active_flag_set_for_used_filter(self, tmp_path: Path) -> None: - from app.services.config_file_service import list_filters + from app.services.filter_config_service import (list_filters) filter_d = tmp_path / "filter.d" _write(filter_d / "sshd.conf", _FILTER_CONF) _write(tmp_path / "jail.conf", JAIL_CONF) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value={"sshd"}), ): result = await list_filters(str(tmp_path), "/fake.sock") @@ -864,14 +842,14 @@ class TestListFilters: assert "sshd" in sshd.used_by_jails async def test_inactive_filter_not_marked_active(self, tmp_path: Path) -> None: - from app.services.config_file_service import list_filters + from app.services.filter_config_service import (list_filters) filter_d = tmp_path / "filter.d" _write(filter_d / "nginx.conf", _FILTER_CONF) _write(tmp_path / "jail.conf", JAIL_CONF) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value={"sshd"}), ): result = await list_filters(str(tmp_path), "/fake.sock") @@ -881,14 +859,14 @@ class TestListFilters: assert nginx.used_by_jails == [] async def test_has_local_override_detected(self, tmp_path: Path) -> None: - from app.services.config_file_service import list_filters + from app.services.filter_config_service import (list_filters) filter_d = tmp_path / "filter.d" _write(filter_d / "sshd.conf", _FILTER_CONF) _write(filter_d / "sshd.local", "[Definition]\n") with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await list_filters(str(tmp_path), "/fake.sock") @@ -897,10 +875,10 @@ class TestListFilters: assert sshd.has_local_override is True async def test_empty_filter_d_returns_empty(self, tmp_path: Path) -> None: - from app.services.config_file_service import list_filters + from app.services.filter_config_service import (list_filters) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await list_filters(str(tmp_path), "/fake.sock") @@ -917,14 +895,14 @@ class TestListFilters: @pytest.mark.asyncio class TestGetFilter: async def test_returns_filter_config(self, tmp_path: Path) -> None: - from app.services.config_file_service import get_filter + from app.services.filter_config_service import (get_filter) filter_d = tmp_path / "filter.d" _write(filter_d / "sshd.conf", _FILTER_CONF) _write(tmp_path / "jail.conf", JAIL_CONF) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value={"sshd"}), ): result = await get_filter(str(tmp_path), "/fake.sock", "sshd") @@ -934,13 +912,13 @@ class TestGetFilter: assert "sshd" in result.used_by_jails async def test_accepts_conf_extension(self, tmp_path: Path) -> None: - from app.services.config_file_service import get_filter + from app.services.filter_config_service import (get_filter) filter_d = tmp_path / "filter.d" _write(filter_d / "sshd.conf", _FILTER_CONF) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await get_filter(str(tmp_path), "/fake.sock", "sshd.conf") @@ -948,11 +926,12 @@ class TestGetFilter: assert result.name == "sshd" async def test_raises_filter_not_found(self, tmp_path: Path) -> None: - from app.services.config_file_service import FilterNotFoundError, get_filter + from app.exceptions import (FilterNotFoundError) + from app.services.filter_config_service import (get_filter) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), pytest.raises(FilterNotFoundError), @@ -960,14 +939,14 @@ class TestGetFilter: await get_filter(str(tmp_path), "/fake.sock", "nonexistent") async def test_has_local_override_detected(self, tmp_path: Path) -> None: - from app.services.config_file_service import get_filter + from app.services.filter_config_service import (get_filter) filter_d = tmp_path / "filter.d" _write(filter_d / "sshd.conf", _FILTER_CONF) _write(filter_d / "sshd.local", "[Definition]\n") with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await get_filter(str(tmp_path), "/fake.sock", "sshd") @@ -984,7 +963,7 @@ class TestParseFiltersSyncLocalOnly: """Verify that .local-only user-created filters appear in results.""" def test_local_only_included(self, tmp_path: Path) -> None: - from app.services.config_file_service import _parse_filters_sync + from app.services.filter_config_service import (_parse_filters_sync) filter_d = tmp_path / "filter.d" _write(filter_d / "custom.local", "[Definition]\nfailregex = ^fail\n") @@ -999,7 +978,7 @@ class TestParseFiltersSyncLocalOnly: assert source_path.endswith("custom.local") def test_local_only_not_duplicated_when_conf_exists(self, tmp_path: Path) -> None: - from app.services.config_file_service import _parse_filters_sync + from app.services.filter_config_service import (_parse_filters_sync) filter_d = tmp_path / "filter.d" _write(filter_d / "sshd.conf", _FILTER_CONF) @@ -1014,7 +993,7 @@ class TestParseFiltersSyncLocalOnly: assert has_local is True # conf + local → has_local_override def test_local_only_sorted_with_conf_filters(self, tmp_path: Path) -> None: - from app.services.config_file_service import _parse_filters_sync + from app.services.filter_config_service import (_parse_filters_sync) filter_d = tmp_path / "filter.d" _write(filter_d / "zzz.conf", _FILTER_CONF) @@ -1036,7 +1015,7 @@ class TestGetFilterLocalOnly: """Verify that get_filter handles .local-only user-created filters.""" async def test_returns_local_only_filter(self, tmp_path: Path) -> None: - from app.services.config_file_service import get_filter + from app.services.filter_config_service import (get_filter) filter_d = tmp_path / "filter.d" _write( @@ -1045,7 +1024,7 @@ class TestGetFilterLocalOnly: ) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await get_filter(str(tmp_path), "/fake.sock", "custom") @@ -1056,11 +1035,12 @@ class TestGetFilterLocalOnly: assert len(result.failregex) == 1 async def test_raises_when_neither_conf_nor_local(self, tmp_path: Path) -> None: - from app.services.config_file_service import FilterNotFoundError, get_filter + from app.exceptions import (FilterNotFoundError) + from app.services.filter_config_service import (get_filter) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), pytest.raises(FilterNotFoundError), @@ -1068,13 +1048,13 @@ class TestGetFilterLocalOnly: await get_filter(str(tmp_path), "/fake.sock", "nonexistent") async def test_accepts_local_extension(self, tmp_path: Path) -> None: - from app.services.config_file_service import get_filter + from app.services.filter_config_service import (get_filter) filter_d = tmp_path / "filter.d" _write(filter_d / "custom.local", "[Definition]\nfailregex = x\n") with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await get_filter(str(tmp_path), "/fake.sock", "custom.local") @@ -1089,20 +1069,18 @@ class TestGetFilterLocalOnly: class TestValidateRegexPatterns: def test_valid_patterns_pass(self) -> None: - from app.services.config_file_service import _validate_regex_patterns + from app.services.filter_config_service import (_validate_regex_patterns) _validate_regex_patterns([r"^fail from \S+", r"\d+\.\d+"]) def test_empty_list_passes(self) -> None: - from app.services.config_file_service import _validate_regex_patterns + from app.services.filter_config_service import (_validate_regex_patterns) _validate_regex_patterns([]) def test_invalid_pattern_raises(self) -> None: - from app.services.config_file_service import ( - FilterInvalidRegexError, - _validate_regex_patterns, - ) + from app.exceptions import (FilterInvalidRegexError) + from app.services.filter_config_service import (_validate_regex_patterns) with pytest.raises(FilterInvalidRegexError) as exc_info: _validate_regex_patterns([r"[unclosed"]) @@ -1110,10 +1088,8 @@ class TestValidateRegexPatterns: assert "[unclosed" in exc_info.value.pattern def test_mixed_valid_invalid_raises_on_first_invalid(self) -> None: - from app.services.config_file_service import ( - FilterInvalidRegexError, - _validate_regex_patterns, - ) + from app.exceptions import (FilterInvalidRegexError) + from app.services.filter_config_service import (_validate_regex_patterns) with pytest.raises(FilterInvalidRegexError) as exc_info: _validate_regex_patterns([r"\d+", r"[bad", r"\w+"]) @@ -1128,7 +1104,7 @@ class TestValidateRegexPatterns: class TestWriteFilterLocalSync: def test_writes_file(self, tmp_path: Path) -> None: - from app.services.config_file_service import _write_filter_local_sync + from app.services.filter_config_service import (_write_filter_local_sync) filter_d = tmp_path / "filter.d" filter_d.mkdir() @@ -1139,14 +1115,14 @@ class TestWriteFilterLocalSync: assert "[Definition]" in local.read_text() def test_creates_filter_d_if_missing(self, tmp_path: Path) -> None: - from app.services.config_file_service import _write_filter_local_sync + from app.services.filter_config_service import (_write_filter_local_sync) filter_d = tmp_path / "filter.d" _write_filter_local_sync(filter_d, "test", "[Definition]\n") assert (filter_d / "test.local").is_file() def test_overwrites_existing_file(self, tmp_path: Path) -> None: - from app.services.config_file_service import _write_filter_local_sync + from app.services.filter_config_service import (_write_filter_local_sync) filter_d = tmp_path / "filter.d" filter_d.mkdir() @@ -1165,7 +1141,7 @@ class TestWriteFilterLocalSync: class TestSetJailLocalKeySync: def test_creates_new_local_file(self, tmp_path: Path) -> None: - from app.services.config_file_service import _set_jail_local_key_sync + from app.utils.config_file_utils import (_set_jail_local_key_sync) _set_jail_local_key_sync(tmp_path, "sshd", "filter", "myfilter") @@ -1176,7 +1152,7 @@ class TestSetJailLocalKeySync: assert "myfilter" in content def test_updates_existing_local_file(self, tmp_path: Path) -> None: - from app.services.config_file_service import _set_jail_local_key_sync + from app.utils.config_file_utils import (_set_jail_local_key_sync) jail_d = tmp_path / "jail.d" jail_d.mkdir() @@ -1190,7 +1166,7 @@ class TestSetJailLocalKeySync: assert "enabled" in content def test_overwrites_existing_key(self, tmp_path: Path) -> None: - from app.services.config_file_service import _set_jail_local_key_sync + from app.utils.config_file_utils import (_set_jail_local_key_sync) jail_d = tmp_path / "jail.d" jail_d.mkdir() @@ -1221,13 +1197,13 @@ ignoreregex = class TestUpdateFilter: async def test_writes_local_override(self, tmp_path: Path) -> None: from app.models.config import FilterUpdateRequest - from app.services.config_file_service import update_filter + from app.services.filter_config_service import (update_filter) filter_d = tmp_path / "filter.d" _write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await update_filter( @@ -1244,13 +1220,13 @@ class TestUpdateFilter: async def test_accepts_conf_extension(self, tmp_path: Path) -> None: from app.models.config import FilterUpdateRequest - from app.services.config_file_service import update_filter + from app.services.filter_config_service import (update_filter) filter_d = tmp_path / "filter.d" _write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await update_filter( @@ -1264,11 +1240,12 @@ class TestUpdateFilter: async def test_raises_filter_not_found(self, tmp_path: Path) -> None: from app.models.config import FilterUpdateRequest - from app.services.config_file_service import FilterNotFoundError, update_filter + from app.exceptions import (FilterNotFoundError) + from app.services.filter_config_service import (update_filter) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), pytest.raises(FilterNotFoundError), @@ -1282,17 +1259,15 @@ class TestUpdateFilter: async def test_raises_on_invalid_regex(self, tmp_path: Path) -> None: from app.models.config import FilterUpdateRequest - from app.services.config_file_service import ( - FilterInvalidRegexError, - update_filter, - ) + from app.exceptions import (FilterInvalidRegexError) + from app.services.filter_config_service import (update_filter) filter_d = tmp_path / "filter.d" _write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), pytest.raises(FilterInvalidRegexError), @@ -1306,7 +1281,8 @@ class TestUpdateFilter: async def test_raises_filter_name_error_for_invalid_name(self, tmp_path: Path) -> None: from app.models.config import FilterUpdateRequest - from app.services.config_file_service import FilterNameError, update_filter + from app.exceptions import (FilterNameError) + from app.services.filter_config_service import (update_filter) with pytest.raises(FilterNameError): await update_filter( @@ -1318,18 +1294,18 @@ class TestUpdateFilter: async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None: from app.models.config import FilterUpdateRequest - from app.services.config_file_service import update_filter + from app.services.filter_config_service import (update_filter) filter_d = tmp_path / "filter.d" _write(filter_d / "sshd.conf", _FILTER_CONF) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), patch( - "app.services.config_file_service.jail_service.reload_all", + "app.services.jail_config_service.jail_service.reload_all", new=AsyncMock(), ) as mock_reload, ): @@ -1353,10 +1329,10 @@ class TestUpdateFilter: class TestCreateFilter: async def test_creates_local_file(self, tmp_path: Path) -> None: from app.models.config import FilterCreateRequest - from app.services.config_file_service import create_filter + from app.services.filter_config_service import (create_filter) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await create_filter( @@ -1375,14 +1351,15 @@ class TestCreateFilter: async def test_raises_already_exists_when_conf_exists(self, tmp_path: Path) -> None: from app.models.config import FilterCreateRequest - from app.services.config_file_service import FilterAlreadyExistsError, create_filter + from app.exceptions import (FilterAlreadyExistsError) + from app.services.filter_config_service import (create_filter) filter_d = tmp_path / "filter.d" _write(filter_d / "sshd.conf", _FILTER_CONF) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), pytest.raises(FilterAlreadyExistsError), @@ -1395,14 +1372,15 @@ class TestCreateFilter: async def test_raises_already_exists_when_local_exists(self, tmp_path: Path) -> None: from app.models.config import FilterCreateRequest - from app.services.config_file_service import FilterAlreadyExistsError, create_filter + from app.exceptions import (FilterAlreadyExistsError) + from app.services.filter_config_service import (create_filter) filter_d = tmp_path / "filter.d" _write(filter_d / "custom.local", "[Definition]\n") with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), pytest.raises(FilterAlreadyExistsError), @@ -1415,11 +1393,12 @@ class TestCreateFilter: async def test_raises_invalid_regex(self, tmp_path: Path) -> None: from app.models.config import FilterCreateRequest - from app.services.config_file_service import FilterInvalidRegexError, create_filter + from app.exceptions import (FilterInvalidRegexError) + from app.services.filter_config_service import (create_filter) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), pytest.raises(FilterInvalidRegexError), @@ -1432,7 +1411,8 @@ class TestCreateFilter: async def test_raises_filter_name_error_for_invalid_name(self, tmp_path: Path) -> None: from app.models.config import FilterCreateRequest - from app.services.config_file_service import FilterNameError, create_filter + from app.exceptions import (FilterNameError) + from app.services.filter_config_service import (create_filter) with pytest.raises(FilterNameError): await create_filter( @@ -1443,15 +1423,15 @@ class TestCreateFilter: async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None: from app.models.config import FilterCreateRequest - from app.services.config_file_service import create_filter + from app.services.filter_config_service import (create_filter) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), patch( - "app.services.config_file_service.jail_service.reload_all", + "app.services.jail_config_service.jail_service.reload_all", new=AsyncMock(), ) as mock_reload, ): @@ -1473,7 +1453,7 @@ class TestCreateFilter: @pytest.mark.asyncio class TestDeleteFilter: async def test_deletes_local_file_when_conf_and_local_exist(self, tmp_path: Path) -> None: - from app.services.config_file_service import delete_filter + from app.services.filter_config_service import (delete_filter) filter_d = tmp_path / "filter.d" _write(filter_d / "sshd.conf", _FILTER_CONF) @@ -1485,7 +1465,7 @@ class TestDeleteFilter: assert (filter_d / "sshd.conf").exists() async def test_deletes_local_only_filter(self, tmp_path: Path) -> None: - from app.services.config_file_service import delete_filter + from app.services.filter_config_service import (delete_filter) filter_d = tmp_path / "filter.d" _write(filter_d / "custom.local", "[Definition]\n") @@ -1495,7 +1475,8 @@ class TestDeleteFilter: assert not (filter_d / "custom.local").exists() async def test_raises_readonly_for_conf_only(self, tmp_path: Path) -> None: - from app.services.config_file_service import FilterReadonlyError, delete_filter + from app.exceptions import (FilterReadonlyError) + from app.services.filter_config_service import (delete_filter) filter_d = tmp_path / "filter.d" _write(filter_d / "sshd.conf", _FILTER_CONF) @@ -1504,13 +1485,15 @@ class TestDeleteFilter: await delete_filter(str(tmp_path), "sshd") async def test_raises_not_found_for_missing_filter(self, tmp_path: Path) -> None: - from app.services.config_file_service import FilterNotFoundError, delete_filter + from app.exceptions import (FilterNotFoundError) + from app.services.filter_config_service import (delete_filter) with pytest.raises(FilterNotFoundError): await delete_filter(str(tmp_path), "nonexistent") async def test_accepts_filter_name_error_for_invalid_name(self, tmp_path: Path) -> None: - from app.services.config_file_service import FilterNameError, delete_filter + from app.exceptions import (FilterNameError) + from app.services.filter_config_service import (delete_filter) with pytest.raises(FilterNameError): await delete_filter(str(tmp_path), "../evil") @@ -1525,14 +1508,14 @@ class TestDeleteFilter: class TestAssignFilterToJail: async def test_writes_filter_key_to_jail_local(self, tmp_path: Path) -> None: from app.models.config import AssignFilterRequest - from app.services.config_file_service import assign_filter_to_jail + from app.services.filter_config_service import (assign_filter_to_jail) # Setup: jail.conf with sshd jail, filter.conf for "myfilter" _write(tmp_path / "jail.conf", JAIL_CONF) _write(tmp_path / "filter.d" / "myfilter.conf", _FILTER_CONF) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.filter_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): await assign_filter_to_jail( @@ -1549,10 +1532,8 @@ class TestAssignFilterToJail: async def test_raises_jail_not_found(self, tmp_path: Path) -> None: from app.models.config import AssignFilterRequest - from app.services.config_file_service import ( - JailNotFoundInConfigError, - assign_filter_to_jail, - ) + from app.services.filter_config_service import (assign_filter_to_jail) + from app.exceptions import (JailNotFoundInConfigError) _write(tmp_path / "filter.d" / "sshd.conf", _FILTER_CONF) @@ -1566,7 +1547,8 @@ class TestAssignFilterToJail: async def test_raises_filter_not_found(self, tmp_path: Path) -> None: from app.models.config import AssignFilterRequest - from app.services.config_file_service import FilterNotFoundError, assign_filter_to_jail + from app.exceptions import (FilterNotFoundError) + from app.services.filter_config_service import (assign_filter_to_jail) _write(tmp_path / "jail.conf", JAIL_CONF) @@ -1580,7 +1562,8 @@ class TestAssignFilterToJail: async def test_raises_jail_name_error_for_invalid_name(self, tmp_path: Path) -> None: from app.models.config import AssignFilterRequest - from app.services.config_file_service import JailNameError, assign_filter_to_jail + from app.services.filter_config_service import (assign_filter_to_jail) + from app.exceptions import (JailNameError) with pytest.raises(JailNameError): await assign_filter_to_jail( @@ -1592,7 +1575,8 @@ class TestAssignFilterToJail: async def test_raises_filter_name_error_for_invalid_filter(self, tmp_path: Path) -> None: from app.models.config import AssignFilterRequest - from app.services.config_file_service import FilterNameError, assign_filter_to_jail + from app.exceptions import (FilterNameError) + from app.services.filter_config_service import (assign_filter_to_jail) with pytest.raises(FilterNameError): await assign_filter_to_jail( @@ -1604,13 +1588,13 @@ class TestAssignFilterToJail: async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None: from app.models.config import AssignFilterRequest - from app.services.config_file_service import assign_filter_to_jail + from app.services.filter_config_service import (assign_filter_to_jail) _write(tmp_path / "jail.conf", JAIL_CONF) _write(tmp_path / "filter.d" / "myfilter.conf", _FILTER_CONF) with patch( - "app.services.config_file_service.jail_service.reload_all", + "app.services.jail_config_service.jail_service.reload_all", new=AsyncMock(), ) as mock_reload: await assign_filter_to_jail( @@ -1658,34 +1642,37 @@ actionunban = echo unban class TestSafeActionName: def test_valid_simple(self) -> None: - from app.services.config_file_service import _safe_action_name + from app.services.action_config_service import (_safe_action_name) assert _safe_action_name("iptables") == "iptables" def test_valid_with_hyphen(self) -> None: - from app.services.config_file_service import _safe_action_name + from app.services.action_config_service import (_safe_action_name) assert _safe_action_name("iptables-multiport") == "iptables-multiport" def test_valid_with_dot(self) -> None: - from app.services.config_file_service import _safe_action_name + from app.services.action_config_service import (_safe_action_name) assert _safe_action_name("my.action") == "my.action" def test_invalid_path_traversal(self) -> None: - from app.services.config_file_service import ActionNameError, _safe_action_name + from app.exceptions import (ActionNameError) + from app.services.action_config_service import (_safe_action_name) with pytest.raises(ActionNameError): _safe_action_name("../evil") def test_invalid_empty(self) -> None: - from app.services.config_file_service import ActionNameError, _safe_action_name + from app.exceptions import (ActionNameError) + from app.services.action_config_service import (_safe_action_name) with pytest.raises(ActionNameError): _safe_action_name("") def test_invalid_slash(self) -> None: - from app.services.config_file_service import ActionNameError, _safe_action_name + from app.exceptions import (ActionNameError) + from app.services.action_config_service import (_safe_action_name) with pytest.raises(ActionNameError): _safe_action_name("a/b") @@ -1698,32 +1685,32 @@ class TestSafeActionName: class TestBuildActionToJailsMap: def test_active_jail_maps_to_action(self) -> None: - from app.services.config_file_service import _build_action_to_jails_map + from app.services.action_config_service import (_build_action_to_jails_map) result = _build_action_to_jails_map({"sshd": {"action": "iptables-multiport"}}, {"sshd"}) assert result == {"iptables-multiport": ["sshd"]} def test_inactive_jail_not_included(self) -> None: - from app.services.config_file_service import _build_action_to_jails_map + from app.services.action_config_service import (_build_action_to_jails_map) result = _build_action_to_jails_map({"sshd": {"action": "iptables-multiport"}}, set()) assert result == {} def test_multiple_actions_per_jail(self) -> None: - from app.services.config_file_service import _build_action_to_jails_map + from app.services.action_config_service import (_build_action_to_jails_map) result = _build_action_to_jails_map({"sshd": {"action": "iptables-multiport\niptables-ipset"}}, {"sshd"}) assert "iptables-multiport" in result assert "iptables-ipset" in result def test_parameter_block_stripped(self) -> None: - from app.services.config_file_service import _build_action_to_jails_map + from app.services.action_config_service import (_build_action_to_jails_map) result = _build_action_to_jails_map({"sshd": {"action": "iptables[port=ssh, protocol=tcp]"}}, {"sshd"}) assert "iptables" in result def test_multiple_jails_sharing_action(self) -> None: - from app.services.config_file_service import _build_action_to_jails_map + from app.services.action_config_service import (_build_action_to_jails_map) all_jails = { "sshd": {"action": "iptables-multiport"}, @@ -1733,7 +1720,7 @@ class TestBuildActionToJailsMap: assert sorted(result["iptables-multiport"]) == ["apache", "sshd"] def test_jail_with_no_action_key(self) -> None: - from app.services.config_file_service import _build_action_to_jails_map + from app.services.action_config_service import (_build_action_to_jails_map) result = _build_action_to_jails_map({"sshd": {}}, {"sshd"}) assert result == {} @@ -1746,13 +1733,13 @@ class TestBuildActionToJailsMap: class TestParseActionsSync: def test_returns_empty_for_missing_dir(self, tmp_path: Path) -> None: - from app.services.config_file_service import _parse_actions_sync + from app.services.action_config_service import (_parse_actions_sync) result = _parse_actions_sync(tmp_path / "nonexistent") assert result == [] def test_single_action_returned(self, tmp_path: Path) -> None: - from app.services.config_file_service import _parse_actions_sync + from app.services.action_config_service import (_parse_actions_sync) action_d = tmp_path / "action.d" _write(action_d / "iptables.conf", _ACTION_CONF) @@ -1768,7 +1755,7 @@ class TestParseActionsSync: assert source_path.endswith("iptables.conf") def test_local_override_detected(self, tmp_path: Path) -> None: - from app.services.config_file_service import _parse_actions_sync + from app.services.action_config_service import (_parse_actions_sync) action_d = tmp_path / "action.d" _write(action_d / "iptables.conf", _ACTION_CONF) @@ -1780,7 +1767,7 @@ class TestParseActionsSync: assert has_local is True def test_local_content_merged_into_content(self, tmp_path: Path) -> None: - from app.services.config_file_service import _parse_actions_sync + from app.services.action_config_service import (_parse_actions_sync) action_d = tmp_path / "action.d" _write(action_d / "iptables.conf", _ACTION_CONF) @@ -1792,7 +1779,7 @@ class TestParseActionsSync: assert "local override tweak" in content def test_local_only_action_included(self, tmp_path: Path) -> None: - from app.services.config_file_service import _parse_actions_sync + from app.services.action_config_service import (_parse_actions_sync) action_d = tmp_path / "action.d" _write(action_d / "custom.local", _ACTION_CONF_MINIMAL) @@ -1807,7 +1794,7 @@ class TestParseActionsSync: assert source_path.endswith("custom.local") def test_sorted_alphabetically(self, tmp_path: Path) -> None: - from app.services.config_file_service import _parse_actions_sync + from app.services.action_config_service import (_parse_actions_sync) action_d = tmp_path / "action.d" for n in ("zzz", "aaa", "mmm"): @@ -1826,14 +1813,14 @@ class TestParseActionsSync: @pytest.mark.asyncio class TestListActions: async def test_returns_all_actions(self, tmp_path: Path) -> None: - from app.services.config_file_service import list_actions + from app.services.action_config_service import (list_actions) action_d = tmp_path / "action.d" _write(action_d / "iptables.conf", _ACTION_CONF) _write(action_d / "cloudflare.conf", _ACTION_CONF_MINIMAL) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await list_actions(str(tmp_path), "/fake.sock") @@ -1844,7 +1831,7 @@ class TestListActions: assert "cloudflare" in names async def test_active_flag_set_for_used_action(self, tmp_path: Path) -> None: - from app.services.config_file_service import list_actions + from app.services.action_config_service import (list_actions) action_d = tmp_path / "action.d" _write(action_d / "iptables.conf", _ACTION_CONF) @@ -1861,11 +1848,11 @@ class TestListActions: with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value={"sshd"}), ), patch( - "app.services.config_file_service._parse_jails_sync", + "app.services.action_config_service._parse_jails_sync", return_value=(all_jails_with_action, {}), ), ): @@ -1876,13 +1863,13 @@ class TestListActions: assert "sshd" in iptables.used_by_jails async def test_inactive_action_has_active_false(self, tmp_path: Path) -> None: - from app.services.config_file_service import list_actions + from app.services.action_config_service import (list_actions) action_d = tmp_path / "action.d" _write(action_d / "cloudflare.conf", _ACTION_CONF_MINIMAL) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await list_actions(str(tmp_path), "/fake.sock") @@ -1892,14 +1879,14 @@ class TestListActions: assert cf.used_by_jails == [] async def test_has_local_override_detected(self, tmp_path: Path) -> None: - from app.services.config_file_service import list_actions + from app.services.action_config_service import (list_actions) action_d = tmp_path / "action.d" _write(action_d / "iptables.conf", _ACTION_CONF) _write(action_d / "iptables.local", "[Definition]\n") with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await list_actions(str(tmp_path), "/fake.sock") @@ -1908,10 +1895,10 @@ class TestListActions: assert ipt.has_local_override is True async def test_empty_action_d_returns_empty(self, tmp_path: Path) -> None: - from app.services.config_file_service import list_actions + from app.services.action_config_service import (list_actions) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await list_actions(str(tmp_path), "/fake.sock") @@ -1920,13 +1907,13 @@ class TestListActions: assert result.total == 0 async def test_total_matches_actions_count(self, tmp_path: Path) -> None: - from app.services.config_file_service import list_actions + from app.services.action_config_service import (list_actions) action_d = tmp_path / "action.d" _write(action_d / "iptables.conf", _ACTION_CONF) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await list_actions(str(tmp_path), "/fake.sock") @@ -1942,13 +1929,13 @@ class TestListActions: @pytest.mark.asyncio class TestGetAction: async def test_returns_action_config(self, tmp_path: Path) -> None: - from app.services.config_file_service import get_action + from app.services.action_config_service import (get_action) action_d = tmp_path / "action.d" _write(action_d / "iptables.conf", _ACTION_CONF) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await get_action(str(tmp_path), "/fake.sock", "iptables") @@ -1958,13 +1945,13 @@ class TestGetAction: assert "iptables" in (result.actionban or "") async def test_strips_conf_extension(self, tmp_path: Path) -> None: - from app.services.config_file_service import get_action + from app.services.action_config_service import (get_action) action_d = tmp_path / "action.d" _write(action_d / "iptables.conf", _ACTION_CONF) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await get_action(str(tmp_path), "/fake.sock", "iptables.conf") @@ -1972,11 +1959,12 @@ class TestGetAction: assert result.name == "iptables" async def test_raises_for_unknown_action(self, tmp_path: Path) -> None: - from app.services.config_file_service import ActionNotFoundError, get_action + from app.exceptions import (ActionNotFoundError) + from app.services.action_config_service import (get_action) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), pytest.raises(ActionNotFoundError), @@ -1984,13 +1972,13 @@ class TestGetAction: await get_action(str(tmp_path), "/fake.sock", "nonexistent") async def test_local_only_action_returned(self, tmp_path: Path) -> None: - from app.services.config_file_service import get_action + from app.services.action_config_service import (get_action) action_d = tmp_path / "action.d" _write(action_d / "custom.local", _ACTION_CONF_MINIMAL) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await get_action(str(tmp_path), "/fake.sock", "custom") @@ -1998,7 +1986,7 @@ class TestGetAction: assert result.name == "custom" async def test_active_status_populated(self, tmp_path: Path) -> None: - from app.services.config_file_service import get_action + from app.services.action_config_service import (get_action) action_d = tmp_path / "action.d" _write(action_d / "iptables.conf", _ACTION_CONF) @@ -2009,11 +1997,11 @@ class TestGetAction: with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value={"sshd"}), ), patch( - "app.services.config_file_service._parse_jails_sync", + "app.services.action_config_service._parse_jails_sync", return_value=(all_jails_with_action, {}), ), ): @@ -2030,7 +2018,7 @@ class TestGetAction: class TestWriteActionLocalSync: def test_writes_file(self, tmp_path: Path) -> None: - from app.services.config_file_service import _write_action_local_sync + from app.services.action_config_service import (_write_action_local_sync) action_d = tmp_path / "action.d" action_d.mkdir() @@ -2041,14 +2029,14 @@ class TestWriteActionLocalSync: assert "[Definition]" in local.read_text() def test_creates_action_d_if_missing(self, tmp_path: Path) -> None: - from app.services.config_file_service import _write_action_local_sync + from app.services.action_config_service import (_write_action_local_sync) action_d = tmp_path / "action.d" _write_action_local_sync(action_d, "test", "[Definition]\n") assert (action_d / "test.local").is_file() def test_overwrites_existing_file(self, tmp_path: Path) -> None: - from app.services.config_file_service import _write_action_local_sync + from app.services.action_config_service import (_write_action_local_sync) action_d = tmp_path / "action.d" action_d.mkdir() @@ -2069,13 +2057,13 @@ class TestWriteActionLocalSync: class TestUpdateAction: async def test_updates_actionban(self, tmp_path: Path) -> None: from app.models.config import ActionUpdateRequest - from app.services.config_file_service import update_action + from app.services.action_config_service import (update_action) action_d = tmp_path / "action.d" _write(action_d / "iptables.conf", _ACTION_CONF) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await update_action( @@ -2092,11 +2080,12 @@ class TestUpdateAction: async def test_raises_not_found_for_unknown_action(self, tmp_path: Path) -> None: from app.models.config import ActionUpdateRequest - from app.services.config_file_service import ActionNotFoundError, update_action + from app.exceptions import (ActionNotFoundError) + from app.services.action_config_service import (update_action) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), pytest.raises(ActionNotFoundError), @@ -2110,7 +2099,8 @@ class TestUpdateAction: async def test_raises_name_error_for_invalid_name(self, tmp_path: Path) -> None: from app.models.config import ActionUpdateRequest - from app.services.config_file_service import ActionNameError, update_action + from app.exceptions import (ActionNameError) + from app.services.action_config_service import (update_action) with pytest.raises(ActionNameError): await update_action( @@ -2122,18 +2112,18 @@ class TestUpdateAction: async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None: from app.models.config import ActionUpdateRequest - from app.services.config_file_service import update_action + from app.services.action_config_service import (update_action) action_d = tmp_path / "action.d" _write(action_d / "iptables.conf", _ACTION_CONF) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), patch( - "app.services.config_file_service.jail_service.reload_all", + "app.services.jail_config_service.jail_service.reload_all", new=AsyncMock(), ) as mock_reload, ): @@ -2157,10 +2147,10 @@ class TestUpdateAction: class TestCreateAction: async def test_creates_local_file(self, tmp_path: Path) -> None: from app.models.config import ActionCreateRequest - from app.services.config_file_service import create_action + from app.services.action_config_service import (create_action) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): result = await create_action( @@ -2179,10 +2169,8 @@ class TestCreateAction: async def test_raises_already_exists_for_conf(self, tmp_path: Path) -> None: from app.models.config import ActionCreateRequest - from app.services.config_file_service import ( - ActionAlreadyExistsError, - create_action, - ) + from app.exceptions import (ActionAlreadyExistsError) + from app.services.action_config_service import (create_action) action_d = tmp_path / "action.d" _write(action_d / "iptables.conf", _ACTION_CONF) @@ -2196,10 +2184,8 @@ class TestCreateAction: async def test_raises_already_exists_for_local(self, tmp_path: Path) -> None: from app.models.config import ActionCreateRequest - from app.services.config_file_service import ( - ActionAlreadyExistsError, - create_action, - ) + from app.exceptions import (ActionAlreadyExistsError) + from app.services.action_config_service import (create_action) action_d = tmp_path / "action.d" _write(action_d / "custom.local", _ACTION_CONF_MINIMAL) @@ -2213,7 +2199,8 @@ class TestCreateAction: async def test_raises_name_error_for_invalid_name(self, tmp_path: Path) -> None: from app.models.config import ActionCreateRequest - from app.services.config_file_service import ActionNameError, create_action + from app.exceptions import (ActionNameError) + from app.services.action_config_service import (create_action) with pytest.raises(ActionNameError): await create_action( @@ -2224,15 +2211,15 @@ class TestCreateAction: async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None: from app.models.config import ActionCreateRequest - from app.services.config_file_service import create_action + from app.services.action_config_service import (create_action) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), patch( - "app.services.config_file_service.jail_service.reload_all", + "app.services.jail_config_service.jail_service.reload_all", new=AsyncMock(), ) as mock_reload, ): @@ -2254,7 +2241,7 @@ class TestCreateAction: @pytest.mark.asyncio class TestDeleteAction: async def test_deletes_local_file(self, tmp_path: Path) -> None: - from app.services.config_file_service import delete_action + from app.services.action_config_service import (delete_action) action_d = tmp_path / "action.d" _write(action_d / "iptables.conf", _ACTION_CONF) @@ -2266,7 +2253,8 @@ class TestDeleteAction: assert (action_d / "iptables.conf").is_file() # original untouched async def test_raises_readonly_for_conf_only(self, tmp_path: Path) -> None: - from app.services.config_file_service import ActionReadonlyError, delete_action + from app.exceptions import (ActionReadonlyError) + from app.services.action_config_service import (delete_action) action_d = tmp_path / "action.d" _write(action_d / "iptables.conf", _ACTION_CONF) @@ -2275,13 +2263,14 @@ class TestDeleteAction: await delete_action(str(tmp_path), "iptables") async def test_raises_not_found_for_missing(self, tmp_path: Path) -> None: - from app.services.config_file_service import ActionNotFoundError, delete_action + from app.exceptions import (ActionNotFoundError) + from app.services.action_config_service import (delete_action) with pytest.raises(ActionNotFoundError): await delete_action(str(tmp_path), "nonexistent") async def test_deletes_local_only_action(self, tmp_path: Path) -> None: - from app.services.config_file_service import delete_action + from app.services.action_config_service import (delete_action) action_d = tmp_path / "action.d" _write(action_d / "custom.local", _ACTION_CONF_MINIMAL) @@ -2291,7 +2280,8 @@ class TestDeleteAction: assert not (action_d / "custom.local").is_file() async def test_raises_name_error_for_invalid_name(self, tmp_path: Path) -> None: - from app.services.config_file_service import ActionNameError, delete_action + from app.exceptions import (ActionNameError) + from app.services.action_config_service import (delete_action) with pytest.raises(ActionNameError): await delete_action(str(tmp_path), "../etc/evil") @@ -2304,7 +2294,7 @@ class TestDeleteAction: class TestAppendJailActionSync: def test_creates_local_with_action(self, tmp_path: Path) -> None: - from app.services.config_file_service import _append_jail_action_sync + from app.services.action_config_service import (_append_jail_action_sync) _append_jail_action_sync(tmp_path, "sshd", "iptables-multiport") @@ -2313,7 +2303,7 @@ class TestAppendJailActionSync: assert "iptables-multiport" in local.read_text() def test_appends_to_existing_action_list(self, tmp_path: Path) -> None: - from app.services.config_file_service import _append_jail_action_sync + from app.services.action_config_service import (_append_jail_action_sync) jail_d = tmp_path / "jail.d" _write(jail_d / "sshd.local", "[sshd]\naction = iptables-multiport\n") @@ -2325,7 +2315,7 @@ class TestAppendJailActionSync: assert "cloudflare" in content def test_does_not_duplicate_action(self, tmp_path: Path) -> None: - from app.services.config_file_service import _append_jail_action_sync + from app.services.action_config_service import (_append_jail_action_sync) jail_d = tmp_path / "jail.d" _write(jail_d / "sshd.local", "[sshd]\naction = iptables-multiport\n") @@ -2338,7 +2328,7 @@ class TestAppendJailActionSync: assert content.count("iptables-multiport") == 1 def test_does_not_duplicate_when_params_differ(self, tmp_path: Path) -> None: - from app.services.config_file_service import _append_jail_action_sync + from app.services.action_config_service import (_append_jail_action_sync) jail_d = tmp_path / "jail.d" _write( @@ -2360,7 +2350,7 @@ class TestAppendJailActionSync: class TestRemoveJailActionSync: def test_removes_action_from_list(self, tmp_path: Path) -> None: - from app.services.config_file_service import _remove_jail_action_sync + from app.services.action_config_service import (_remove_jail_action_sync) jail_d = tmp_path / "jail.d" _write( @@ -2374,10 +2364,7 @@ class TestRemoveJailActionSync: assert "iptables-multiport" not in content def test_removes_only_targeted_action(self, tmp_path: Path) -> None: - from app.services.config_file_service import ( - _append_jail_action_sync, - _remove_jail_action_sync, - ) + from app.services.action_config_service import (_append_jail_action_sync, _remove_jail_action_sync) jail_d = tmp_path / "jail.d" jail_d.mkdir(parents=True, exist_ok=True) @@ -2391,13 +2378,13 @@ class TestRemoveJailActionSync: assert "cloudflare" in content def test_is_noop_when_no_local_file(self, tmp_path: Path) -> None: - from app.services.config_file_service import _remove_jail_action_sync + from app.services.action_config_service import (_remove_jail_action_sync) # Should not raise; no .local file to modify. _remove_jail_action_sync(tmp_path, "sshd", "iptables-multiport") def test_is_noop_when_action_not_in_list(self, tmp_path: Path) -> None: - from app.services.config_file_service import _remove_jail_action_sync + from app.services.action_config_service import (_remove_jail_action_sync) jail_d = tmp_path / "jail.d" _write(jail_d / "sshd.local", "[sshd]\naction = cloudflare\n") @@ -2417,14 +2404,14 @@ class TestRemoveJailActionSync: class TestAssignActionToJail: async def test_creates_local_with_action(self, tmp_path: Path) -> None: from app.models.config import AssignActionRequest - from app.services.config_file_service import assign_action_to_jail + from app.services.action_config_service import (assign_action_to_jail) _write(tmp_path / "jail.conf", JAIL_CONF) action_d = tmp_path / "action.d" _write(action_d / "iptables.conf", _ACTION_CONF) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): await assign_action_to_jail( @@ -2440,14 +2427,14 @@ class TestAssignActionToJail: async def test_params_written_to_action_entry(self, tmp_path: Path) -> None: from app.models.config import AssignActionRequest - from app.services.config_file_service import assign_action_to_jail + from app.services.action_config_service import (assign_action_to_jail) _write(tmp_path / "jail.conf", JAIL_CONF) action_d = tmp_path / "action.d" _write(action_d / "iptables.conf", _ACTION_CONF) with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): await assign_action_to_jail( @@ -2462,10 +2449,8 @@ class TestAssignActionToJail: async def test_raises_jail_not_found(self, tmp_path: Path) -> None: from app.models.config import AssignActionRequest - from app.services.config_file_service import ( - JailNotFoundInConfigError, - assign_action_to_jail, - ) + from app.services.action_config_service import (assign_action_to_jail) + from app.exceptions import (JailNotFoundInConfigError) with pytest.raises(JailNotFoundInConfigError): await assign_action_to_jail( @@ -2477,10 +2462,8 @@ class TestAssignActionToJail: async def test_raises_action_not_found(self, tmp_path: Path) -> None: from app.models.config import AssignActionRequest - from app.services.config_file_service import ( - ActionNotFoundError, - assign_action_to_jail, - ) + from app.exceptions import (ActionNotFoundError) + from app.services.action_config_service import (assign_action_to_jail) _write(tmp_path / "jail.conf", JAIL_CONF) @@ -2494,7 +2477,8 @@ class TestAssignActionToJail: async def test_raises_jail_name_error(self, tmp_path: Path) -> None: from app.models.config import AssignActionRequest - from app.services.config_file_service import JailNameError, assign_action_to_jail + from app.services.action_config_service import (assign_action_to_jail) + from app.exceptions import (JailNameError) with pytest.raises(JailNameError): await assign_action_to_jail( @@ -2506,10 +2490,8 @@ class TestAssignActionToJail: async def test_raises_action_name_error(self, tmp_path: Path) -> None: from app.models.config import AssignActionRequest - from app.services.config_file_service import ( - ActionNameError, - assign_action_to_jail, - ) + from app.exceptions import (ActionNameError) + from app.services.action_config_service import (assign_action_to_jail) with pytest.raises(ActionNameError): await assign_action_to_jail( @@ -2521,7 +2503,7 @@ class TestAssignActionToJail: async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None: from app.models.config import AssignActionRequest - from app.services.config_file_service import assign_action_to_jail + from app.services.action_config_service import (assign_action_to_jail) _write(tmp_path / "jail.conf", JAIL_CONF) action_d = tmp_path / "action.d" @@ -2529,11 +2511,11 @@ class TestAssignActionToJail: with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.action_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), patch( - "app.services.config_file_service.jail_service.reload_all", + "app.services.jail_config_service.jail_service.reload_all", new=AsyncMock(), ) as mock_reload, ): @@ -2556,14 +2538,14 @@ class TestAssignActionToJail: @pytest.mark.asyncio class TestRemoveActionFromJail: async def test_removes_action_from_local(self, tmp_path: Path) -> None: - from app.services.config_file_service import remove_action_from_jail + from app.services.action_config_service import (remove_action_from_jail) _write(tmp_path / "jail.conf", JAIL_CONF) jail_d = tmp_path / "jail.d" _write(jail_d / "sshd.local", "[sshd]\naction = iptables-multiport\n") with patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ): await remove_action_from_jail(str(tmp_path), "/fake.sock", "sshd", "iptables-multiport") @@ -2572,22 +2554,22 @@ class TestRemoveActionFromJail: assert "iptables-multiport" not in content async def test_raises_jail_not_found(self, tmp_path: Path) -> None: - from app.services.config_file_service import ( - JailNotFoundInConfigError, - remove_action_from_jail, - ) + from app.services.action_config_service import (remove_action_from_jail) + from app.exceptions import (JailNotFoundInConfigError) with pytest.raises(JailNotFoundInConfigError): await remove_action_from_jail(str(tmp_path), "/fake.sock", "nonexistent", "iptables") async def test_raises_jail_name_error(self, tmp_path: Path) -> None: - from app.services.config_file_service import JailNameError, remove_action_from_jail + from app.services.action_config_service import (remove_action_from_jail) + from app.exceptions import (JailNameError) with pytest.raises(JailNameError): await remove_action_from_jail(str(tmp_path), "/fake.sock", "../evil", "iptables") async def test_raises_action_name_error(self, tmp_path: Path) -> None: - from app.services.config_file_service import ActionNameError, remove_action_from_jail + from app.exceptions import (ActionNameError) + from app.services.action_config_service import (remove_action_from_jail) _write(tmp_path / "jail.conf", JAIL_CONF) @@ -2595,7 +2577,7 @@ class TestRemoveActionFromJail: await remove_action_from_jail(str(tmp_path), "/fake.sock", "sshd", "../evil") async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None: - from app.services.config_file_service import remove_action_from_jail + from app.services.action_config_service import (remove_action_from_jail) _write(tmp_path / "jail.conf", JAIL_CONF) jail_d = tmp_path / "jail.d" @@ -2603,11 +2585,11 @@ class TestRemoveActionFromJail: with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), patch( - "app.services.config_file_service.jail_service.reload_all", + "app.services.jail_config_service.jail_service.reload_all", new=AsyncMock(), ) as mock_reload, ): @@ -2633,16 +2615,16 @@ class TestActivateJailReloadArgs: req = ActivateJailRequest() with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(side_effect=[set(), {"apache-auth"}]), ), - patch("app.services.config_file_service.jail_service") as mock_js, + patch("app.services.jail_config_service.jail_service") as mock_js, patch( - "app.services.config_file_service._probe_fail2ban_running", + "app.services.jail_config_service._probe_fail2ban_running", new=AsyncMock(return_value=True), ), patch( - "app.services.config_file_service._validate_jail_config_sync", + "app.services.jail_config_service._config_file_validate_jail_config_sync", return_value=JailValidationResult(jail_name="apache-auth", valid=True), ), ): @@ -2659,16 +2641,16 @@ class TestActivateJailReloadArgs: req = ActivateJailRequest() with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(side_effect=[set(), {"apache-auth"}]), ), - patch("app.services.config_file_service.jail_service") as mock_js, + patch("app.services.jail_config_service.jail_service") as mock_js, patch( - "app.services.config_file_service._probe_fail2ban_running", + "app.services.jail_config_service._probe_fail2ban_running", new=AsyncMock(return_value=True), ), patch( - "app.services.config_file_service._validate_jail_config_sync", + "app.services.jail_config_service._config_file_validate_jail_config_sync", return_value=JailValidationResult(jail_name="apache-auth", valid=True), ), ): @@ -2693,16 +2675,16 @@ class TestActivateJailReloadArgs: # fail2ban is up (probe succeeds) but the jail didn't appear. with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(side_effect=[set(), set()]), ), - patch("app.services.config_file_service.jail_service") as mock_js, + patch("app.services.jail_config_service.jail_service") as mock_js, patch( - "app.services.config_file_service._probe_fail2ban_running", + "app.services.jail_config_service._probe_fail2ban_running", new=AsyncMock(return_value=True), ), patch( - "app.services.config_file_service._validate_jail_config_sync", + "app.services.jail_config_service._config_file_validate_jail_config_sync", return_value=JailValidationResult(jail_name="apache-auth", valid=True), ), ): @@ -2727,10 +2709,10 @@ class TestDeactivateJailReloadArgs: _write(tmp_path / "jail.conf", JAIL_CONF) with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value={"sshd"}), ), - patch("app.services.config_file_service.jail_service") as mock_js, + patch("app.services.jail_config_service.jail_service") as mock_js, ): mock_js.reload_all = AsyncMock() await deactivate_jail(str(tmp_path), "/fake.sock", "sshd") @@ -2887,15 +2869,15 @@ class TestRollbackJail: with ( patch( - "app.services.config_file_service.start_daemon", + "app.services.jail_config_service.start_daemon", new=AsyncMock(return_value=True), ), patch( - "app.services.config_file_service.wait_for_fail2ban", + "app.services.jail_config_service.wait_for_fail2ban", new=AsyncMock(return_value=True), ), patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), ): @@ -2915,11 +2897,11 @@ class TestRollbackJail: with ( patch( - "app.services.config_file_service.start_daemon", + "app.services.jail_config_service.start_daemon", new=AsyncMock(return_value=False), ), patch( - "app.services.config_file_service.wait_for_fail2ban", + "app.services.jail_config_service.wait_for_fail2ban", new=AsyncMock(return_value=False), ), ): @@ -2953,14 +2935,14 @@ class TestActivateJailBlocking: with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), patch( - "app.services.config_file_service._validate_jail_config_sync", + "app.services.jail_config_service._config_file_validate_jail_config_sync", return_value=validation, ), - patch("app.services.config_file_service.jail_service") as mock_js, + patch("app.services.jail_config_service.jail_service") as mock_js, ): mock_js.reload_all = AsyncMock() result = await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req) @@ -2981,14 +2963,14 @@ class TestActivateJailBlocking: with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), patch( - "app.services.config_file_service._validate_jail_config_sync", + "app.services.jail_config_service._config_file_validate_jail_config_sync", return_value=validation, ), - patch("app.services.config_file_service.jail_service") as mock_js, + patch("app.services.jail_config_service.jail_service") as mock_js, ): mock_js.reload_all = AsyncMock() result = await activate_jail(str(tmp_path), "/fake.sock", "sshd", req) @@ -3010,16 +2992,16 @@ class TestActivateJailBlocking: with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(side_effect=[set(), {"apache-auth"}]), ), patch( - "app.services.config_file_service._validate_jail_config_sync", + "app.services.jail_config_service._config_file_validate_jail_config_sync", return_value=validation, ), - patch("app.services.config_file_service.jail_service") as mock_js, + patch("app.services.jail_config_service.jail_service") as mock_js, patch( - "app.services.config_file_service._probe_fail2ban_running", + "app.services.jail_config_service._probe_fail2ban_running", new=AsyncMock(return_value=True), ), ): @@ -3066,16 +3048,16 @@ class TestActivateJailRollback: with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), - patch("app.services.config_file_service.jail_service") as mock_js, + patch("app.services.jail_config_service.jail_service") as mock_js, patch( - "app.services.config_file_service._probe_fail2ban_running", + "app.services.jail_config_service._probe_fail2ban_running", new=AsyncMock(return_value=True), ), patch( - "app.services.config_file_service._validate_jail_config_sync", + "app.services.jail_config_service._config_file_validate_jail_config_sync", return_value=JailValidationResult(jail_name="apache-auth", valid=True), ), ): @@ -3109,22 +3091,22 @@ class TestActivateJailRollback: probe_call_count += 1 # First _POST_RELOAD_MAX_ATTEMPTS probes (health-check after # activation) all fail; subsequent probes (recovery) succeed. - from app.services.config_file_service import _POST_RELOAD_MAX_ATTEMPTS + from app.services.jail_config_service import (_POST_RELOAD_MAX_ATTEMPTS) return probe_call_count > _POST_RELOAD_MAX_ATTEMPTS with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), - patch("app.services.config_file_service.jail_service") as mock_js, + patch("app.services.jail_config_service.jail_service") as mock_js, patch( - "app.services.config_file_service._probe_fail2ban_running", + "app.services.jail_config_service._probe_fail2ban_running", new=AsyncMock(side_effect=probe_side_effect), ), patch( - "app.services.config_file_service._validate_jail_config_sync", + "app.services.jail_config_service._config_file_validate_jail_config_sync", return_value=JailValidationResult(jail_name="apache-auth", valid=True), ), ): @@ -3153,16 +3135,16 @@ class TestActivateJailRollback: with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), - patch("app.services.config_file_service.jail_service") as mock_js, + patch("app.services.jail_config_service.jail_service") as mock_js, patch( - "app.services.config_file_service._probe_fail2ban_running", + "app.services.jail_config_service._probe_fail2ban_running", new=AsyncMock(return_value=True), ), patch( - "app.services.config_file_service._validate_jail_config_sync", + "app.services.jail_config_service._config_file_validate_jail_config_sync", return_value=JailValidationResult(jail_name="apache-auth", valid=True), ), ): @@ -3207,16 +3189,16 @@ class TestActivateJailRollback: with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), - patch("app.services.config_file_service.jail_service") as mock_js, + patch("app.services.jail_config_service.jail_service") as mock_js, patch( - "app.services.config_file_service._probe_fail2ban_running", + "app.services.jail_config_service._probe_fail2ban_running", new=AsyncMock(return_value=True), ), patch( - "app.services.config_file_service._validate_jail_config_sync", + "app.services.jail_config_service._config_file_validate_jail_config_sync", return_value=JailValidationResult(jail_name="apache-auth", valid=True), ), ): @@ -3262,16 +3244,16 @@ class TestActivateJailRollback: with ( patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", new=AsyncMock(return_value=set()), ), - patch("app.services.config_file_service.jail_service") as mock_js, + patch("app.services.jail_config_service.jail_service") as mock_js, patch( - "app.services.config_file_service._probe_fail2ban_running", + "app.services.jail_config_service._probe_fail2ban_running", new=AsyncMock(return_value=True), ), patch( - "app.services.config_file_service._validate_jail_config_sync", + "app.services.jail_config_service._config_file_validate_jail_config_sync", return_value=JailValidationResult(jail_name="apache-auth", valid=True), ), ): @@ -3290,7 +3272,7 @@ class TestActivateJailRollback: @pytest.mark.asyncio class TestRollbackJailIntegration: - """Integration tests for :func:`~app.services.config_file_service.rollback_jail`.""" + """Integration tests for :func:`~app.services.jail_config_service.rollback_jail`.""" async def test_local_file_written_enabled_false(self, tmp_path: Path) -> None: """rollback_jail writes enabled=false to jail.d/{name}.local before any socket call.""" @@ -3298,15 +3280,15 @@ class TestRollbackJailIntegration: with ( patch( - "app.services.config_file_service.start_daemon", + "app.services.jail_config_service.start_daemon", AsyncMock(return_value=True), ), patch( - "app.services.config_file_service.wait_for_fail2ban", + "app.services.jail_config_service.wait_for_fail2ban", AsyncMock(return_value=True), ), patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", AsyncMock(return_value={"sshd"}), ), ): @@ -3322,13 +3304,13 @@ class TestRollbackJailIntegration: mock_start = AsyncMock(return_value=True) with ( - patch("app.services.config_file_service.start_daemon", mock_start), + patch("app.services.jail_config_service.start_daemon", mock_start), patch( - "app.services.config_file_service.wait_for_fail2ban", + "app.services.jail_config_service.wait_for_fail2ban", AsyncMock(return_value=True), ), patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", AsyncMock(return_value={"other"}), ), ): @@ -3344,11 +3326,11 @@ class TestRollbackJailIntegration: """ with ( patch( - "app.services.config_file_service.start_daemon", + "app.services.jail_config_service.start_daemon", AsyncMock(return_value=True), ), patch( - "app.services.config_file_service.wait_for_fail2ban", + "app.services.jail_config_service.wait_for_fail2ban", AsyncMock(return_value=False), # socket still unresponsive ), ): @@ -3360,11 +3342,11 @@ class TestRollbackJailIntegration: """active_jails is 0 in the response when fail2ban_running is False.""" with ( patch( - "app.services.config_file_service.start_daemon", + "app.services.jail_config_service.start_daemon", AsyncMock(return_value=False), ), patch( - "app.services.config_file_service.wait_for_fail2ban", + "app.services.jail_config_service.wait_for_fail2ban", AsyncMock(return_value=False), ), ): @@ -3376,15 +3358,15 @@ class TestRollbackJailIntegration: """active_jails reflects the actual jail count from the socket when fail2ban is up.""" with ( patch( - "app.services.config_file_service.start_daemon", + "app.services.jail_config_service.start_daemon", AsyncMock(return_value=True), ), patch( - "app.services.config_file_service.wait_for_fail2ban", + "app.services.jail_config_service.wait_for_fail2ban", AsyncMock(return_value=True), ), patch( - "app.services.config_file_service._get_active_jail_names", + "app.services.jail_config_service._get_active_jail_names", AsyncMock(return_value={"sshd", "nginx", "apache-auth"}), ), ): @@ -3397,11 +3379,11 @@ class TestRollbackJailIntegration: # fail2ban is down: start_daemon fails and wait_for_fail2ban returns False. with ( patch( - "app.services.config_file_service.start_daemon", + "app.services.jail_config_service.start_daemon", AsyncMock(return_value=False), ), patch( - "app.services.config_file_service.wait_for_fail2ban", + "app.services.jail_config_service.wait_for_fail2ban", AsyncMock(return_value=False), ), ):