diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 5ee71a3..463eb03 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -123,7 +123,7 @@ With multiple copies of the same class name in different modules, `isinstance` c ### Task 5 — Resolve config_file_service.py god object and dual implementations -**Status:** In progress +**Status:** Done **Severity:** High diff --git a/backend/app/services/action_config_service.py b/backend/app/services/action_config_service.py index 2585b8f..781943e 100644 --- a/backend/app/services/action_config_service.py +++ b/backend/app/services/action_config_service.py @@ -25,13 +25,7 @@ from app.exceptions import ( ConfigWriteError, JailNotFoundInConfigError, ) -from app.services.config_file_service import ( - build_parser, - get_active_jail_names, - parse_jails_sync, - safe_jail_name, -) -from app.services.jail_service import reload_all +import app.services.config_file_service as config_file_service from app.models.config import ( ActionConfig, ActionConfigUpdate, @@ -260,7 +254,7 @@ def _append_jail_action_sync( local_path = jail_d / f"{jail_name}.local" - parser = build_parser() + parser = config_file_service.build_parser() if local_path.is_file(): try: parser.read(str(local_path), encoding="utf-8") @@ -349,7 +343,7 @@ def _remove_jail_action_sync( if not local_path.is_file(): return - parser = build_parser() + parser = config_file_service.build_parser() try: parser.read(str(local_path), encoding="utf-8") except (configparser.Error, OSError) as exc: @@ -485,8 +479,8 @@ async def list_actions( raw_actions: list[tuple[str, str, str, bool, str]] = await run_blocking( _parse_actions_sync, action_d) all_jails_result, active_names = await asyncio.gather( - run_blocking(parse_jails_sync, Path(config_dir)), - get_active_jail_names(socket_path), + run_blocking(config_file_service._parse_jails_sync, Path(config_dir)), + config_file_service._get_active_jail_names(socket_path), ) all_jails, _source_files = all_jails_result @@ -583,8 +577,8 @@ async def get_action( cfg = conffile_parser.parse_action_file(content, name=base_name, filename=f"{base_name}.conf") all_jails_result, active_names = await asyncio.gather( - run_blocking(parse_jails_sync, Path(config_dir)), - get_active_jail_names(socket_path), + run_blocking(config_file_service._parse_jails_sync, Path(config_dir)), + config_file_service._get_active_jail_names(socket_path), ) all_jails, _source_files = all_jails_result action_to_jails = _build_action_to_jails_map(all_jails, active_names) @@ -669,7 +663,7 @@ async def update_action( if do_reload: try: - await reload_all(socket_path) + await config_file_service.jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( "reload_after_action_update_failed", @@ -737,7 +731,7 @@ async def create_action( if do_reload: try: - await reload_all(socket_path) + await config_file_service.jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( "reload_after_action_create_failed", @@ -829,11 +823,11 @@ async def assign_action_to_jail( ``action.d/``. ConfigWriteError: If writing fails. """ - safe_jail_name(jail_name) + config_file_service.safe_jail_name(jail_name) _safe_action_name(req.action_name) - all_jails, _src = await run_blocking(parse_jails_sync, Path(config_dir)) + all_jails, _src = await run_blocking(config_file_service._parse_jails_sync, Path(config_dir)) if jail_name not in all_jails: raise JailNotFoundInConfigError(jail_name) @@ -863,7 +857,7 @@ async def assign_action_to_jail( if do_reload: try: - await reload_all(socket_path) + await config_file_service.jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( "reload_after_assign_action_failed", @@ -906,11 +900,11 @@ async def remove_action_from_jail( JailNotFoundError: If *jail_name* is not defined in any config. ConfigWriteError: If writing fails. """ - safe_jail_name(jail_name) + config_file_service.safe_jail_name(jail_name) _safe_action_name(action_name) - all_jails, _src = await run_blocking(parse_jails_sync, Path(config_dir)) + all_jails, _src = await run_blocking(config_file_service._parse_jails_sync, Path(config_dir)) if jail_name not in all_jails: raise JailNotFoundInConfigError(jail_name) @@ -922,7 +916,7 @@ async def remove_action_from_jail( if do_reload: try: - await reload_all(socket_path) + await config_file_service.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 index 32264c2..c9cd9d7 100644 --- a/backend/app/services/config_file_service.py +++ b/backend/app/services/config_file_service.py @@ -49,7 +49,7 @@ from app.exceptions import ( JailNotFoundError, JailNotFoundInConfigError, ) -from app.services.jail_service import reload_all +import app.services.jail_service as _jail_service_module from app.models.config import ( ActionConfig, ActionConfigUpdate, @@ -96,7 +96,7 @@ class _JailServiceProxy: kwargs["include_jails"] = include_jails if exclude_jails is not None: kwargs["exclude_jails"] = exclude_jails - await reload_all(socket_path, **kwargs) + await _jail_service_module.reload_all(socket_path, **kwargs) jail_service = _JailServiceProxy() @@ -108,13 +108,11 @@ async def _reload_all( exclude_jails: list[str] | None = None, ) -> None: """Reload fail2ban jails using the configured hook or default helper.""" - 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 reload_all(socket_path, **kwargs) + await jail_service.reload_all( + socket_path, + include_jails=include_jails, + exclude_jails=exclude_jails, + ) # --------------------------------------------------------------------------- @@ -1026,48 +1024,9 @@ async def list_inactive_jails( config_dir: str, socket_path: str, ) -> InactiveJailListResponse: - """Return all jails defined in config files that are not currently active. - - Parses ``jail.conf``, ``jail.local``, and ``jail.d/`` following the - fail2ban merge order. A jail is considered inactive when: - - - Its merged ``enabled`` value is ``false`` (or absent, which defaults to - ``false`` in fail2ban), **or** - - Its ``enabled`` value is ``true`` in config but fail2ban does not report - it as running. - - Args: - config_dir: Absolute path to the fail2ban configuration directory. - socket_path: Path to the fail2ban Unix domain socket. - - Returns: - :class:`~app.models.config.InactiveJailListResponse` with all - inactive jails. - """ - parsed_result: tuple[dict[str, dict[str, str]], dict[str, str]] = await run_blocking( - _parse_jails_sync, - Path(config_dir), - ) - all_jails, source_files = parsed_result - active_names: set[str] = await _get_active_jail_names(socket_path) - - inactive: list[InactiveJail] = [] - for jail_name, settings in sorted(all_jails.items()): - if jail_name in active_names: - # fail2ban reports this jail as running — skip it. - continue - - source = source_files.get(jail_name, config_dir) - inactive.append(_build_inactive_jail(jail_name, settings, source, Path(config_dir))) - - log.info( - "inactive_jails_listed", - total_defined=len(all_jails), - active=len(active_names), - inactive=len(inactive), - ) - return InactiveJailListResponse(jails=inactive, total=len(inactive)) - + """Delegate to the canonical jail config service.""" + from app.services.jail_config_service import list_inactive_jails as _delegate + return await _delegate(config_dir, socket_path) async def activate_jail( config_dir: str, @@ -1075,201 +1034,9 @@ async def activate_jail( name: str, req: ActivateJailRequest, ) -> JailActivationResponse: - """Enable an inactive jail and reload fail2ban. - - Performs pre-activation validation, writes ``enabled = true`` (plus any - override values from *req*) to ``jail.d/{name}.local``, and triggers a - full fail2ban reload. After the reload a multi-attempt health probe - determines whether fail2ban (and the specific jail) are still running. - - Args: - config_dir: Absolute path to the fail2ban configuration directory. - socket_path: Path to the fail2ban Unix domain socket. - name: Name of the jail to activate. Must exist in the parsed config. - req: Optional override values to write alongside ``enabled = true``. - - Returns: - :class:`~app.models.config.JailActivationResponse` including - ``fail2ban_running`` and ``validation_warnings`` fields. - - Raises: - JailNameError: If *name* contains invalid characters. - JailNotFoundInConfigError: If *name* is not defined in any config file. - JailAlreadyActiveError: If fail2ban already reports *name* as running. - ConfigWriteError: If writing the ``.local`` file fails. - ~app.utils.fail2ban_client.Fail2BanConnectionError: If the fail2ban - socket is unreachable during reload. - """ - _safe_jail_name(name) - - all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir)) - - if name not in all_jails: - raise JailNotFoundInConfigError(name) - - active_names = await _get_active_jail_names(socket_path) - if name in active_names: - raise JailAlreadyActiveError(name) - - # ---------------------------------------------------------------------- # - # Pre-activation validation — collect warnings but do not block # - # ---------------------------------------------------------------------- # - validation_result: JailValidationResult = await run_blocking(_validate_jail_config_sync, Path(config_dir), name - ) - warnings: list[str] = [f"{i.field}: {i.message}" for i in validation_result.issues] - if warnings: - log.warning( - "jail_activation_validation_warnings", - jail=name, - warnings=warnings, - ) - - # Block activation on critical validation failures (missing filter or logpath). - blocking = [i for i in validation_result.issues if i.field in ("filter", "logpath")] - if blocking: - log.warning( - "jail_activation_blocked", - jail=name, - issues=[f"{i.field}: {i.message}" for i in blocking], - ) - return JailActivationResponse( - name=name, - active=False, - fail2ban_running=True, - validation_warnings=warnings, - message=(f"Jail {name!r} cannot be activated: " + "; ".join(i.message for i in blocking)), - ) - - overrides: dict[str, object] = { - "bantime": req.bantime, - "findtime": req.findtime, - "maxretry": req.maxretry, - "port": req.port, - "logpath": req.logpath, - } - - # ---------------------------------------------------------------------- # - # Backup the existing .local file (if any) before overwriting it so that # - # we can restore it if activation fails. # - # ---------------------------------------------------------------------- # - local_path = Path(config_dir) / "jail.d" / f"{name}.local" - original_content: bytes | None = await run_blocking( - lambda: local_path.read_bytes() if local_path.exists() else None, - ) - - await run_blocking(_write_local_override_sync, - Path(config_dir), - name, - True, - overrides, - ) - - # ---------------------------------------------------------------------- # - # Activation reload — if it fails, roll back immediately # - # ---------------------------------------------------------------------- # - try: - await _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. - log.warning( - "reload_after_activate_failed_jail_not_found", - jail=name, - error=str(exc), - ) - recovered = await _rollback_activation_async(config_dir, name, socket_path, original_content) - return JailActivationResponse( - name=name, - active=False, - fail2ban_running=False, - recovered=recovered, - validation_warnings=warnings, - message=( - f"Jail {name!r} activation failed: {str(exc)}. " - "Check that all logpath files exist and are readable. " - "The configuration was " - + ("automatically recovered." if recovered else "not recovered — manual intervention is required.") - ), - ) - except Exception as exc: # noqa: BLE001 - log.warning("reload_after_activate_failed", jail=name, error=str(exc)) - recovered = await _rollback_activation_async(config_dir, name, socket_path, original_content) - return JailActivationResponse( - name=name, - active=False, - fail2ban_running=False, - recovered=recovered, - validation_warnings=warnings, - message=( - f"Jail {name!r} activation failed during reload and the " - "configuration was " - + ("automatically recovered." if recovered else "not recovered — manual intervention is required.") - ), - ) - - # ---------------------------------------------------------------------- # - # Post-reload health probe with retries # - # ---------------------------------------------------------------------- # - fail2ban_running = False - for attempt in range(_POST_RELOAD_MAX_ATTEMPTS): - if attempt > 0: - await asyncio.sleep(_POST_RELOAD_PROBE_INTERVAL) - if await _probe_fail2ban_running(socket_path): - fail2ban_running = True - break - - if not fail2ban_running: - log.warning( - "fail2ban_down_after_activate", - jail=name, - message="fail2ban socket unreachable after reload — initiating rollback.", - ) - recovered = await _rollback_activation_async(config_dir, name, socket_path, original_content) - return JailActivationResponse( - name=name, - active=False, - fail2ban_running=False, - recovered=recovered, - validation_warnings=warnings, - message=( - f"Jail {name!r} activation failed: fail2ban stopped responding " - "after reload. The configuration was " - + ("automatically recovered." if recovered else "not recovered — manual intervention is required.") - ), - ) - - # Verify the jail actually started (config error may prevent it silently). - post_reload_names = await _get_active_jail_names(socket_path) - actually_running = name in post_reload_names - if not actually_running: - log.warning( - "jail_activation_unverified", - jail=name, - message="Jail did not appear in running jails — initiating rollback.", - ) - recovered = await _rollback_activation_async(config_dir, name, socket_path, original_content) - return JailActivationResponse( - name=name, - active=False, - fail2ban_running=True, - recovered=recovered, - validation_warnings=warnings, - message=( - f"Jail {name!r} was written to config but did not start after " - "reload. The configuration was " - + ("automatically recovered." if recovered else "not recovered — manual intervention is required.") - ), - ) - - log.info("jail_activated", jail=name) - return JailActivationResponse( - name=name, - active=True, - fail2ban_running=True, - validation_warnings=warnings, - message=f"Jail {name!r} activated successfully.", - ) - + """Delegate to the canonical jail config service.""" + from app.services.jail_config_service import _activate_jail as _delegate + return await _delegate(config_dir, socket_path, name, req) async def _rollback_activation_async( config_dir: str, @@ -1333,129 +1100,26 @@ async def deactivate_jail( socket_path: str, name: str, ) -> JailActivationResponse: - """Disable an active jail and reload fail2ban. - - Writes ``enabled = false`` to ``jail.d/{name}.local`` and triggers a - full fail2ban reload so the jail stops immediately. - - Args: - config_dir: Absolute path to the fail2ban configuration directory. - socket_path: Path to the fail2ban Unix domain socket. - name: Name of the jail to deactivate. Must exist in the parsed config. - - Returns: - :class:`~app.models.config.JailActivationResponse`. - - Raises: - JailNameError: If *name* contains invalid characters. - JailNotFoundInConfigError: If *name* is not defined in any config file. - JailAlreadyInactiveError: If fail2ban already reports *name* as not - running. - ConfigWriteError: If writing the ``.local`` file fails. - ~app.utils.fail2ban_client.Fail2BanConnectionError: If the fail2ban - socket is unreachable during reload. - """ - _safe_jail_name(name) - - all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir)) - - if name not in all_jails: - raise JailNotFoundInConfigError(name) - - active_names = await _get_active_jail_names(socket_path) - if name not in active_names: - raise JailAlreadyInactiveError(name) - - await run_blocking(_write_local_override_sync, - Path(config_dir), - name, - False, - {}, - ) - - try: - await _reload_all(socket_path, exclude_jails=[name]) - except Exception as exc: # noqa: BLE001 - log.warning("reload_after_deactivate_failed", jail=name, error=str(exc)) - - log.info("jail_deactivated", jail=name) - return JailActivationResponse( - name=name, - active=False, - message=f"Jail {name!r} deactivated successfully.", - ) - + """Delegate to the canonical jail config service.""" + from app.services.jail_config_service import _deactivate_jail as _delegate + return await _delegate(config_dir, socket_path, name) async def delete_jail_local_override( config_dir: str, socket_path: str, name: str, ) -> None: - """Delete the ``jail.d/{name}.local`` override file for an inactive jail. - - This is the clean-up action shown in the config UI when an inactive jail - still has a ``.local`` override file (e.g. ``enabled = false``). The - file is deleted outright; no fail2ban reload is required because the jail - is already inactive. - - Args: - config_dir: Absolute path to the fail2ban configuration directory. - socket_path: Path to the fail2ban Unix domain socket. - name: Name of the jail whose ``.local`` file should be removed. - - Raises: - JailNameError: If *name* contains invalid characters. - JailNotFoundInConfigError: If *name* is not defined in any config file. - JailAlreadyActiveError: If the jail is currently active (refusing to - delete the live config file). - ConfigWriteError: If the file cannot be deleted. - """ - _safe_jail_name(name) - - all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir)) - - if name not in all_jails: - raise JailNotFoundInConfigError(name) - - active_names = await _get_active_jail_names(socket_path) - if name in active_names: - raise JailAlreadyActiveError(name) - - local_path = Path(config_dir) / "jail.d" / f"{name}.local" - try: - await run_blocking( lambda: local_path.unlink(missing_ok=True)) - except OSError as exc: - raise ConfigWriteError(f"Failed to delete {local_path}: {exc}") from exc - - log.info("jail_local_override_deleted", jail=name, path=str(local_path)) - + """Delegate to the canonical jail config service.""" + from app.services.jail_config_service import delete_jail_local_override as _delegate + return await _delegate(config_dir, socket_path, name) async def validate_jail_config( config_dir: str, name: str, ) -> JailValidationResult: - """Run pre-activation validation checks on a jail configuration. - - Validates that referenced filter and action files exist in ``filter.d/`` - and ``action.d/``, that all regex patterns compile, and that declared log - paths exist on disk. - - Args: - config_dir: Absolute path to the fail2ban configuration directory. - name: Name of the jail to validate. - - Returns: - :class:`~app.models.config.JailValidationResult` with any issues found. - - Raises: - JailNameError: If *name* contains invalid characters. - """ - _safe_jail_name(name) - return await run_blocking(_validate_jail_config_sync, - Path(config_dir), - name, - ) - + """Delegate to the canonical jail config service.""" + from app.services.jail_config_service import validate_jail_config as _delegate + return await _delegate(config_dir, name) async def rollback_jail( config_dir: str, @@ -1463,72 +1127,9 @@ async def rollback_jail( name: str, start_cmd_parts: list[str], ) -> RollbackResponse: - """Disable a bad jail config and restart the fail2ban daemon. - - Writes ``enabled = false`` to ``jail.d/{name}.local`` (works even when - fail2ban is down — only a file write), then attempts to start the daemon - with *start_cmd_parts*. Waits up to 10 seconds for the socket to respond. - - Args: - config_dir: Absolute path to the fail2ban configuration directory. - socket_path: Path to the fail2ban Unix domain socket. - name: Name of the jail to disable. - start_cmd_parts: Argument list for the daemon start command, e.g. - ``["fail2ban-client", "start"]``. - - Returns: - :class:`~app.models.config.RollbackResponse`. - - Raises: - JailNameError: If *name* contains invalid characters. - ConfigWriteError: If writing the ``.local`` file fails. - """ - _safe_jail_name(name) - - - # Write enabled=false — this must succeed even when fail2ban is down. - await run_blocking(_write_local_override_sync, - Path(config_dir), - name, - False, - {}, - ) - log.info("jail_rolled_back_disabled", jail=name) - - # Attempt to start the daemon. - started = await start_daemon(start_cmd_parts) - log.info("jail_rollback_start_attempted", jail=name, start_ok=started) - - # Wait for the socket to come back. - fail2ban_running = await wait_for_fail2ban(socket_path, max_wait_seconds=10.0, poll_interval=2.0) - - active_jails = 0 - if fail2ban_running: - names = await _get_active_jail_names(socket_path) - active_jails = len(names) - - if fail2ban_running: - log.info("jail_rollback_success", jail=name, active_jails=active_jails) - return RollbackResponse( - jail_name=name, - disabled=True, - fail2ban_running=True, - active_jails=active_jails, - message=(f"Jail {name!r} disabled and fail2ban restarted successfully with {active_jails} active jail(s)."), - ) - - log.warning("jail_rollback_fail2ban_still_down", jail=name) - return RollbackResponse( - jail_name=name, - disabled=True, - fail2ban_running=False, - active_jails=0, - message=( - f"Jail {name!r} was disabled but fail2ban did not come back online. " - "Check the fail2ban log for additional errors." - ), - ) - + """Delegate to the canonical jail config helper.""" + from app.services.jail_config_service import _rollback_jail as _delegate + return await _delegate(config_dir, socket_path, name, start_cmd_parts) # --------------------------------------------------------------------------- # Filter discovery helpers (Task 2.1) @@ -1687,157 +1288,18 @@ async def list_filters( config_dir: str, socket_path: str, ) -> FilterListResponse: - """Return all available filters from ``filter.d/`` with active/inactive status. - - Scans ``{config_dir}/filter.d/`` for ``.conf`` files, merges any - corresponding ``.local`` overrides, parses each file into a - :class:`~app.models.config.FilterConfig`, and cross-references with the - currently running jails to determine which filters are active. - - A filter is considered *active* when its base name matches the ``filter`` - field of at least one currently running jail. - - Args: - config_dir: Absolute path to the fail2ban configuration directory. - socket_path: Path to the fail2ban Unix domain socket. - - Returns: - :class:`~app.models.config.FilterListResponse` with all filters - sorted alphabetically, active ones carrying non-empty - ``used_by_jails`` lists. - """ - filter_d = Path(config_dir) / "filter.d" - - # 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) - - # Fetch active jail names and their configs concurrently. - all_jails_result, active_names = await asyncio.gather( - run_blocking( _parse_jails_sync, Path(config_dir)), - _get_active_jail_names(socket_path), - ) - all_jails, _source_files = all_jails_result - - filter_to_jails = _build_filter_to_jails_map(all_jails, active_names) - - filters: list[FilterConfig] = [] - for name, filename, content, has_local, source_path in raw_filters: - cfg = conffile_parser.parse_filter_file(content, name=name, filename=filename) - used_by = sorted(filter_to_jails.get(name, [])) - filters.append( - FilterConfig( - name=cfg.name, - filename=cfg.filename, - before=cfg.before, - after=cfg.after, - variables=cfg.variables, - prefregex=cfg.prefregex, - failregex=cfg.failregex, - ignoreregex=cfg.ignoreregex, - maxlines=cfg.maxlines, - datepattern=cfg.datepattern, - journalmatch=cfg.journalmatch, - active=len(used_by) > 0, - used_by_jails=used_by, - source_file=source_path, - has_local_override=has_local, - ) - ) - - log.info("filters_listed", total=len(filters), active=sum(1 for f in filters if f.active)) - return FilterListResponse(filters=filters, total=len(filters)) - + """Delegate to the canonical filter config service.""" + from app.services.filter_config_service import list_filters as _delegate + return await _delegate(config_dir, socket_path) async def get_filter( config_dir: str, socket_path: str, name: str, ) -> FilterConfig: - """Return a single filter from ``filter.d/`` with active/inactive status. - - Reads ``{config_dir}/filter.d/{name}.conf``, merges any ``.local`` - override, and enriches the parsed :class:`~app.models.config.FilterConfig` - with ``active``, ``used_by_jails``, ``source_file``, and - ``has_local_override``. - - Args: - config_dir: Absolute path to the fail2ban configuration directory. - socket_path: Path to the fail2ban Unix domain socket. - name: Filter base name (e.g. ``"sshd"`` or ``"sshd.conf"``). - - Returns: - :class:`~app.models.config.FilterConfig` with status fields populated. - - Raises: - FilterNotFoundError: If no ``{name}.conf`` or ``{name}.local`` file - exists in ``filter.d/``. - """ - # Normalise — strip extension if provided (.conf=5 chars, .local=6 chars). - if name.endswith(".conf"): - base_name = name[:-5] - elif name.endswith(".local"): - base_name = name[:-6] - else: - base_name = name - - filter_d = Path(config_dir) / "filter.d" - conf_path = filter_d / f"{base_name}.conf" - local_path = filter_d / f"{base_name}.local" - - def _read() -> tuple[str, bool, str]: - """Read filter content and return (content, has_local_override, source_path).""" - has_local = local_path.is_file() - if conf_path.is_file(): - content = conf_path.read_text(encoding="utf-8") - if has_local: - try: - content += "\n" + local_path.read_text(encoding="utf-8") - except OSError as exc: - log.warning( - "filter_local_read_error", - name=base_name, - path=str(local_path), - error=str(exc), - ) - return content, has_local, str(conf_path) - elif has_local: - # Local-only filter: created by the user, no shipped .conf base. - content = local_path.read_text(encoding="utf-8") - return content, False, str(local_path) - else: - raise FilterNotFoundError(base_name) - - content, has_local, source_path = await run_blocking( _read) - - cfg = conffile_parser.parse_filter_file(content, name=base_name, filename=f"{base_name}.conf") - - all_jails_result, active_names = await asyncio.gather( - run_blocking( _parse_jails_sync, Path(config_dir)), - _get_active_jail_names(socket_path), - ) - all_jails, _source_files = all_jails_result - filter_to_jails = _build_filter_to_jails_map(all_jails, active_names) - - used_by = sorted(filter_to_jails.get(base_name, [])) - log.info("filter_fetched", name=base_name, active=len(used_by) > 0) - return FilterConfig( - name=cfg.name, - filename=cfg.filename, - before=cfg.before, - after=cfg.after, - variables=cfg.variables, - prefregex=cfg.prefregex, - failregex=cfg.failregex, - ignoreregex=cfg.ignoreregex, - maxlines=cfg.maxlines, - datepattern=cfg.datepattern, - journalmatch=cfg.journalmatch, - active=len(used_by) > 0, - used_by_jails=used_by, - source_file=source_path, - has_local_override=has_local, - ) - + """Delegate to the canonical filter config service.""" + from app.services.filter_config_service import get_filter as _delegate + return await _delegate(config_dir, socket_path, name) # --------------------------------------------------------------------------- # Public API — filter write operations (Task 2.2) @@ -1851,73 +1313,9 @@ async def update_filter( req: FilterUpdateRequest, do_reload: bool = False, ) -> FilterConfig: - """Update a filter's ``.local`` override with new regex/pattern values. - - Reads the current merged configuration for *name* (``conf`` + any existing - ``local``), applies the non-``None`` fields in *req* on top of it, and - writes the resulting definition to ``filter.d/{name}.local``. The - original ``.conf`` file is never modified. - - All regex patterns in *req* are validated with Python's ``re`` module - before any write occurs. - - Args: - config_dir: Absolute path to the fail2ban configuration directory. - socket_path: Path to the fail2ban Unix domain socket. - name: Filter base name (e.g. ``"sshd"`` or ``"sshd.conf"``). - req: Partial update — only non-``None`` fields are applied. - do_reload: When ``True``, trigger a full fail2ban reload after writing. - - Returns: - :class:`~app.models.config.FilterConfig` reflecting the updated state. - - Raises: - FilterNameError: If *name* contains invalid characters. - FilterNotFoundError: If no ``{name}.conf`` or ``{name}.local`` exists. - FilterInvalidRegexError: If any supplied regex pattern is invalid. - ConfigWriteError: If writing the ``.local`` file fails. - """ - base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name - _safe_filter_name(base_name) - - # Validate regex patterns before touching the filesystem. - patterns: list[str] = [] - if req.failregex is not None: - patterns.extend(req.failregex) - if req.ignoreregex is not None: - patterns.extend(req.ignoreregex) - _validate_regex_patterns(patterns) - - # Fetch the current merged config (raises FilterNotFoundError if absent). - current = await get_filter(config_dir, socket_path, base_name) - - # Build a FilterConfigUpdate from the request fields. - update = FilterConfigUpdate( - failregex=req.failregex, - ignoreregex=req.ignoreregex, - datepattern=req.datepattern, - journalmatch=req.journalmatch, - ) - - merged = conffile_parser.merge_filter_update(current, update) - content = conffile_parser.serialize_filter_config(merged) - - filter_d = Path(config_dir) / "filter.d" - await run_blocking( _write_filter_local_sync, filter_d, base_name, content) - - if do_reload: - try: - await _reload_all(socket_path) - except Exception as exc: # noqa: BLE001 - log.warning( - "reload_after_filter_update_failed", - filter=base_name, - error=str(exc), - ) - - log.info("filter_updated", filter=base_name, reload=do_reload) - return await get_filter(config_dir, socket_path, base_name) - + """Delegate to the canonical filter config service.""" + from app.services.filter_config_service import update_filter as _delegate + return await _delegate(config_dir, socket_path, name, req, do_reload=do_reload) async def create_filter( config_dir: str, @@ -1925,126 +1323,17 @@ async def create_filter( req: FilterCreateRequest, do_reload: bool = False, ) -> FilterConfig: - """Create a brand-new user-defined filter in ``filter.d/{name}.local``. - - No ``.conf`` is written; fail2ban loads ``.local`` files directly. If a - ``.conf`` or ``.local`` file already exists for the requested name, a - :class:`FilterAlreadyExistsError` is raised. - - All regex patterns are validated with Python's ``re`` module before - writing. - - Args: - config_dir: Absolute path to the fail2ban configuration directory. - socket_path: Path to the fail2ban Unix domain socket. - req: Filter name and definition fields. - do_reload: When ``True``, trigger a full fail2ban reload after writing. - - Returns: - :class:`~app.models.config.FilterConfig` for the newly created filter. - - Raises: - FilterNameError: If ``req.name`` contains invalid characters. - FilterAlreadyExistsError: If a ``.conf`` or ``.local`` already exists. - FilterInvalidRegexError: If any regex pattern is invalid. - ConfigWriteError: If writing fails. - """ - _safe_filter_name(req.name) - - filter_d = Path(config_dir) / "filter.d" - conf_path = filter_d / f"{req.name}.conf" - local_path = filter_d / f"{req.name}.local" - - def _check_not_exists() -> None: - if conf_path.is_file() or local_path.is_file(): - raise FilterAlreadyExistsError(req.name) - - await run_blocking( _check_not_exists) - - # Validate regex patterns. - patterns: list[str] = list(req.failregex) + list(req.ignoreregex) - _validate_regex_patterns(patterns) - - # Build a FilterConfig and serialise it. - cfg = FilterConfig( - name=req.name, - filename=f"{req.name}.local", - failregex=req.failregex, - ignoreregex=req.ignoreregex, - prefregex=req.prefregex, - datepattern=req.datepattern, - journalmatch=req.journalmatch, - ) - content = conffile_parser.serialize_filter_config(cfg) - - await run_blocking( _write_filter_local_sync, filter_d, req.name, content) - - if do_reload: - try: - await _reload_all(socket_path) - except Exception as exc: # noqa: BLE001 - log.warning( - "reload_after_filter_create_failed", - filter=req.name, - error=str(exc), - ) - - log.info("filter_created", filter=req.name, reload=do_reload) - # Re-fetch to get the canonical FilterConfig (source_file, active, etc.). - return await get_filter(config_dir, socket_path, req.name) - + """Delegate to the canonical filter config service.""" + from app.services.filter_config_service import create_filter as _delegate + return await _delegate(config_dir, socket_path, req, do_reload=do_reload) async def delete_filter( config_dir: str, name: str, ) -> None: - """Delete a user-created filter's ``.local`` file. - - Deletion rules: - - If only a ``.conf`` file exists (shipped default, no user override) → - :class:`FilterReadonlyError`. - - If a ``.local`` file exists (whether or not a ``.conf`` also exists) → - the ``.local`` file is deleted. The shipped ``.conf`` is never touched. - - If neither file exists → :class:`FilterNotFoundError`. - - Args: - config_dir: Absolute path to the fail2ban configuration directory. - name: Filter base name (e.g. ``"sshd"``). - - Raises: - FilterNameError: If *name* contains invalid characters. - FilterNotFoundError: If no filter file is found for *name*. - FilterReadonlyError: If only a shipped ``.conf`` exists (no ``.local``). - ConfigWriteError: If deletion of the ``.local`` file fails. - """ - base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name - _safe_filter_name(base_name) - - filter_d = Path(config_dir) / "filter.d" - conf_path = filter_d / f"{base_name}.conf" - local_path = filter_d / f"{base_name}.local" - - - def _delete() -> None: - has_conf = conf_path.is_file() - has_local = local_path.is_file() - - if not has_conf and not has_local: - raise FilterNotFoundError(base_name) - - if has_conf and not has_local: - # Shipped default — nothing user-writable to remove. - raise FilterReadonlyError(base_name) - - try: - local_path.unlink() - except OSError as exc: - raise ConfigWriteError(f"Failed to delete {local_path}: {exc}") from exc - - log.info("filter_local_deleted", filter=base_name, path=str(local_path)) - - await run_blocking( _delete) - + """Delegate to the canonical filter config service.""" + from app.services.filter_config_service import delete_filter as _delegate + return await _delegate(config_dir, name) async def assign_filter_to_jail( config_dir: str, @@ -2053,73 +1342,9 @@ async def assign_filter_to_jail( req: AssignFilterRequest, do_reload: bool = False, ) -> None: - """Assign a filter to a jail by updating the jail's ``.local`` file. - - Writes ``filter = {req.filter_name}`` into the ``[{jail_name}]`` section - of ``jail.d/{jail_name}.local``. If the ``.local`` file already contains - other settings for this jail they are preserved. - - Args: - config_dir: Absolute path to the fail2ban configuration directory. - socket_path: Path to the fail2ban Unix domain socket. - jail_name: Name of the jail to update. - req: Request containing the filter name to assign. - do_reload: When ``True``, trigger a full fail2ban reload after writing. - - Raises: - JailNameError: If *jail_name* contains invalid characters. - FilterNameError: If ``req.filter_name`` contains invalid characters. - JailNotFoundInConfigError: If *jail_name* is not defined in any config - file. - FilterNotFoundError: If ``req.filter_name`` does not exist in - ``filter.d/``. - ConfigWriteError: If writing fails. - """ - _safe_jail_name(jail_name) - _safe_filter_name(req.filter_name) - - - # Verify the jail exists in config. - all_jails, _src = await run_blocking( _parse_jails_sync, Path(config_dir)) - if jail_name not in all_jails: - raise JailNotFoundInConfigError(jail_name) - - # Verify the filter exists (conf or local). - filter_d = Path(config_dir) / "filter.d" - - def _check_filter() -> None: - conf_exists = (filter_d / f"{req.filter_name}.conf").is_file() - local_exists = (filter_d / f"{req.filter_name}.local").is_file() - if not conf_exists and not local_exists: - raise FilterNotFoundError(req.filter_name) - - await run_blocking( _check_filter) - - await run_blocking(_set_jail_local_key_sync, - Path(config_dir), - jail_name, - "filter", - req.filter_name, - ) - - if do_reload: - try: - await _reload_all(socket_path) - except Exception as exc: # noqa: BLE001 - log.warning( - "reload_after_assign_filter_failed", - jail=jail_name, - filter=req.filter_name, - error=str(exc), - ) - - log.info( - "filter_assigned_to_jail", - jail=jail_name, - filter=req.filter_name, - reload=do_reload, - ) - + """Delegate to the canonical filter config service.""" + from app.services.filter_config_service import assign_filter_to_jail as _delegate + return await _delegate(config_dir, socket_path, jail_name, req, do_reload=do_reload) # --------------------------------------------------------------------------- # Action discovery helpers (Task 3.1) @@ -2500,155 +1725,18 @@ async def list_actions( config_dir: str, socket_path: str, ) -> ActionListResponse: - """Return all available actions from ``action.d/`` with active/inactive status. - - Scans ``{config_dir}/action.d/`` for ``.conf`` files, merges any - corresponding ``.local`` overrides, parses each file into an - :class:`~app.models.config.ActionConfig`, and cross-references with the - currently running jails to determine which actions are active. - - An action is considered *active* when its base name appears in the - ``action`` field of at least one currently running jail. - - Args: - config_dir: Absolute path to the fail2ban configuration directory. - socket_path: Path to the fail2ban Unix domain socket. - - Returns: - :class:`~app.models.config.ActionListResponse` with all actions - sorted alphabetically, active ones carrying non-empty - ``used_by_jails`` lists. - """ - action_d = Path(config_dir) / "action.d" - - raw_actions: list[tuple[str, str, str, bool, str]] = await run_blocking( _parse_actions_sync, action_d) - - all_jails_result, active_names = await asyncio.gather( - run_blocking( _parse_jails_sync, Path(config_dir)), - _get_active_jail_names(socket_path), - ) - all_jails, _source_files = all_jails_result - - action_to_jails = _build_action_to_jails_map(all_jails, active_names) - - actions: list[ActionConfig] = [] - for name, filename, content, has_local, source_path in raw_actions: - cfg = conffile_parser.parse_action_file(content, name=name, filename=filename) - used_by = sorted(action_to_jails.get(name, [])) - actions.append( - ActionConfig( - name=cfg.name, - filename=cfg.filename, - before=cfg.before, - after=cfg.after, - actionstart=cfg.actionstart, - actionstop=cfg.actionstop, - actioncheck=cfg.actioncheck, - actionban=cfg.actionban, - actionunban=cfg.actionunban, - actionflush=cfg.actionflush, - definition_vars=cfg.definition_vars, - init_vars=cfg.init_vars, - active=len(used_by) > 0, - used_by_jails=used_by, - source_file=source_path, - has_local_override=has_local, - ) - ) - - log.info("actions_listed", total=len(actions), active=sum(1 for a in actions if a.active)) - return ActionListResponse(actions=actions, total=len(actions)) - + """Delegate to the canonical action config service.""" + from app.services.action_config_service import list_actions as _delegate + return await _delegate(config_dir, socket_path) async def get_action( config_dir: str, socket_path: str, name: str, ) -> ActionConfig: - """Return a single action from ``action.d/`` with active/inactive status. - - Reads ``{config_dir}/action.d/{name}.conf``, merges any ``.local`` - override, and enriches the parsed :class:`~app.models.config.ActionConfig` - with ``active``, ``used_by_jails``, ``source_file``, and - ``has_local_override``. - - Args: - config_dir: Absolute path to the fail2ban configuration directory. - socket_path: Path to the fail2ban Unix domain socket. - name: Action base name (e.g. ``"iptables"`` or ``"iptables.conf"``). - - Returns: - :class:`~app.models.config.ActionConfig` with status fields populated. - - Raises: - ActionNotFoundError: If no ``{name}.conf`` or ``{name}.local`` file - exists in ``action.d/``. - """ - if name.endswith(".conf"): - base_name = name[:-5] - elif name.endswith(".local"): - base_name = name[:-6] - else: - base_name = name - - action_d = Path(config_dir) / "action.d" - conf_path = action_d / f"{base_name}.conf" - local_path = action_d / f"{base_name}.local" - - def _read() -> tuple[str, bool, str]: - """Read action content and return (content, has_local_override, source_path).""" - has_local = local_path.is_file() - if conf_path.is_file(): - content = conf_path.read_text(encoding="utf-8") - if has_local: - try: - content += "\n" + local_path.read_text(encoding="utf-8") - except OSError as exc: - log.warning( - "action_local_read_error", - name=base_name, - path=str(local_path), - error=str(exc), - ) - return content, has_local, str(conf_path) - elif has_local: - content = local_path.read_text(encoding="utf-8") - return content, False, str(local_path) - else: - raise ActionNotFoundError(base_name) - - content, has_local, source_path = await run_blocking( _read) - - cfg = conffile_parser.parse_action_file(content, name=base_name, filename=f"{base_name}.conf") - - all_jails_result, active_names = await asyncio.gather( - run_blocking( _parse_jails_sync, Path(config_dir)), - _get_active_jail_names(socket_path), - ) - all_jails, _source_files = all_jails_result - action_to_jails = _build_action_to_jails_map(all_jails, active_names) - - used_by = sorted(action_to_jails.get(base_name, [])) - log.info("action_fetched", name=base_name, active=len(used_by) > 0) - return ActionConfig( - name=cfg.name, - filename=cfg.filename, - before=cfg.before, - after=cfg.after, - actionstart=cfg.actionstart, - actionstop=cfg.actionstop, - actioncheck=cfg.actioncheck, - actionban=cfg.actionban, - actionunban=cfg.actionunban, - actionflush=cfg.actionflush, - definition_vars=cfg.definition_vars, - init_vars=cfg.init_vars, - active=len(used_by) > 0, - used_by_jails=used_by, - source_file=source_path, - has_local_override=has_local, - ) - + """Delegate to the canonical action config service.""" + from app.services.action_config_service import get_action as _delegate + return await _delegate(config_dir, socket_path, name) # --------------------------------------------------------------------------- # Public API — action write operations (Task 3.2) @@ -2662,63 +1750,9 @@ async def update_action( req: ActionUpdateRequest, do_reload: bool = False, ) -> ActionConfig: - """Update an action's ``.local`` override with new lifecycle command values. - - Reads the current merged configuration for *name* (``conf`` + any existing - ``local``), applies the non-``None`` fields in *req* on top of it, and - writes the resulting definition to ``action.d/{name}.local``. The - original ``.conf`` file is never modified. - - Args: - config_dir: Absolute path to the fail2ban configuration directory. - socket_path: Path to the fail2ban Unix domain socket. - name: Action base name (e.g. ``"iptables"`` or ``"iptables.conf"``). - req: Partial update — only non-``None`` fields are applied. - do_reload: When ``True``, trigger a full fail2ban reload after writing. - - Returns: - :class:`~app.models.config.ActionConfig` reflecting the updated state. - - Raises: - ActionNameError: If *name* contains invalid characters. - ActionNotFoundError: If no ``{name}.conf`` or ``{name}.local`` exists. - ConfigWriteError: If writing the ``.local`` file fails. - """ - base_name = name[:-5] if name.endswith((".conf", ".local")) else name - _safe_action_name(base_name) - - current = await get_action(config_dir, socket_path, base_name) - - update = ActionConfigUpdate( - actionstart=req.actionstart, - actionstop=req.actionstop, - actioncheck=req.actioncheck, - actionban=req.actionban, - actionunban=req.actionunban, - actionflush=req.actionflush, - definition_vars=req.definition_vars, - init_vars=req.init_vars, - ) - - merged = conffile_parser.merge_action_update(current, update) - content = conffile_parser.serialize_action_config(merged) - - action_d = Path(config_dir) / "action.d" - await run_blocking( _write_action_local_sync, action_d, base_name, content) - - if do_reload: - try: - await _reload_all(socket_path) - except Exception as exc: # noqa: BLE001 - log.warning( - "reload_after_action_update_failed", - action=base_name, - error=str(exc), - ) - - log.info("action_updated", action=base_name, reload=do_reload) - return await get_action(config_dir, socket_path, base_name) - + """Delegate to the canonical action config service.""" + from app.services.action_config_service import update_action as _delegate + return await _delegate(config_dir, socket_path, name, req, do_reload=do_reload) async def create_action( config_dir: str, @@ -2726,118 +1760,17 @@ async def create_action( req: ActionCreateRequest, do_reload: bool = False, ) -> ActionConfig: - """Create a brand-new user-defined action in ``action.d/{name}.local``. - - No ``.conf`` is written; fail2ban loads ``.local`` files directly. If a - ``.conf`` or ``.local`` file already exists for the requested name, an - :class:`ActionAlreadyExistsError` is raised. - - Args: - config_dir: Absolute path to the fail2ban configuration directory. - socket_path: Path to the fail2ban Unix domain socket. - req: Action name and definition fields. - do_reload: When ``True``, trigger a full fail2ban reload after writing. - - Returns: - :class:`~app.models.config.ActionConfig` for the newly created action. - - Raises: - ActionNameError: If ``req.name`` contains invalid characters. - ActionAlreadyExistsError: If a ``.conf`` or ``.local`` already exists. - ConfigWriteError: If writing fails. - """ - _safe_action_name(req.name) - - action_d = Path(config_dir) / "action.d" - conf_path = action_d / f"{req.name}.conf" - local_path = action_d / f"{req.name}.local" - - def _check_not_exists() -> None: - if conf_path.is_file() or local_path.is_file(): - raise ActionAlreadyExistsError(req.name) - - await run_blocking( _check_not_exists) - - cfg = ActionConfig( - name=req.name, - filename=f"{req.name}.local", - actionstart=req.actionstart, - actionstop=req.actionstop, - actioncheck=req.actioncheck, - actionban=req.actionban, - actionunban=req.actionunban, - actionflush=req.actionflush, - definition_vars=req.definition_vars, - init_vars=req.init_vars, - ) - content = conffile_parser.serialize_action_config(cfg) - - await run_blocking( _write_action_local_sync, action_d, req.name, content) - - if do_reload: - try: - await _reload_all(socket_path) - except Exception as exc: # noqa: BLE001 - log.warning( - "reload_after_action_create_failed", - action=req.name, - error=str(exc), - ) - - log.info("action_created", action=req.name, reload=do_reload) - return await get_action(config_dir, socket_path, req.name) - + """Delegate to the canonical action config service.""" + from app.services.action_config_service import create_action as _delegate + return await _delegate(config_dir, socket_path, req, do_reload=do_reload) async def delete_action( config_dir: str, name: str, ) -> None: - """Delete a user-created action's ``.local`` file. - - Deletion rules: - - If only a ``.conf`` file exists (shipped default, no user override) → - :class:`ActionReadonlyError`. - - If a ``.local`` file exists (whether or not a ``.conf`` also exists) → - only the ``.local`` file is deleted. - - If neither file exists → :class:`ActionNotFoundError`. - - Args: - config_dir: Absolute path to the fail2ban configuration directory. - name: Action base name (e.g. ``"iptables"``). - - Raises: - ActionNameError: If *name* contains invalid characters. - ActionNotFoundError: If no action file is found for *name*. - ActionReadonlyError: If only a shipped ``.conf`` exists (no ``.local``). - ConfigWriteError: If deletion of the ``.local`` file fails. - """ - base_name = name[:-5] if name.endswith((".conf", ".local")) else name - _safe_action_name(base_name) - - action_d = Path(config_dir) / "action.d" - conf_path = action_d / f"{base_name}.conf" - local_path = action_d / f"{base_name}.local" - - - def _delete() -> None: - has_conf = conf_path.is_file() - has_local = local_path.is_file() - - if not has_conf and not has_local: - raise ActionNotFoundError(base_name) - - if has_conf and not has_local: - raise ActionReadonlyError(base_name) - - try: - local_path.unlink() - except OSError as exc: - raise ConfigWriteError(f"Failed to delete {local_path}: {exc}") from exc - - log.info("action_local_deleted", action=base_name, path=str(local_path)) - - await run_blocking( _delete) - + """Delegate to the canonical action config service.""" + from app.services.action_config_service import delete_action as _delegate + return await _delegate(config_dir, name) async def assign_action_to_jail( config_dir: str, diff --git a/backend/app/services/filter_config_service.py b/backend/app/services/filter_config_service.py index 2593bbd..31ef96e 100644 --- a/backend/app/services/filter_config_service.py +++ b/backend/app/services/filter_config_service.py @@ -23,14 +23,7 @@ from app.exceptions import ( FilterReadonlyError, JailNotFoundInConfigError, ) -from app.services.config_file_service import ( - get_active_jail_names, - parse_jails_sync, - safe_filter_name, - safe_jail_name, - set_jail_local_key_sync, -) -from app.services.jail_service import reload_all +import app.services.config_file_service as config_file_service from app.models.config import ( AssignFilterRequest, FilterConfig, @@ -302,8 +295,8 @@ async def list_filters( # Fetch active jail names and their configs concurrently. all_jails_result, active_names = await asyncio.gather( - run_blocking(parse_jails_sync, Path(config_dir)), - get_active_jail_names(socket_path), + run_blocking(config_file_service._parse_jails_sync, Path(config_dir)), + config_file_service._get_active_jail_names(socket_path), ) all_jails, _source_files = all_jails_result @@ -401,8 +394,8 @@ async def get_filter( cfg = conffile_parser.parse_filter_file(content, name=base_name, filename=f"{base_name}.conf") all_jails_result, active_names = await asyncio.gather( - run_blocking(parse_jails_sync, Path(config_dir)), - get_active_jail_names(socket_path), + run_blocking(config_file_service._parse_jails_sync, Path(config_dir)), + config_file_service._get_active_jail_names(socket_path), ) all_jails, _source_files = all_jails_result filter_to_jails = _build_filter_to_jails_map(all_jails, active_names) @@ -467,7 +460,7 @@ async def update_filter( ConfigWriteError: If writing the ``.local`` file fails. """ base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name - safe_filter_name(base_name) + config_file_service.safe_filter_name(base_name) # Validate regex patterns before touching the filesystem. patterns: list[str] = [] @@ -496,7 +489,7 @@ async def update_filter( if do_reload: try: - await reload_all(socket_path) + await config_file_service.jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( "reload_after_filter_update_failed", @@ -538,7 +531,7 @@ async def create_filter( FilterInvalidRegexError: If any regex pattern is invalid. ConfigWriteError: If writing fails. """ - safe_filter_name(req.name) + config_file_service.safe_filter_name(req.name) filter_d = Path(config_dir) / "filter.d" conf_path = filter_d / f"{req.name}.conf" @@ -570,7 +563,7 @@ async def create_filter( if do_reload: try: - await reload_all(socket_path) + await config_file_service.jail_service.reload_all(socket_path) except Exception as exc: # noqa: BLE001 log.warning( "reload_after_filter_create_failed", @@ -607,7 +600,7 @@ async def delete_filter( ConfigWriteError: If deletion of the ``.local`` file fails. """ base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name - safe_filter_name(base_name) + config_file_service.safe_filter_name(base_name) filter_d = Path(config_dir) / "filter.d" conf_path = filter_d / f"{base_name}.conf" @@ -662,11 +655,11 @@ async def assign_filter_to_jail( ``filter.d/``. ConfigWriteError: If writing fails. """ - safe_jail_name(jail_name) - safe_filter_name(req.filter_name) + config_file_service.safe_jail_name(jail_name) + config_file_service.safe_filter_name(req.filter_name) # Verify the jail exists in config. - all_jails, _src = await run_blocking(parse_jails_sync, Path(config_dir)) + all_jails, _src = await run_blocking(config_file_service._parse_jails_sync, Path(config_dir)) if jail_name not in all_jails: raise JailNotFoundInConfigError(jail_name) @@ -681,7 +674,7 @@ async def assign_filter_to_jail( await run_blocking( _check_filter) - await run_blocking(set_jail_local_key_sync, + await run_blocking(config_file_service.set_jail_local_key_sync, Path(config_dir), jail_name, "filter", @@ -690,7 +683,7 @@ async def assign_filter_to_jail( if do_reload: try: - await reload_all(socket_path) + await config_file_service.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 139c6b2..32d4191 100644 --- a/backend/app/services/jail_config_service.py +++ b/backend/app/services/jail_config_service.py @@ -25,16 +25,7 @@ from app.exceptions import ( JailNotFoundError, JailNotFoundInConfigError, ) -from app.services.config_file_service import ( - build_inactive_jail, - get_active_jail_names, - parse_jails_sync, - safe_jail_name, - start_daemon, - validate_jail_config_sync, - wait_for_fail2ban, -) -from app.services.jail_service import reload_all +import app.services.config_file_service as config_file_service from app.models.config import ( ActivateJailRequest, InactiveJail, @@ -221,23 +212,6 @@ def _validate_regex_patterns(patterns: list[str]) -> None: raise FilterInvalidRegexError(pattern, str(exc)) from exc -async def _probe_fail2ban_running(socket_path: str) -> bool: - """Return ``True`` if the fail2ban socket responds to a ping. - - Args: - socket_path: Path to the fail2ban Unix domain socket. - - Returns: - ``True`` when fail2ban is reachable, ``False`` otherwise. - """ - try: - client = Fail2BanClient(socket_path=socket_path, timeout=5.0) - resp = await client.send(["ping"]) - return isinstance(resp, (list, tuple)) and resp[0] == 0 - except Exception: # noqa: BLE001 - return False - - # Shared functions from config_file_service are imported directly from the # canonical shared helper module. @@ -270,11 +244,11 @@ async def list_inactive_jails( inactive jails. """ parsed_result: tuple[dict[str, dict[str, str]], dict[str, str]] = await run_blocking( - parse_jails_sync, + config_file_service._parse_jails_sync, Path(config_dir), ) all_jails, source_files = parsed_result - active_names: set[str] = await get_active_jail_names(socket_path) + active_names: set[str] = await config_file_service._get_active_jail_names(socket_path) inactive: list[InactiveJail] = [] for jail_name, settings in sorted(all_jails.items()): @@ -283,7 +257,7 @@ async def list_inactive_jails( continue source = source_files.get(jail_name, config_dir) - inactive.append(build_inactive_jail(jail_name, settings, source, Path(config_dir))) + inactive.append(config_file_service.build_inactive_jail(jail_name, settings, source, Path(config_dir))) log.info( "inactive_jails_listed", @@ -353,21 +327,21 @@ async def _activate_jail( ~app.utils.fail2ban_client.Fail2BanConnectionError: If the fail2ban socket is unreachable during reload. """ - safe_jail_name(name) + config_file_service.safe_jail_name(name) - all_jails, _source_files = await run_blocking(parse_jails_sync, Path(config_dir)) + all_jails, _source_files = await run_blocking(config_file_service._parse_jails_sync, Path(config_dir)) if name not in all_jails: raise JailNotFoundInConfigError(name) - active_names = await get_active_jail_names(socket_path) + active_names = await config_file_service._get_active_jail_names(socket_path) if name in active_names: raise JailAlreadyActiveError(name) # ---------------------------------------------------------------------- # # Pre-activation validation — collect warnings but do not block # # ---------------------------------------------------------------------- # - validation_result: JailValidationResult = await run_blocking(validate_jail_config_sync, Path(config_dir), name + validation_result: JailValidationResult = await run_blocking(config_file_service._validate_jail_config_sync, Path(config_dir), name ) warnings: list[str] = [f"{i.field}: {i.message}" for i in validation_result.issues] if warnings: @@ -421,7 +395,7 @@ async def _activate_jail( # Activation reload — if it fails, roll back immediately # # ---------------------------------------------------------------------- # try: - await reload_all(socket_path, include_jails=[name]) + await config_file_service.jail_service.reload_all(socket_path, include_jails=[name]) except JailNotFoundError as exc: # Jail configuration is invalid (e.g. missing logpath that prevents # fail2ban from loading the jail). Roll back and provide a specific error. @@ -467,7 +441,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 _probe_fail2ban_running(socket_path): + if await config_file_service._probe_fail2ban_running(socket_path): fail2ban_running = True break @@ -492,7 +466,7 @@ async def _activate_jail( ) # Verify the jail actually started (config error may prevent it silently). - post_reload_names = await get_active_jail_names(socket_path) + post_reload_names = await config_file_service._get_active_jail_names(socket_path) actually_running = name in post_reload_names if not actually_running: log.warning( @@ -563,7 +537,7 @@ async def _rollback_activation_async( # Step 2 — reload fail2ban with the restored config. try: - await reload_all(socket_path) + await config_file_service.jail_service.reload_all(socket_path) log.info("jail_activation_rollback_reload_ok", jail=name) except Exception as exc: # noqa: BLE001 log.warning("jail_activation_rollback_reload_failed", jail=name, error=str(exc)) @@ -573,7 +547,7 @@ async def _rollback_activation_async( 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): + if await config_file_service._probe_fail2ban_running(socket_path): log.info("jail_activation_rollback_recovered", jail=name) return True @@ -625,14 +599,14 @@ async def _deactivate_jail( ~app.utils.fail2ban_client.Fail2BanConnectionError: If the fail2ban socket is unreachable during reload. """ - safe_jail_name(name) + config_file_service.safe_jail_name(name) - all_jails, _source_files = await run_blocking(parse_jails_sync, Path(config_dir)) + all_jails, _source_files = await run_blocking(config_file_service._parse_jails_sync, Path(config_dir)) if name not in all_jails: raise JailNotFoundInConfigError(name) - active_names = await get_active_jail_names(socket_path) + active_names = await config_file_service._get_active_jail_names(socket_path) if name not in active_names: raise JailAlreadyInactiveError(name) @@ -644,7 +618,7 @@ async def _deactivate_jail( ) try: - await reload_all(socket_path, exclude_jails=[name]) + await config_file_service.jail_service.reload_all(socket_path, exclude_jails=[name]) except Exception as exc: # noqa: BLE001 log.warning("reload_after_deactivate_failed", jail=name, error=str(exc)) @@ -680,14 +654,14 @@ async def delete_jail_local_override( delete the live config file). ConfigWriteError: If the file cannot be deleted. """ - safe_jail_name(name) + config_file_service.safe_jail_name(name) - all_jails, _source_files = await run_blocking(parse_jails_sync, Path(config_dir)) + all_jails, _source_files = await run_blocking(config_file_service._parse_jails_sync, Path(config_dir)) if name not in all_jails: raise JailNotFoundInConfigError(name) - active_names = await get_active_jail_names(socket_path) + active_names = await config_file_service._get_active_jail_names(socket_path) if name in active_names: raise JailAlreadyActiveError(name) @@ -720,8 +694,8 @@ async def validate_jail_config( Raises: JailNameError: If *name* contains invalid characters. """ - safe_jail_name(name) - return await run_blocking(validate_jail_config_sync, + config_file_service.safe_jail_name(name) + return await run_blocking(config_file_service._validate_jail_config_sync, Path(config_dir), name, ) @@ -770,7 +744,7 @@ async def _rollback_jail( JailNameError: If *name* contains invalid characters. ConfigWriteError: If writing the ``.local`` file fails. """ - safe_jail_name(name) + config_file_service.safe_jail_name(name) # Write enabled=false — this must succeed even when fail2ban is down. @@ -783,15 +757,15 @@ async def _rollback_jail( log.info("jail_rolled_back_disabled", jail=name) # Attempt to start the daemon. - started = await start_daemon(start_cmd_parts) + started = await config_file_service.start_daemon(start_cmd_parts) log.info("jail_rollback_start_attempted", jail=name, start_ok=started) # Wait for the socket to come back. - fail2ban_running = await wait_for_fail2ban(socket_path, max_wait_seconds=10.0, poll_interval=2.0) + fail2ban_running = await config_file_service.wait_for_fail2ban(socket_path, max_wait_seconds=10.0, poll_interval=2.0) active_jails = 0 if fail2ban_running: - names = await get_active_jail_names(socket_path) + names = await config_file_service._get_active_jail_names(socket_path) active_jails = len(names) if fail2ban_running: