From 1f4ee360f6463901268bdb6526018c83b121dd2c Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 21 Mar 2026 18:56:02 +0100 Subject: [PATCH] Rename file_config_service to raw_config_io_service and update references --- Docs/Tasks.md | 2 +- backend/app/routers/file_config.py | 40 +- backend/app/services/raw_config_io_service.py | 1011 +++++++++++++++++ .../tests/test_routers/test_file_config.py | 88 +- .../test_services/test_file_config_service.py | 2 +- 5 files changed, 1077 insertions(+), 66 deletions(-) create mode 100644 backend/app/services/raw_config_io_service.py diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 74a86f8..109210f 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -129,7 +129,7 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue. --- -### Task 6 — Rename confusing config service files +### Task 6 — Rename confusing config service files (✅ completed) **Priority**: Medium **Refactoring ref**: Refactoring.md §3 diff --git a/backend/app/routers/file_config.py b/backend/app/routers/file_config.py index cb93f33..e29e853 100644 --- a/backend/app/routers/file_config.py +++ b/backend/app/routers/file_config.py @@ -51,8 +51,8 @@ from app.models.file_config import ( JailConfigFileEnabledUpdate, JailConfigFilesResponse, ) -from app.services import file_config_service -from app.services.file_config_service import ( +from app.services import raw_config_io_service +from app.services.raw_config_io_service import ( ConfigDirError, ConfigFileExistsError, ConfigFileNameError, @@ -134,7 +134,7 @@ async def list_jail_config_files( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - return await file_config_service.list_jail_config_files(config_dir) + return await raw_config_io_service.list_jail_config_files(config_dir) except ConfigDirError as exc: raise _service_unavailable(str(exc)) from exc @@ -166,7 +166,7 @@ async def get_jail_config_file( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - return await file_config_service.get_jail_config_file(config_dir, filename) + return await raw_config_io_service.get_jail_config_file(config_dir, filename) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: @@ -204,7 +204,7 @@ async def write_jail_config_file( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - await file_config_service.write_jail_config_file(config_dir, filename, body) + await raw_config_io_service.write_jail_config_file(config_dir, filename, body) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: @@ -244,7 +244,7 @@ async def set_jail_config_file_enabled( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - await file_config_service.set_jail_config_enabled( + await raw_config_io_service.set_jail_config_enabled( config_dir, filename, body.enabled ) except ConfigFileNameError as exc: @@ -285,7 +285,7 @@ async def create_jail_config_file( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - filename = await file_config_service.create_jail_config_file(config_dir, body) + filename = await raw_config_io_service.create_jail_config_file(config_dir, body) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileExistsError: @@ -338,7 +338,7 @@ async def get_filter_file_raw( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - return await file_config_service.get_filter_file(config_dir, name) + return await raw_config_io_service.get_filter_file(config_dir, name) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: @@ -373,7 +373,7 @@ async def write_filter_file( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - await file_config_service.write_filter_file(config_dir, name, body) + await raw_config_io_service.write_filter_file(config_dir, name, body) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: @@ -412,7 +412,7 @@ async def create_filter_file( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - filename = await file_config_service.create_filter_file(config_dir, body) + filename = await raw_config_io_service.create_filter_file(config_dir, body) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileExistsError: @@ -454,7 +454,7 @@ async def list_action_files( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - return await file_config_service.list_action_files(config_dir) + return await raw_config_io_service.list_action_files(config_dir) except ConfigDirError as exc: raise _service_unavailable(str(exc)) from exc @@ -486,7 +486,7 @@ async def get_action_file( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - return await file_config_service.get_action_file(config_dir, name) + return await raw_config_io_service.get_action_file(config_dir, name) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: @@ -521,7 +521,7 @@ async def write_action_file( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - await file_config_service.write_action_file(config_dir, name, body) + await raw_config_io_service.write_action_file(config_dir, name, body) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: @@ -560,7 +560,7 @@ async def create_action_file( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - filename = await file_config_service.create_action_file(config_dir, body) + filename = await raw_config_io_service.create_action_file(config_dir, body) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileExistsError: @@ -613,7 +613,7 @@ async def get_parsed_filter( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - return await file_config_service.get_parsed_filter_file(config_dir, name) + return await raw_config_io_service.get_parsed_filter_file(config_dir, name) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: @@ -651,7 +651,7 @@ async def update_parsed_filter( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - await file_config_service.update_parsed_filter_file(config_dir, name, body) + await raw_config_io_service.update_parsed_filter_file(config_dir, name, body) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: @@ -698,7 +698,7 @@ async def get_parsed_action( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - return await file_config_service.get_parsed_action_file(config_dir, name) + return await raw_config_io_service.get_parsed_action_file(config_dir, name) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: @@ -736,7 +736,7 @@ async def update_parsed_action( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - await file_config_service.update_parsed_action_file(config_dir, name, body) + await raw_config_io_service.update_parsed_action_file(config_dir, name, body) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: @@ -783,7 +783,7 @@ async def get_parsed_jail_file( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - return await file_config_service.get_parsed_jail_file(config_dir, filename) + return await raw_config_io_service.get_parsed_jail_file(config_dir, filename) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: @@ -821,7 +821,7 @@ async def update_parsed_jail_file( """ config_dir: str = request.app.state.settings.fail2ban_config_dir try: - await file_config_service.update_parsed_jail_file(config_dir, filename, body) + await raw_config_io_service.update_parsed_jail_file(config_dir, filename, body) except ConfigFileNameError as exc: raise _bad_request(str(exc)) from exc except ConfigFileNotFoundError: diff --git a/backend/app/services/raw_config_io_service.py b/backend/app/services/raw_config_io_service.py new file mode 100644 index 0000000..e6d6c7d --- /dev/null +++ b/backend/app/services/raw_config_io_service.py @@ -0,0 +1,1011 @@ +"""File-based fail2ban configuration service. + +Provides functions to list, read, and write files in the fail2ban +configuration directory (``jail.d/``, ``filter.d/``, ``action.d/``). + +All file operations are synchronous (wrapped in +:func:`asyncio.get_event_loop().run_in_executor` by callers that need async +behaviour) because the config files are small and infrequently touched — the +overhead of async I/O is not warranted here. + +Security note: every path-related helper validates that the resolved path +stays strictly inside the configured config directory to prevent directory +traversal attacks. +""" + +from __future__ import annotations + +import asyncio +import configparser +import re +from pathlib import Path +from typing import TYPE_CHECKING + +import structlog + +from app.models.file_config import ( + ConfFileContent, + ConfFileCreateRequest, + ConfFileEntry, + ConfFilesResponse, + ConfFileUpdateRequest, + JailConfigFile, + JailConfigFileContent, + JailConfigFilesResponse, +) + +if TYPE_CHECKING: + from app.models.config import ( + ActionConfig, + ActionConfigUpdate, + FilterConfig, + FilterConfigUpdate, + JailFileConfig, + JailFileConfigUpdate, + ) + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_MAX_CONTENT_BYTES: int = 512 * 1024 # 512 KB – hard cap on file write size +_CONF_EXTENSIONS: tuple[str, str] = (".conf", ".local") + +# Allowed characters in a new file's base name. Tighter than the OS allows +# on purpose: alphanumeric, hyphen, underscore, dot (but not leading dot). +_SAFE_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") + +# --------------------------------------------------------------------------- +# Custom exceptions +# --------------------------------------------------------------------------- + + +class ConfigDirError(Exception): + """Raised when the fail2ban config directory is missing or inaccessible.""" + + +class ConfigFileNotFoundError(Exception): + """Raised when a requested config file does not exist.""" + + def __init__(self, filename: str) -> None: + """Initialise with the filename that was not found. + + Args: + filename: The filename that could not be located. + """ + self.filename = filename + super().__init__(f"Config file not found: {filename!r}") + + +class ConfigFileExistsError(Exception): + """Raised when trying to create a file that already exists.""" + + def __init__(self, filename: str) -> None: + """Initialise with the filename that already exists. + + Args: + filename: The filename that conflicts. + """ + self.filename = filename + super().__init__(f"Config file already exists: {filename!r}") + + +class ConfigFileWriteError(Exception): + """Raised when a file cannot be written (permissions, disk full, etc.).""" + + +class ConfigFileNameError(Exception): + """Raised when a supplied filename is invalid or unsafe.""" + + +# --------------------------------------------------------------------------- +# Internal path helpers +# --------------------------------------------------------------------------- + + +def _resolve_subdir(config_dir: str, subdir: str) -> Path: + """Resolve and return the path of *subdir* inside *config_dir*. + + Args: + config_dir: The top-level fail2ban config directory. + subdir: Subdirectory name (e.g. ``"jail.d"``). + + Returns: + Resolved :class:`~pathlib.Path` to the subdirectory. + + Raises: + ConfigDirError: If *config_dir* does not exist or is not a directory. + """ + base = Path(config_dir).resolve() + if not base.is_dir(): + raise ConfigDirError(f"fail2ban config directory not found: {config_dir!r}") + return base / subdir + + +def _assert_within(base: Path, target: Path) -> None: + """Raise :class:`ConfigFileNameError` if *target* is outside *base*. + + Args: + base: The allowed root directory (resolved). + target: The path to validate (resolved). + + Raises: + ConfigFileNameError: If *target* would escape *base*. + """ + try: + target.relative_to(base) + except ValueError as err: + raise ConfigFileNameError( + f"Path {str(target)!r} escapes config directory {str(base)!r}" + ) from err + + +def _validate_new_name(name: str) -> None: + """Validate a base name for a new config file. + + Args: + name: The proposed base name (without extension). + + Raises: + ConfigFileNameError: If *name* contains invalid characters or patterns. + """ + if not _SAFE_NAME_RE.match(name): + raise ConfigFileNameError( + f"Invalid config file name {name!r}. " + "Use only alphanumeric characters, hyphens, underscores, and dots; " + "must start with an alphanumeric character." + ) + + +def _validate_content(content: str) -> None: + """Reject content that exceeds the size limit. + + Args: + content: The proposed file content. + + Raises: + ConfigFileWriteError: If *content* exceeds :data:`_MAX_CONTENT_BYTES`. + """ + if len(content.encode("utf-8")) > _MAX_CONTENT_BYTES: + raise ConfigFileWriteError( + f"Content exceeds maximum allowed size of {_MAX_CONTENT_BYTES // 1024} KB." + ) + + +# --------------------------------------------------------------------------- +# Internal helpers — INI parsing / patching +# --------------------------------------------------------------------------- + + +def _parse_enabled(path: Path) -> bool: + """Return the ``enabled`` value for the primary section in *path*. + + Reads the INI file with :mod:`configparser` and looks for an ``enabled`` + key in the section whose name matches the file stem (or in ``DEFAULT``). + Returns ``True`` if the key is absent (fail2ban's own default). + + Args: + path: Path to a ``.conf`` or ``.local`` jail config file. + + Returns: + ``True`` if the jail is (or defaults to) enabled, ``False`` otherwise. + """ + cp = configparser.ConfigParser( + # Treat all keys case-insensitively; interpolation disabled because + # fail2ban uses %(variables)s which would confuse configparser. + interpolation=None, + ) + try: + cp.read(str(path), encoding="utf-8") + except configparser.Error: + return True # Unreadable files are treated as enabled (safe default). + + jail_name = path.stem + # Prefer the jail-specific section; fall back to DEFAULT. + for section in (jail_name, "DEFAULT"): + if cp.has_option(section, "enabled"): + raw = cp.get(section, "enabled").strip().lower() + return raw in ("true", "1", "yes") + return True + + +def _set_enabled_in_content(content: str, enabled: bool) -> str: + """Return *content* with the first ``enabled = …`` line replaced. + + If no ``enabled`` line exists, appends one to the last ``[section]`` block + found in the file. + + Args: + content: Current raw file content. + enabled: New value for the ``enabled`` key. + + Returns: + Modified file content as a string. + """ + value = "true" if enabled else "false" + # Try to replace an existing "enabled = ..." line (inside any section). + pattern = re.compile( + r"^(\s*enabled\s*=\s*).*$", + re.MULTILINE | re.IGNORECASE, + ) + if pattern.search(content): + return pattern.sub(rf"\g<1>{value}", content, count=1) + + # No existing enabled line. Find the last [section] header and append + # the enabled setting right after it. + section_pattern = re.compile(r"^\[([^\[\]]+)\]\s*$", re.MULTILINE) + matches = list(section_pattern.finditer(content)) + if matches: + # Insert after the last section header line. + last_match = matches[-1] + insert_pos = last_match.end() + return content[:insert_pos] + f"\nenabled = {value}" + content[insert_pos:] + + # No section found at all — prepend a minimal block. + return f"[DEFAULT]\nenabled = {value}\n\n" + content + + +# --------------------------------------------------------------------------- +# Public API — jail config files (Task 4a) +# --------------------------------------------------------------------------- + + +async def list_jail_config_files(config_dir: str) -> JailConfigFilesResponse: + """List all jail config files in ``/jail.d/``. + + Only ``.conf`` and ``.local`` files are returned. The ``enabled`` state + is parsed from each file's content. + + Args: + config_dir: Path to the fail2ban configuration directory. + + Returns: + :class:`~app.models.file_config.JailConfigFilesResponse`. + + Raises: + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> JailConfigFilesResponse: + jail_d = _resolve_subdir(config_dir, "jail.d") + if not jail_d.is_dir(): + log.warning("jail_d_not_found", config_dir=config_dir) + return JailConfigFilesResponse(files=[], total=0) + + files: list[JailConfigFile] = [] + for path in sorted(jail_d.iterdir()): + if not path.is_file(): + continue + if path.suffix not in _CONF_EXTENSIONS: + continue + _assert_within(jail_d.resolve(), path.resolve()) + files.append( + JailConfigFile( + name=path.stem, + filename=path.name, + enabled=_parse_enabled(path), + ) + ) + log.info("jail_config_files_listed", count=len(files)) + return JailConfigFilesResponse(files=files, total=len(files)) + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def get_jail_config_file(config_dir: str, filename: str) -> JailConfigFileContent: + """Return the content and metadata of a single jail config file. + + Args: + config_dir: Path to the fail2ban configuration directory. + filename: The filename (e.g. ``sshd.conf``) — must end in ``.conf`` or ``.local``. + + Returns: + :class:`~app.models.file_config.JailConfigFileContent`. + + Raises: + ConfigFileNameError: If *filename* is unsafe. + ConfigFileNotFoundError: If the file does not exist. + ConfigDirError: If the config directory does not exist. + """ + + def _do() -> JailConfigFileContent: + jail_d = _resolve_subdir(config_dir, "jail.d").resolve() + if not jail_d.is_dir(): + raise ConfigFileNotFoundError(filename) + + path = (jail_d / filename).resolve() + _assert_within(jail_d, path) + if path.suffix not in _CONF_EXTENSIONS: + raise ConfigFileNameError( + f"Invalid file extension for {filename!r}. " + "Only .conf and .local files are supported." + ) + if not path.is_file(): + raise ConfigFileNotFoundError(filename) + + content = path.read_text(encoding="utf-8", errors="replace") + return JailConfigFileContent( + name=path.stem, + filename=path.name, + enabled=_parse_enabled(path), + content=content, + ) + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def set_jail_config_enabled( + config_dir: str, + filename: str, + enabled: bool, +) -> None: + """Set the ``enabled`` flag in a jail config file. + + Reads the file, modifies (or inserts) the ``enabled`` key, and writes it + back. The update preserves all other content including comments. + + Args: + config_dir: Path to the fail2ban configuration directory. + filename: The filename (e.g. ``sshd.conf``). + enabled: New value for the ``enabled`` key. + + Raises: + ConfigFileNameError: If *filename* is unsafe. + ConfigFileNotFoundError: If the file does not exist. + ConfigFileWriteError: If the file cannot be written. + ConfigDirError: If the config directory does not exist. + """ + + def _do() -> None: + jail_d = _resolve_subdir(config_dir, "jail.d").resolve() + if not jail_d.is_dir(): + raise ConfigFileNotFoundError(filename) + + path = (jail_d / filename).resolve() + _assert_within(jail_d, path) + if path.suffix not in _CONF_EXTENSIONS: + raise ConfigFileNameError( + f"Only .conf and .local files are supported, got {filename!r}." + ) + if not path.is_file(): + raise ConfigFileNotFoundError(filename) + + original = path.read_text(encoding="utf-8", errors="replace") + updated = _set_enabled_in_content(original, enabled) + try: + path.write_text(updated, encoding="utf-8") + except OSError as exc: + raise ConfigFileWriteError( + f"Cannot write {filename!r}: {exc}" + ) from exc + log.info( + "jail_config_file_enabled_set", + filename=filename, + enabled=enabled, + ) + + await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def create_jail_config_file( + config_dir: str, + req: ConfFileCreateRequest, +) -> str: + """Create a new jail.d config file. + + Args: + config_dir: Path to the fail2ban configuration directory. + req: :class:`~app.models.file_config.ConfFileCreateRequest`. + + Returns: + The filename that was created. + + Raises: + ConfigFileExistsError: If a file with that name already exists. + ConfigFileNameError: If the name is invalid. + ConfigFileWriteError: If the file cannot be created. + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> str: + jail_d = _resolve_subdir(config_dir, "jail.d") + filename = _create_conf_file(jail_d, req.name, req.content) + log.info("jail_config_file_created", filename=filename) + return filename + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def write_jail_config_file( + config_dir: str, + filename: str, + req: ConfFileUpdateRequest, +) -> None: + """Overwrite an existing jail.d config file with new raw content. + + Args: + config_dir: Path to the fail2ban configuration directory. + filename: Filename including extension (e.g. ``sshd.conf``). + req: :class:`~app.models.file_config.ConfFileUpdateRequest` with new + content. + + Raises: + ConfigFileNotFoundError: If the file does not exist. + ConfigFileNameError: If *filename* is unsafe or has a bad extension. + ConfigFileWriteError: If the file cannot be written. + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> None: + jail_d = _resolve_subdir(config_dir, "jail.d").resolve() + if not jail_d.is_dir(): + raise ConfigFileNotFoundError(filename) + path = (jail_d / filename).resolve() + _assert_within(jail_d, path) + if path.suffix not in _CONF_EXTENSIONS: + raise ConfigFileNameError( + f"Only .conf and .local files are supported, got {filename!r}." + ) + if not path.is_file(): + raise ConfigFileNotFoundError(filename) + try: + path.write_text(req.content, encoding="utf-8") + except OSError as exc: + raise ConfigFileWriteError( + f"Cannot write {filename!r}: {exc}" + ) from exc + log.info("jail_config_file_written", filename=filename) + + await asyncio.get_event_loop().run_in_executor(None, _do) + + +# --------------------------------------------------------------------------- +# Internal helpers — generic conf file listing / reading / writing +# --------------------------------------------------------------------------- + + +def _list_conf_files(subdir: Path) -> ConfFilesResponse: + """List ``.conf`` and ``.local`` files in *subdir*. + + Args: + subdir: Resolved path to the directory to scan. + + Returns: + :class:`~app.models.file_config.ConfFilesResponse`. + """ + if not subdir.is_dir(): + return ConfFilesResponse(files=[], total=0) + + files: list[ConfFileEntry] = [] + for path in sorted(subdir.iterdir()): + if not path.is_file(): + continue + if path.suffix not in _CONF_EXTENSIONS: + continue + _assert_within(subdir.resolve(), path.resolve()) + files.append(ConfFileEntry(name=path.stem, filename=path.name)) + return ConfFilesResponse(files=files, total=len(files)) + + +def _read_conf_file(subdir: Path, name: str) -> ConfFileContent: + """Read a single conf file by base name. + + Args: + subdir: Resolved path to the containing directory. + name: Base name with optional extension. If no extension is given, + ``.conf`` is tried first, then ``.local``. + + Returns: + :class:`~app.models.file_config.ConfFileContent`. + + Raises: + ConfigFileNameError: If *name* is unsafe. + ConfigFileNotFoundError: If no matching file is found. + """ + resolved_subdir = subdir.resolve() + # Accept names with or without extension. + if "." in name and not name.startswith("."): + candidates = [resolved_subdir / name] + else: + candidates = [resolved_subdir / (name + ext) for ext in _CONF_EXTENSIONS] + + for path in candidates: + resolved = path.resolve() + _assert_within(resolved_subdir, resolved) + if resolved.is_file(): + content = resolved.read_text(encoding="utf-8", errors="replace") + return ConfFileContent( + name=resolved.stem, + filename=resolved.name, + content=content, + ) + raise ConfigFileNotFoundError(name) + + +def _write_conf_file(subdir: Path, name: str, content: str) -> None: + """Overwrite or create a conf file. + + Args: + subdir: Resolved path to the containing directory. + name: Base name with optional extension. + content: New file content. + + Raises: + ConfigFileNameError: If *name* is unsafe. + ConfigFileNotFoundError: If *name* does not match an existing file + (use :func:`_create_conf_file` for new files). + ConfigFileWriteError: If the file cannot be written. + """ + resolved_subdir = subdir.resolve() + _validate_content(content) + + # Accept names with or without extension. + if "." in name and not name.startswith("."): + candidates = [resolved_subdir / name] + else: + candidates = [resolved_subdir / (name + ext) for ext in _CONF_EXTENSIONS] + + target: Path | None = None + for path in candidates: + resolved = path.resolve() + _assert_within(resolved_subdir, resolved) + if resolved.is_file(): + target = resolved + break + + if target is None: + raise ConfigFileNotFoundError(name) + + try: + target.write_text(content, encoding="utf-8") + except OSError as exc: + raise ConfigFileWriteError(f"Cannot write {name!r}: {exc}") from exc + + +def _create_conf_file(subdir: Path, name: str, content: str) -> str: + """Create a new ``.conf`` file in *subdir*. + + Args: + subdir: Resolved path to the containing directory. + name: Base name for the new file (without extension). + content: Initial file content. + + Returns: + The filename that was created (e.g. ``myfilter.conf``). + + Raises: + ConfigFileNameError: If *name* is invalid. + ConfigFileExistsError: If a ``.conf`` or ``.local`` file with *name* already exists. + ConfigFileWriteError: If the file cannot be written. + """ + resolved_subdir = subdir.resolve() + _validate_new_name(name) + _validate_content(content) + + for ext in _CONF_EXTENSIONS: + existing = (resolved_subdir / (name + ext)).resolve() + _assert_within(resolved_subdir, existing) + if existing.exists(): + raise ConfigFileExistsError(name + ext) + + target = (resolved_subdir / (name + ".conf")).resolve() + _assert_within(resolved_subdir, target) + try: + target.write_text(content, encoding="utf-8") + except OSError as exc: + raise ConfigFileWriteError(f"Cannot create {name!r}: {exc}") from exc + + return target.name + + +# --------------------------------------------------------------------------- +# Public API — filter files (Task 4d) +# --------------------------------------------------------------------------- + + +async def list_filter_files(config_dir: str) -> ConfFilesResponse: + """List all filter definition files in ``/filter.d/``. + + Args: + config_dir: Path to the fail2ban configuration directory. + + Returns: + :class:`~app.models.file_config.ConfFilesResponse`. + + Raises: + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> ConfFilesResponse: + filter_d = _resolve_subdir(config_dir, "filter.d") + result = _list_conf_files(filter_d) + log.info("filter_files_listed", count=result.total) + return result + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def get_filter_file(config_dir: str, name: str) -> ConfFileContent: + """Return the content of a filter definition file. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name (with or without ``.conf``/``.local`` extension). + + Returns: + :class:`~app.models.file_config.ConfFileContent`. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> ConfFileContent: + filter_d = _resolve_subdir(config_dir, "filter.d") + return _read_conf_file(filter_d, name) + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def write_filter_file( + config_dir: str, + name: str, + req: ConfFileUpdateRequest, +) -> None: + """Overwrite an existing filter definition file. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name of the file to update (with or without extension). + req: :class:`~app.models.file_config.ConfFileUpdateRequest` with new content. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigFileWriteError: If the file cannot be written. + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> None: + filter_d = _resolve_subdir(config_dir, "filter.d") + _write_conf_file(filter_d, name, req.content) + log.info("filter_file_written", name=name) + + await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def create_filter_file( + config_dir: str, + req: ConfFileCreateRequest, +) -> str: + """Create a new filter definition file. + + Args: + config_dir: Path to the fail2ban configuration directory. + req: :class:`~app.models.file_config.ConfFileCreateRequest`. + + Returns: + The filename that was created. + + Raises: + ConfigFileExistsError: If a file with that name already exists. + ConfigFileNameError: If the name is invalid. + ConfigFileWriteError: If the file cannot be created. + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> str: + filter_d = _resolve_subdir(config_dir, "filter.d") + filename = _create_conf_file(filter_d, req.name, req.content) + log.info("filter_file_created", filename=filename) + return filename + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +# --------------------------------------------------------------------------- +# Public API — action files (Task 4e) +# --------------------------------------------------------------------------- + + +async def list_action_files(config_dir: str) -> ConfFilesResponse: + """List all action definition files in ``/action.d/``. + + Args: + config_dir: Path to the fail2ban configuration directory. + + Returns: + :class:`~app.models.file_config.ConfFilesResponse`. + + Raises: + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> ConfFilesResponse: + action_d = _resolve_subdir(config_dir, "action.d") + result = _list_conf_files(action_d) + log.info("action_files_listed", count=result.total) + return result + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def get_action_file(config_dir: str, name: str) -> ConfFileContent: + """Return the content of an action definition file. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name (with or without ``.conf``/``.local`` extension). + + Returns: + :class:`~app.models.file_config.ConfFileContent`. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> ConfFileContent: + action_d = _resolve_subdir(config_dir, "action.d") + return _read_conf_file(action_d, name) + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def write_action_file( + config_dir: str, + name: str, + req: ConfFileUpdateRequest, +) -> None: + """Overwrite an existing action definition file. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name of the file to update. + req: :class:`~app.models.file_config.ConfFileUpdateRequest` with new content. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigFileWriteError: If the file cannot be written. + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> None: + action_d = _resolve_subdir(config_dir, "action.d") + _write_conf_file(action_d, name, req.content) + log.info("action_file_written", name=name) + + await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def create_action_file( + config_dir: str, + req: ConfFileCreateRequest, +) -> str: + """Create a new action definition file. + + Args: + config_dir: Path to the fail2ban configuration directory. + req: :class:`~app.models.file_config.ConfFileCreateRequest`. + + Returns: + The filename that was created. + + Raises: + ConfigFileExistsError: If a file with that name already exists. + ConfigFileNameError: If the name is invalid. + ConfigFileWriteError: If the file cannot be created. + ConfigDirError: If *config_dir* does not exist. + """ + + def _do() -> str: + action_d = _resolve_subdir(config_dir, "action.d") + filename = _create_conf_file(action_d, req.name, req.content) + log.info("action_file_created", filename=filename) + return filename + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +# --------------------------------------------------------------------------- +# Public API — structured (parsed) filter files (Task 2.1) +# --------------------------------------------------------------------------- + + +async def get_parsed_filter_file(config_dir: str, name: str) -> FilterConfig: + """Parse a filter definition file and return its structured representation. + + Reads the raw ``.conf``/``.local`` file from ``filter.d/``, parses it with + :func:`~app.utils.conffile_parser.parse_filter_file`, and returns the + result. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name with or without extension. + + Returns: + :class:`~app.models.config.FilterConfig`. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigDirError: If *config_dir* does not exist. + """ + from app.utils.conffile_parser import parse_filter_file # avoid circular imports + + def _do() -> FilterConfig: + filter_d = _resolve_subdir(config_dir, "filter.d") + raw = _read_conf_file(filter_d, name) + result = parse_filter_file(raw.content, name=raw.name, filename=raw.filename) + log.debug("filter_file_parsed", name=raw.name) + return result + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def update_parsed_filter_file( + config_dir: str, + name: str, + update: FilterConfigUpdate, +) -> None: + """Apply a structured partial update to a filter definition file. + + Reads the existing file, merges *update* onto it, serializes to INI format, + and writes the result back to disk. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name of the file to update. + update: Partial fields to apply. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigFileWriteError: If the file cannot be written. + ConfigDirError: If *config_dir* does not exist. + """ + from app.utils.conffile_parser import ( # avoid circular imports + merge_filter_update, + parse_filter_file, + serialize_filter_config, + ) + + def _do() -> None: + filter_d = _resolve_subdir(config_dir, "filter.d") + raw = _read_conf_file(filter_d, name) + current = parse_filter_file(raw.content, name=raw.name, filename=raw.filename) + merged = merge_filter_update(current, update) + new_content = serialize_filter_config(merged) + _validate_content(new_content) + _write_conf_file(filter_d, name, new_content) + log.info("filter_file_updated_parsed", name=name) + + await asyncio.get_event_loop().run_in_executor(None, _do) + + +# --------------------------------------------------------------------------- +# Public API — structured (parsed) action files (Task 3.1) +# --------------------------------------------------------------------------- + + +async def get_parsed_action_file(config_dir: str, name: str) -> ActionConfig: + """Parse an action definition file and return its structured representation. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name with or without extension. + + Returns: + :class:`~app.models.config.ActionConfig`. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigDirError: If *config_dir* does not exist. + """ + from app.utils.conffile_parser import parse_action_file # avoid circular imports + + def _do() -> ActionConfig: + action_d = _resolve_subdir(config_dir, "action.d") + raw = _read_conf_file(action_d, name) + result = parse_action_file(raw.content, name=raw.name, filename=raw.filename) + log.debug("action_file_parsed", name=raw.name) + return result + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def update_parsed_action_file( + config_dir: str, + name: str, + update: ActionConfigUpdate, +) -> None: + """Apply a structured partial update to an action definition file. + + Args: + config_dir: Path to the fail2ban configuration directory. + name: Base name of the file to update. + update: Partial fields to apply. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigFileWriteError: If the file cannot be written. + ConfigDirError: If *config_dir* does not exist. + """ + from app.utils.conffile_parser import ( # avoid circular imports + merge_action_update, + parse_action_file, + serialize_action_config, + ) + + def _do() -> None: + action_d = _resolve_subdir(config_dir, "action.d") + raw = _read_conf_file(action_d, name) + current = parse_action_file(raw.content, name=raw.name, filename=raw.filename) + merged = merge_action_update(current, update) + new_content = serialize_action_config(merged) + _validate_content(new_content) + _write_conf_file(action_d, name, new_content) + log.info("action_file_updated_parsed", name=name) + + await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def get_parsed_jail_file(config_dir: str, filename: str) -> JailFileConfig: + """Parse a jail.d config file into a structured :class:`~app.models.config.JailFileConfig`. + + Args: + config_dir: Path to the fail2ban configuration directory. + filename: Filename including extension (e.g. ``"sshd.conf"``). + + Returns: + :class:`~app.models.config.JailFileConfig`. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigDirError: If *config_dir* does not exist. + """ + from app.utils.conffile_parser import parse_jail_file # avoid circular imports + + def _do() -> JailFileConfig: + jail_d = _resolve_subdir(config_dir, "jail.d") + raw = _read_conf_file(jail_d, filename) + result = parse_jail_file(raw.content, filename=raw.filename) + log.debug("jail_file_parsed", filename=raw.filename) + return result + + return await asyncio.get_event_loop().run_in_executor(None, _do) + + +async def update_parsed_jail_file( + config_dir: str, + filename: str, + update: JailFileConfigUpdate, +) -> None: + """Apply a structured partial update to a jail.d config file. + + Args: + config_dir: Path to the fail2ban configuration directory. + filename: Filename including extension (e.g. ``"sshd.conf"``). + update: Partial fields to apply. + + Raises: + ConfigFileNotFoundError: If no matching file is found. + ConfigFileWriteError: If the file cannot be written. + ConfigDirError: If *config_dir* does not exist. + """ + from app.utils.conffile_parser import ( # avoid circular imports + merge_jail_file_update, + parse_jail_file, + serialize_jail_file_config, + ) + + def _do() -> None: + jail_d = _resolve_subdir(config_dir, "jail.d") + raw = _read_conf_file(jail_d, filename) + current = parse_jail_file(raw.content, filename=raw.filename) + merged = merge_jail_file_update(current, update) + new_content = serialize_jail_file_config(merged) + _validate_content(new_content) + _write_conf_file(jail_d, filename, new_content) + log.info("jail_file_updated_parsed", filename=filename) + + await asyncio.get_event_loop().run_in_executor(None, _do) diff --git a/backend/tests/test_routers/test_file_config.py b/backend/tests/test_routers/test_file_config.py index 2226238..b6a88fc 100644 --- a/backend/tests/test_routers/test_file_config.py +++ b/backend/tests/test_routers/test_file_config.py @@ -26,7 +26,7 @@ from app.models.file_config import ( JailConfigFileContent, JailConfigFilesResponse, ) -from app.services.file_config_service import ( +from app.services.raw_config_io_service import ( ConfigDirError, ConfigFileExistsError, ConfigFileNameError, @@ -112,7 +112,7 @@ class TestListJailConfigFiles: self, file_config_client: AsyncClient ) -> None: with patch( - "app.routers.file_config.file_config_service.list_jail_config_files", + "app.routers.file_config.raw_config_io_service.list_jail_config_files", AsyncMock(return_value=_jail_files_resp()), ): resp = await file_config_client.get("/api/config/jail-files") @@ -126,7 +126,7 @@ class TestListJailConfigFiles: self, file_config_client: AsyncClient ) -> None: with patch( - "app.routers.file_config.file_config_service.list_jail_config_files", + "app.routers.file_config.raw_config_io_service.list_jail_config_files", AsyncMock(side_effect=ConfigDirError("not found")), ): resp = await file_config_client.get("/api/config/jail-files") @@ -157,7 +157,7 @@ class TestGetJailConfigFile: content="[sshd]\nenabled = true\n", ) with patch( - "app.routers.file_config.file_config_service.get_jail_config_file", + "app.routers.file_config.raw_config_io_service.get_jail_config_file", AsyncMock(return_value=content), ): resp = await file_config_client.get("/api/config/jail-files/sshd.conf") @@ -167,7 +167,7 @@ class TestGetJailConfigFile: async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.get_jail_config_file", + "app.routers.file_config.raw_config_io_service.get_jail_config_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), ): resp = await file_config_client.get("/api/config/jail-files/missing.conf") @@ -178,7 +178,7 @@ class TestGetJailConfigFile: self, file_config_client: AsyncClient ) -> None: with patch( - "app.routers.file_config.file_config_service.get_jail_config_file", + "app.routers.file_config.raw_config_io_service.get_jail_config_file", AsyncMock(side_effect=ConfigFileNameError("bad name")), ): resp = await file_config_client.get("/api/config/jail-files/bad.txt") @@ -194,7 +194,7 @@ class TestGetJailConfigFile: class TestSetJailConfigEnabled: async def test_204_on_success(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.set_jail_config_enabled", + "app.routers.file_config.raw_config_io_service.set_jail_config_enabled", AsyncMock(return_value=None), ): resp = await file_config_client.put( @@ -206,7 +206,7 @@ class TestSetJailConfigEnabled: async def test_404_file_not_found(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.set_jail_config_enabled", + "app.routers.file_config.raw_config_io_service.set_jail_config_enabled", AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), ): resp = await file_config_client.put( @@ -232,7 +232,7 @@ class TestGetFilterFileRaw: async def test_200_returns_content(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.get_filter_file", + "app.routers.file_config.raw_config_io_service.get_filter_file", AsyncMock(return_value=_conf_file_content("nginx")), ): resp = await file_config_client.get("/api/config/filters/nginx/raw") @@ -242,7 +242,7 @@ class TestGetFilterFileRaw: async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.get_filter_file", + "app.routers.file_config.raw_config_io_service.get_filter_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): resp = await file_config_client.get("/api/config/filters/missing/raw") @@ -258,7 +258,7 @@ class TestGetFilterFileRaw: class TestUpdateFilterFile: async def test_204_on_success(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.write_filter_file", + "app.routers.file_config.raw_config_io_service.write_filter_file", AsyncMock(return_value=None), ): resp = await file_config_client.put( @@ -270,7 +270,7 @@ class TestUpdateFilterFile: async def test_400_write_error(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.write_filter_file", + "app.routers.file_config.raw_config_io_service.write_filter_file", AsyncMock(side_effect=ConfigFileWriteError("disk full")), ): resp = await file_config_client.put( @@ -289,7 +289,7 @@ class TestUpdateFilterFile: class TestCreateFilterFile: async def test_201_creates_file(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.create_filter_file", + "app.routers.file_config.raw_config_io_service.create_filter_file", AsyncMock(return_value="myfilter.conf"), ): resp = await file_config_client.post( @@ -302,7 +302,7 @@ class TestCreateFilterFile: async def test_409_conflict(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.create_filter_file", + "app.routers.file_config.raw_config_io_service.create_filter_file", AsyncMock(side_effect=ConfigFileExistsError("myfilter.conf")), ): resp = await file_config_client.post( @@ -314,7 +314,7 @@ class TestCreateFilterFile: async def test_400_invalid_name(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.create_filter_file", + "app.routers.file_config.raw_config_io_service.create_filter_file", AsyncMock(side_effect=ConfigFileNameError("bad/../name")), ): resp = await file_config_client.post( @@ -387,7 +387,7 @@ class TestGetActionFileRaw: async def test_200_returns_content(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.get_action_file", + "app.routers.file_config.raw_config_io_service.get_action_file", AsyncMock(return_value=_conf_file_content("iptables")), ): resp = await file_config_client.get("/api/config/actions/iptables/raw") @@ -397,7 +397,7 @@ class TestGetActionFileRaw: async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.get_action_file", + "app.routers.file_config.raw_config_io_service.get_action_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): resp = await file_config_client.get("/api/config/actions/missing/raw") @@ -408,7 +408,7 @@ class TestGetActionFileRaw: self, file_config_client: AsyncClient ) -> None: with patch( - "app.routers.file_config.file_config_service.get_action_file", + "app.routers.file_config.raw_config_io_service.get_action_file", AsyncMock(side_effect=ConfigDirError("no dir")), ): resp = await file_config_client.get("/api/config/actions/iptables/raw") @@ -426,7 +426,7 @@ class TestUpdateActionFileRaw: async def test_204_on_success(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.write_action_file", + "app.routers.file_config.raw_config_io_service.write_action_file", AsyncMock(return_value=None), ): resp = await file_config_client.put( @@ -438,7 +438,7 @@ class TestUpdateActionFileRaw: async def test_400_write_error(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.write_action_file", + "app.routers.file_config.raw_config_io_service.write_action_file", AsyncMock(side_effect=ConfigFileWriteError("disk full")), ): resp = await file_config_client.put( @@ -450,7 +450,7 @@ class TestUpdateActionFileRaw: async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.write_action_file", + "app.routers.file_config.raw_config_io_service.write_action_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): resp = await file_config_client.put( @@ -462,7 +462,7 @@ class TestUpdateActionFileRaw: async def test_400_invalid_name(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.write_action_file", + "app.routers.file_config.raw_config_io_service.write_action_file", AsyncMock(side_effect=ConfigFileNameError("bad/../name")), ): resp = await file_config_client.put( @@ -481,7 +481,7 @@ class TestUpdateActionFileRaw: class TestCreateJailConfigFile: async def test_201_creates_file(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.create_jail_config_file", + "app.routers.file_config.raw_config_io_service.create_jail_config_file", AsyncMock(return_value="myjail.conf"), ): resp = await file_config_client.post( @@ -494,7 +494,7 @@ class TestCreateJailConfigFile: async def test_409_conflict(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.create_jail_config_file", + "app.routers.file_config.raw_config_io_service.create_jail_config_file", AsyncMock(side_effect=ConfigFileExistsError("myjail.conf")), ): resp = await file_config_client.post( @@ -506,7 +506,7 @@ class TestCreateJailConfigFile: async def test_400_invalid_name(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.create_jail_config_file", + "app.routers.file_config.raw_config_io_service.create_jail_config_file", AsyncMock(side_effect=ConfigFileNameError("bad/../name")), ): resp = await file_config_client.post( @@ -520,7 +520,7 @@ class TestCreateJailConfigFile: self, file_config_client: AsyncClient ) -> None: with patch( - "app.routers.file_config.file_config_service.create_jail_config_file", + "app.routers.file_config.raw_config_io_service.create_jail_config_file", AsyncMock(side_effect=ConfigDirError("no dir")), ): resp = await file_config_client.post( @@ -542,7 +542,7 @@ class TestGetParsedFilter: ) -> None: cfg = FilterConfig(name="nginx", filename="nginx.conf") with patch( - "app.routers.file_config.file_config_service.get_parsed_filter_file", + "app.routers.file_config.raw_config_io_service.get_parsed_filter_file", AsyncMock(return_value=cfg), ): resp = await file_config_client.get("/api/config/filters/nginx/parsed") @@ -554,7 +554,7 @@ class TestGetParsedFilter: async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.get_parsed_filter_file", + "app.routers.file_config.raw_config_io_service.get_parsed_filter_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): resp = await file_config_client.get( @@ -567,7 +567,7 @@ class TestGetParsedFilter: self, file_config_client: AsyncClient ) -> None: with patch( - "app.routers.file_config.file_config_service.get_parsed_filter_file", + "app.routers.file_config.raw_config_io_service.get_parsed_filter_file", AsyncMock(side_effect=ConfigDirError("no dir")), ): resp = await file_config_client.get("/api/config/filters/nginx/parsed") @@ -583,7 +583,7 @@ class TestGetParsedFilter: class TestUpdateParsedFilter: async def test_204_on_success(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.update_parsed_filter_file", + "app.routers.file_config.raw_config_io_service.update_parsed_filter_file", AsyncMock(return_value=None), ): resp = await file_config_client.put( @@ -595,7 +595,7 @@ class TestUpdateParsedFilter: async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.update_parsed_filter_file", + "app.routers.file_config.raw_config_io_service.update_parsed_filter_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): resp = await file_config_client.put( @@ -607,7 +607,7 @@ class TestUpdateParsedFilter: async def test_400_write_error(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.update_parsed_filter_file", + "app.routers.file_config.raw_config_io_service.update_parsed_filter_file", AsyncMock(side_effect=ConfigFileWriteError("disk full")), ): resp = await file_config_client.put( @@ -629,7 +629,7 @@ class TestGetParsedAction: ) -> None: cfg = ActionConfig(name="iptables", filename="iptables.conf") with patch( - "app.routers.file_config.file_config_service.get_parsed_action_file", + "app.routers.file_config.raw_config_io_service.get_parsed_action_file", AsyncMock(return_value=cfg), ): resp = await file_config_client.get( @@ -643,7 +643,7 @@ class TestGetParsedAction: async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.get_parsed_action_file", + "app.routers.file_config.raw_config_io_service.get_parsed_action_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): resp = await file_config_client.get( @@ -656,7 +656,7 @@ class TestGetParsedAction: self, file_config_client: AsyncClient ) -> None: with patch( - "app.routers.file_config.file_config_service.get_parsed_action_file", + "app.routers.file_config.raw_config_io_service.get_parsed_action_file", AsyncMock(side_effect=ConfigDirError("no dir")), ): resp = await file_config_client.get( @@ -674,7 +674,7 @@ class TestGetParsedAction: class TestUpdateParsedAction: async def test_204_on_success(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.update_parsed_action_file", + "app.routers.file_config.raw_config_io_service.update_parsed_action_file", AsyncMock(return_value=None), ): resp = await file_config_client.put( @@ -686,7 +686,7 @@ class TestUpdateParsedAction: async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.update_parsed_action_file", + "app.routers.file_config.raw_config_io_service.update_parsed_action_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): resp = await file_config_client.put( @@ -698,7 +698,7 @@ class TestUpdateParsedAction: async def test_400_write_error(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.update_parsed_action_file", + "app.routers.file_config.raw_config_io_service.update_parsed_action_file", AsyncMock(side_effect=ConfigFileWriteError("disk full")), ): resp = await file_config_client.put( @@ -721,7 +721,7 @@ class TestGetParsedJailFile: section = JailSectionConfig(enabled=True, port="ssh") cfg = JailFileConfig(filename="sshd.conf", jails={"sshd": section}) with patch( - "app.routers.file_config.file_config_service.get_parsed_jail_file", + "app.routers.file_config.raw_config_io_service.get_parsed_jail_file", AsyncMock(return_value=cfg), ): resp = await file_config_client.get( @@ -735,7 +735,7 @@ class TestGetParsedJailFile: async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.get_parsed_jail_file", + "app.routers.file_config.raw_config_io_service.get_parsed_jail_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), ): resp = await file_config_client.get( @@ -748,7 +748,7 @@ class TestGetParsedJailFile: self, file_config_client: AsyncClient ) -> None: with patch( - "app.routers.file_config.file_config_service.get_parsed_jail_file", + "app.routers.file_config.raw_config_io_service.get_parsed_jail_file", AsyncMock(side_effect=ConfigDirError("no dir")), ): resp = await file_config_client.get( @@ -766,7 +766,7 @@ class TestGetParsedJailFile: class TestUpdateParsedJailFile: async def test_204_on_success(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.update_parsed_jail_file", + "app.routers.file_config.raw_config_io_service.update_parsed_jail_file", AsyncMock(return_value=None), ): resp = await file_config_client.put( @@ -778,7 +778,7 @@ class TestUpdateParsedJailFile: async def test_404_not_found(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.update_parsed_jail_file", + "app.routers.file_config.raw_config_io_service.update_parsed_jail_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing.conf")), ): resp = await file_config_client.put( @@ -790,7 +790,7 @@ class TestUpdateParsedJailFile: async def test_400_write_error(self, file_config_client: AsyncClient) -> None: with patch( - "app.routers.file_config.file_config_service.update_parsed_jail_file", + "app.routers.file_config.raw_config_io_service.update_parsed_jail_file", AsyncMock(side_effect=ConfigFileWriteError("disk full")), ): resp = await file_config_client.put( diff --git a/backend/tests/test_services/test_file_config_service.py b/backend/tests/test_services/test_file_config_service.py index 202b4b4..8062f0c 100644 --- a/backend/tests/test_services/test_file_config_service.py +++ b/backend/tests/test_services/test_file_config_service.py @@ -8,7 +8,7 @@ import pytest from app.models.config import ActionConfigUpdate, FilterConfigUpdate, JailFileConfigUpdate from app.models.file_config import ConfFileCreateRequest, ConfFileUpdateRequest -from app.services.file_config_service import ( +from app.services.raw_config_io_service import ( ConfigDirError, ConfigFileExistsError, ConfigFileNameError,