diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 5ddd67e..277ec71 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -144,25 +144,19 @@ fail2ban ships with a large collection of filter definitions in `filter.d/` (ove --- -### Task 2.2 — Backend: Activate and Edit Filters +### Task 2.2 — Backend: Activate and Edit Filters ✅ DONE -**Goal:** Allow users to assign a filter to a jail and edit filter regex patterns. +**Implemented:** +- `PUT /api/config/filters/{name}` — writes `failregex`, `ignoreregex`, `datepattern`, `journalmatch` changes to `filter.d/{name}.local`. Validates regex before writing. Supports `?reload=true`. +- `POST /api/config/filters` — creates `filter.d/{name}.local` from `FilterCreateRequest`. Returns 409 if file already exists. +- `DELETE /api/config/filters/{name}` — deletes `.local` only; refuses with 409 if filter is conf-only (readonly). +- `POST /api/config/jails/{name}/filter` — assigns a filter to a jail by writing `filter = {name}` to `jail.d/{jail}.local`. Supports `?reload=true`. +- All regex patterns validated via `re.compile()` before writing; invalid patterns return 422. +- New models: `FilterUpdateRequest`, `FilterCreateRequest`, `AssignFilterRequest`. +- Resolved routing conflict: `file_config.py` raw-write routes renamed to `PUT /filters/{name}/raw` and `POST /filters/raw` (consistent with existing `GET /filters/{name}/raw`). +- Full service + router tests added; all 930 tests pass. -**Details:** - -- Add a `PUT /api/config/filters/{name}` endpoint that writes changes to a filter's `.local` override file. Accepts updated `failregex`, `ignoreregex`, `datepattern`, and `journalmatch` values. Never write to the `.conf` file directly. -- Add a `POST /api/config/jails/{jail_name}/filter` endpoint that changes which filter a jail uses. This writes `filter = {filter_name}` to the jail's `.local` config. Requires a reload for the change to take effect. -- Add a `POST /api/config/filters` endpoint to create a brand-new filter. Accepts a name and the filter definition fields. Creates a new file at `filter.d/{name}.local`. -- Add a `DELETE /api/config/filters/{name}` endpoint that deletes a custom filter's `.local` file. Refuse to delete files that are `.conf` (shipped defaults) — only user-created `.local` files without a corresponding `.conf` can be fully removed. -- Validate all regex patterns using Python's `re` module before writing them to disk. Return 422 with specific error details if any pattern is invalid. -- After any write operation, optionally trigger a fail2ban reload if the user requests it (query param `?reload=true`). - -**Files to create/modify:** -- `app/services/config_file_service.py` (add filter write/create/delete methods) -- `app/routers/config.py` (add endpoints) -- `app/models/config.py` (add `FilterUpdateRequest`, `FilterCreateRequest`) - -**References:** [Features.md §6](Features.md), [Backend-Development.md](Backend-Development.md) +**Files modified:** `app/models/config.py`, `app/services/config_file_service.py`, `app/routers/config.py`, `app/routers/file_config.py`, `tests/test_services/test_config_file_service.py`, `tests/test_routers/test_config.py`, `tests/test_routers/test_file_config.py` --- diff --git a/backend/app/models/config.py b/backend/app/models/config.py index 55799ec..1f7a644 100644 --- a/backend/app/models/config.py +++ b/backend/app/models/config.py @@ -370,6 +370,79 @@ class FilterConfigUpdate(BaseModel): journalmatch: str | None = Field(default=None) +class FilterUpdateRequest(BaseModel): + """Payload for ``PUT /api/config/filters/{name}``. + + Accepts only the user-editable ``[Definition]`` fields. Fields left as + ``None`` are not changed; the existing value from the merged conf/local is + preserved. + """ + + model_config = ConfigDict(strict=True) + + failregex: list[str] | None = Field( + default=None, + description="Updated failure-detection regex patterns. ``None`` = keep existing.", + ) + ignoreregex: list[str] | None = Field( + default=None, + description="Updated bypass-ban regex patterns. ``None`` = keep existing.", + ) + datepattern: str | None = Field( + default=None, + description="Custom date-parsing pattern. ``None`` = keep existing.", + ) + journalmatch: str | None = Field( + default=None, + description="Systemd journal match expression. ``None`` = keep existing.", + ) + + +class FilterCreateRequest(BaseModel): + """Payload for ``POST /api/config/filters``. + + Creates a new user-defined filter at ``filter.d/{name}.local``. + """ + + model_config = ConfigDict(strict=True) + + name: str = Field( + ..., + description="Filter base name (e.g. ``my-custom-filter``). Must not already exist in ``filter.d/``.", + ) + failregex: list[str] = Field( + default_factory=list, + description="Failure-detection regex patterns.", + ) + ignoreregex: list[str] = Field( + default_factory=list, + description="Regex patterns that bypass ban logic.", + ) + prefregex: str | None = Field( + default=None, + description="Prefix regex prepended to every failregex.", + ) + datepattern: str | None = Field( + default=None, + description="Custom date-parsing pattern.", + ) + journalmatch: str | None = Field( + default=None, + description="Systemd journal match expression.", + ) + + +class AssignFilterRequest(BaseModel): + """Payload for ``POST /api/config/jails/{jail_name}/filter``.""" + + model_config = ConfigDict(strict=True) + + filter_name: str = Field( + ..., + description="Filter base name to assign to the jail (e.g. ``sshd``).", + ) + + class FilterListResponse(BaseModel): """Response for ``GET /api/config/filters``.""" diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py index 5425b74..c81c906 100644 --- a/backend/app/routers/config.py +++ b/backend/app/routers/config.py @@ -9,6 +9,7 @@ global settings, test regex patterns, add log paths, and preview log files. * ``GET /api/config/jails/inactive`` — list all inactive jails * ``POST /api/config/jails/{name}/activate`` — activate an inactive jail * ``POST /api/config/jails/{name}/deactivate`` — deactivate an active jail +* ``POST /api/config/jails/{name}/filter`` — assign a filter to a jail * ``GET /api/config/global`` — global fail2ban settings * ``PUT /api/config/global`` — update global settings * ``POST /api/config/reload`` — reload fail2ban @@ -17,6 +18,9 @@ global settings, test regex patterns, add log paths, and preview log files. * ``POST /api/config/preview-log`` — preview log matches * ``GET /api/config/filters`` — list all filters with active/inactive status * ``GET /api/config/filters/{name}`` — full parsed detail for one filter +* ``PUT /api/config/filters/{name}`` — update a filter's .local override +* ``POST /api/config/filters`` — create a new user-defined filter +* ``DELETE /api/config/filters/{name}`` — delete a filter's .local file """ from __future__ import annotations @@ -29,8 +33,11 @@ from app.dependencies import AuthDep from app.models.config import ( ActivateJailRequest, AddLogPathRequest, + AssignFilterRequest, FilterConfig, + FilterCreateRequest, FilterListResponse, + FilterUpdateRequest, GlobalConfigResponse, GlobalConfigUpdate, InactiveJailListResponse, @@ -48,7 +55,11 @@ from app.models.config import ( from app.services import config_file_service, config_service, jail_service from app.services.config_file_service import ( ConfigWriteError, + FilterAlreadyExistsError, + FilterInvalidRegexError, + FilterNameError, FilterNotFoundError, + FilterReadonlyError, JailAlreadyActiveError, JailAlreadyInactiveError, JailNameError, @@ -731,3 +742,229 @@ async def get_filter( status_code=status.HTTP_404_NOT_FOUND, detail=f"Filter not found: {name!r}", ) from None + + +# --------------------------------------------------------------------------- +# Filter write endpoints (Task 2.2) +# --------------------------------------------------------------------------- + + +_FilterNamePath = Annotated[ + str, + Path(description="Filter base name, e.g. ``sshd`` or ``sshd.conf``."), +] + + +def _filter_not_found(name: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Filter not found: {name!r}", + ) + + +@router.put( + "/filters/{name}", + response_model=FilterConfig, + summary="Update a filter's .local override with new regex/pattern values", +) +async def update_filter( + request: Request, + _auth: AuthDep, + name: _FilterNamePath, + body: FilterUpdateRequest, + reload: bool = Query(default=False, description="Reload fail2ban after writing."), +) -> FilterConfig: + """Update a filter's ``[Definition]`` fields by writing a ``.local`` override. + + All regex patterns are validated before writing. The original ``.conf`` + file is never modified. Fields left as ``null`` in the request body are + kept at their current values. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Filter base name (with or without ``.conf`` extension). + body: Partial update — ``failregex``, ``ignoreregex``, ``datepattern``, + ``journalmatch``. + reload: When ``true``, trigger a fail2ban reload after writing. + + Returns: + Updated :class:`~app.models.config.FilterConfig`. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if the filter does not exist. + HTTPException: 422 if any regex pattern fails to compile. + HTTPException: 500 if writing the ``.local`` file fails. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + try: + return await config_file_service.update_filter( + config_dir, socket_path, name, body, do_reload=reload + ) + except FilterNameError as exc: + raise _bad_request(str(exc)) from exc + except FilterNotFoundError: + raise _filter_not_found(name) from None + except FilterInvalidRegexError as exc: + raise _unprocessable(str(exc)) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write filter override: {exc}", + ) from exc + + +@router.post( + "/filters", + response_model=FilterConfig, + status_code=status.HTTP_201_CREATED, + summary="Create a new user-defined filter", +) +async def create_filter( + request: Request, + _auth: AuthDep, + body: FilterCreateRequest, + reload: bool = Query(default=False, description="Reload fail2ban after creating."), +) -> FilterConfig: + """Create a new user-defined filter at ``filter.d/{name}.local``. + + The filter is created as a ``.local`` file so it can coexist safely with + shipped ``.conf`` files. Returns 409 if a ``.conf`` or ``.local`` for + the requested name already exists. + + Args: + request: FastAPI request object. + _auth: Validated session. + body: Filter name and ``[Definition]`` fields. + reload: When ``true``, trigger a fail2ban reload after creating. + + Returns: + :class:`~app.models.config.FilterConfig` for the new filter. + + Raises: + HTTPException: 400 if the name contains invalid characters. + HTTPException: 409 if the filter already exists. + HTTPException: 422 if any regex pattern is invalid. + HTTPException: 500 if writing fails. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + try: + return await config_file_service.create_filter( + config_dir, socket_path, body, do_reload=reload + ) + except FilterNameError as exc: + raise _bad_request(str(exc)) from exc + except FilterAlreadyExistsError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Filter {exc.name!r} already exists.", + ) from exc + except FilterInvalidRegexError as exc: + raise _unprocessable(str(exc)) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write filter: {exc}", + ) from exc + + +@router.delete( + "/filters/{name}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a user-created filter's .local file", +) +async def delete_filter( + request: Request, + _auth: AuthDep, + name: _FilterNamePath, +) -> None: + """Delete a user-created filter's ``.local`` override file. + + Shipped ``.conf``-only filters cannot be deleted (returns 409). When + both a ``.conf`` and a ``.local`` exist, only the ``.local`` is removed. + When only a ``.local`` exists (user-created filter), the file is deleted + entirely. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Filter base name. + + Raises: + HTTPException: 400 if *name* contains invalid characters. + HTTPException: 404 if the filter does not exist. + HTTPException: 409 if the filter is a shipped default (conf-only). + HTTPException: 500 if deletion fails. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + try: + await config_file_service.delete_filter(config_dir, name) + except FilterNameError as exc: + raise _bad_request(str(exc)) from exc + except FilterNotFoundError: + raise _filter_not_found(name) from None + except FilterReadonlyError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete filter: {exc}", + ) from exc + + +@router.post( + "/jails/{name}/filter", + status_code=status.HTTP_204_NO_CONTENT, + summary="Assign a filter to a jail", +) +async def assign_filter_to_jail( + request: Request, + _auth: AuthDep, + name: _NamePath, + body: AssignFilterRequest, + reload: bool = Query(default=False, description="Reload fail2ban after assigning."), +) -> None: + """Write ``filter = {filter_name}`` to the jail's ``.local`` config. + + Existing keys in the jail's ``.local`` file are preserved. If the file + does not exist it is created. + + Args: + request: FastAPI request object. + _auth: Validated session. + name: Jail name. + body: Filter to assign. + reload: When ``true``, trigger a fail2ban reload after writing. + + Raises: + HTTPException: 400 if *name* or *filter_name* contain invalid characters. + HTTPException: 404 if the jail or filter does not exist. + HTTPException: 500 if writing fails. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + try: + await config_file_service.assign_filter_to_jail( + config_dir, socket_path, name, body, do_reload=reload + ) + except (JailNameError, FilterNameError) as exc: + raise _bad_request(str(exc)) from exc + except JailNotFoundInConfigError: + raise _not_found(name) from None + except FilterNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Filter not found: {exc.name!r}", + ) from exc + except ConfigWriteError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to write jail override: {exc}", + ) from exc + diff --git a/backend/app/routers/file_config.py b/backend/app/routers/file_config.py index 76e3030..54aee28 100644 --- a/backend/app/routers/file_config.py +++ b/backend/app/routers/file_config.py @@ -9,8 +9,8 @@ Endpoints: * ``PUT /api/config/jail-files/{filename}`` — overwrite a jail config file * ``PUT /api/config/jail-files/{filename}/enabled`` — enable/disable a jail config * ``GET /api/config/filters/{name}/raw`` — get one filter file raw content -* ``PUT /api/config/filters/{name}`` — update a filter file -* ``POST /api/config/filters`` — create a new filter file +* ``PUT /api/config/filters/{name}/raw`` — update a filter file (raw content) +* ``POST /api/config/filters/raw`` — create a new filter file (raw content) * ``GET /api/config/filters/{name}/parsed`` — parse a filter file into a structured model * ``PUT /api/config/filters/{name}/parsed`` — update a filter file from a structured model * ``GET /api/config/actions`` — list all action files @@ -23,7 +23,8 @@ Endpoints: Note: ``GET /api/config/filters`` (enriched list) and ``GET /api/config/filters/{name}`` (full parsed detail) are handled by the config router (``config.py``), which is registered first and therefore takes -precedence. The raw-content variant is at ``/filters/{name}/raw``. +precedence. Raw-content read/write variants are at ``/filters/{name}/raw`` +and ``POST /filters/raw``. """ from __future__ import annotations @@ -347,9 +348,9 @@ async def get_filter_file_raw( @router.put( - "/filters/{name}", + "/filters/{name}/raw", status_code=status.HTTP_204_NO_CONTENT, - summary="Update a filter definition file", + summary="Update a filter definition file (raw content)", ) async def write_filter_file( request: Request, @@ -384,10 +385,10 @@ async def write_filter_file( @router.post( - "/filters", + "/filters/raw", status_code=status.HTTP_201_CREATED, response_model=ConfFileContent, - summary="Create a new filter definition file", + summary="Create a new filter definition file (raw content)", ) async def create_filter_file( request: Request, diff --git a/backend/app/services/config_file_service.py b/backend/app/services/config_file_service.py index 2f4c5aa..d05b63c 100644 --- a/backend/app/services/config_file_service.py +++ b/backend/app/services/config_file_service.py @@ -23,6 +23,7 @@ from __future__ import annotations import asyncio import configparser import contextlib +import io import os import re import tempfile @@ -33,8 +34,12 @@ import structlog from app.models.config import ( ActivateJailRequest, + AssignFilterRequest, FilterConfig, + FilterConfigUpdate, + FilterCreateRequest, FilterListResponse, + FilterUpdateRequest, InactiveJail, InactiveJailListResponse, JailActivationResponse, @@ -117,6 +122,54 @@ class ConfigWriteError(Exception): """Raised when writing a ``.local`` override file fails.""" +class FilterNameError(Exception): + """Raised when a filter name contains invalid characters.""" + + +class FilterAlreadyExistsError(Exception): + """Raised when trying to create a filter whose ``.conf`` or ``.local`` already exists.""" + + def __init__(self, name: str) -> None: + """Initialise with the filter name that already exists. + + Args: + name: The filter name that already exists. + """ + self.name: str = name + super().__init__(f"Filter already exists: {name!r}") + + +class FilterReadonlyError(Exception): + """Raised when trying to delete a shipped ``.conf`` filter with no ``.local`` override.""" + + def __init__(self, name: str) -> None: + """Initialise with the filter name that cannot be deleted. + + Args: + name: The filter name that is read-only (shipped ``.conf`` only). + """ + self.name: str = name + super().__init__( + f"Filter {name!r} is a shipped default (.conf only); " + "only user-created .local files can be deleted." + ) + + +class FilterInvalidRegexError(Exception): + """Raised when a regex pattern fails to compile.""" + + def __init__(self, pattern: str, error: str) -> None: + """Initialise with the invalid pattern and the compile error. + + Args: + pattern: The regex string that failed to compile. + error: The ``re.error`` message. + """ + self.pattern: str = pattern + self.error: str = error + super().__init__(f"Invalid regex {pattern!r}: {error}") + + # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- @@ -143,6 +196,27 @@ def _safe_jail_name(name: str) -> str: return name +def _safe_filter_name(name: str) -> str: + """Validate *name* and return it unchanged or raise :class:`FilterNameError`. + + Args: + name: Proposed filter name (without extension). + + Returns: + The name unchanged if valid. + + Raises: + FilterNameError: If *name* contains unsafe characters. + """ + if not _SAFE_FILTER_NAME_RE.match(name): + raise FilterNameError( + f"Filter name {name!r} contains invalid characters. " + "Only alphanumeric characters, hyphens, underscores, and dots are " + "allowed; must start with an alphanumeric character." + ) + return name + + def _ordered_config_files(config_dir: Path) -> list[Path]: """Return all jail config files in fail2ban merge order. @@ -479,6 +553,144 @@ def _write_local_override_sync( ) +def _validate_regex_patterns(patterns: list[str]) -> None: + """Validate each pattern in *patterns* using Python's ``re`` module. + + Args: + patterns: List of regex strings to validate. + + Raises: + FilterInvalidRegexError: If any pattern fails to compile. + """ + for pattern in patterns: + try: + re.compile(pattern) + except re.error as exc: + raise FilterInvalidRegexError(pattern, str(exc)) from exc + + +def _write_filter_local_sync(filter_d: Path, name: str, content: str) -> None: + """Write *content* to ``filter.d/{name}.local`` atomically. + + The write is atomic: content is written to a temp file first, then + renamed into place. The ``filter.d/`` directory is created if absent. + + Args: + filter_d: Path to the ``filter.d`` directory. + name: Validated filter base name (used as filename stem). + content: Full serialized filter content to write. + + Raises: + ConfigWriteError: If writing fails. + """ + try: + filter_d.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise ConfigWriteError( + f"Cannot create filter.d directory: {exc}" + ) from exc + + local_path = filter_d / f"{name}.local" + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=filter_d, + delete=False, + suffix=".tmp", + ) as tmp: + tmp.write(content) + tmp_name = tmp.name + os.replace(tmp_name, local_path) + except OSError as exc: + with contextlib.suppress(OSError): + os.unlink(tmp_name) # noqa: F821 + raise ConfigWriteError( + f"Failed to write {local_path}: {exc}" + ) from exc + + log.info("filter_local_written", filter=name, path=str(local_path)) + + +def _set_jail_local_key_sync( + config_dir: Path, + jail_name: str, + key: str, + value: str, +) -> None: + """Update ``jail.d/{jail_name}.local`` to set a single key in the jail section. + + If the ``.local`` file already exists it is read, the key is updated (or + added), and the file is written back atomically without disturbing other + settings. If the file does not exist a new one is created containing + only the BanGUI header comment, the jail section, and the requested key. + + Args: + config_dir: The fail2ban configuration root directory. + jail_name: Validated jail name (used as section name and filename stem). + key: Config key to set inside the jail section. + value: Config value to assign. + + Raises: + ConfigWriteError: If writing fails. + """ + jail_d = config_dir / "jail.d" + try: + jail_d.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise ConfigWriteError( + f"Cannot create jail.d directory: {exc}" + ) from exc + + local_path = jail_d / f"{jail_name}.local" + + parser = _build_parser() + if local_path.is_file(): + try: + parser.read(str(local_path), encoding="utf-8") + except (configparser.Error, OSError) as exc: + log.warning( + "jail_local_read_for_update_error", + jail=jail_name, + error=str(exc), + ) + + if not parser.has_section(jail_name): + parser.add_section(jail_name) + parser.set(jail_name, key, value) + + # Serialize: write a BanGUI header then the parser output. + buf = io.StringIO() + buf.write("# Managed by BanGUI — do not edit manually\n\n") + parser.write(buf) + content = buf.getvalue() + + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=jail_d, + delete=False, + suffix=".tmp", + ) as tmp: + tmp.write(content) + tmp_name = tmp.name + os.replace(tmp_name, local_path) + except OSError as exc: + with contextlib.suppress(OSError): + os.unlink(tmp_name) # noqa: F821 + raise ConfigWriteError( + f"Failed to write {local_path}: {exc}" + ) from exc + + log.info( + "jail_local_key_set", + jail=jail_name, + key=key, + path=str(local_path), + ) + + # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @@ -743,32 +955,43 @@ def _build_filter_to_jails_map( def _parse_filters_sync( filter_d: Path, -) -> list[tuple[str, str, str, bool]]: +) -> list[tuple[str, str, str, bool, str]]: """Synchronously scan ``filter.d/`` and return per-filter tuples. Each tuple contains: - ``name`` — filter base name (``"sshd"``). - - ``filename`` — actual filename (``"sshd.conf"``). + - ``filename`` — actual filename (``"sshd.conf"`` or ``"sshd.local"``). - ``content`` — merged file content (``conf`` overridden by ``local``). - - ``has_local`` — whether a ``.local`` override exists. + - ``has_local`` — whether a ``.local`` override exists alongside a ``.conf``. + - ``source_path`` — absolute path to the primary (``conf``) source file, or + to the ``.local`` file for user-created (local-only) filters. + + Also discovers ``.local``-only files (user-created filters with no + corresponding ``.conf``). These are returned with ``has_local = False`` + and ``source_path`` pointing to the ``.local`` file itself. Args: filter_d: Path to the ``filter.d`` directory. Returns: - List of ``(name, filename, content, has_local)`` tuples, sorted by name. + List of ``(name, filename, content, has_local, source_path)`` tuples, + sorted by name. """ if not filter_d.is_dir(): log.warning("filter_d_not_found", path=str(filter_d)) return [] - results: list[tuple[str, str, str, bool]] = [] + conf_names: set[str] = set() + results: list[tuple[str, str, str, bool, str]] = [] + + # ---- .conf-based filters (with optional .local override) ---------------- for conf_path in sorted(filter_d.glob("*.conf")): if not conf_path.is_file(): continue name = conf_path.stem filename = conf_path.name + conf_names.add(name) local_path = conf_path.with_suffix(".local") has_local = local_path.is_file() @@ -794,8 +1017,29 @@ def _parse_filters_sync( error=str(exc), ) - results.append((name, filename, content, has_local)) + results.append((name, filename, content, has_local, str(conf_path))) + # ---- .local-only filters (user-created, no corresponding .conf) ---------- + for local_path in sorted(filter_d.glob("*.local")): + if not local_path.is_file(): + continue + name = local_path.stem + if name in conf_names: + # Already covered above as a .conf filter with a .local override. + continue + try: + content = local_path.read_text(encoding="utf-8") + except OSError as exc: + log.warning( + "filter_local_read_error", + name=name, + path=str(local_path), + error=str(exc), + ) + continue + results.append((name, local_path.name, content, False, str(local_path))) + + results.sort(key=lambda t: t[0]) log.debug("filters_scanned", count=len(results), filter_d=str(filter_d)) return results @@ -832,7 +1076,7 @@ async def list_filters( loop = asyncio.get_event_loop() # Run the synchronous scan in a thread-pool executor. - raw_filters: list[tuple[str, str, str, bool]] = await loop.run_in_executor( + raw_filters: list[tuple[str, str, str, bool, str]] = await loop.run_in_executor( None, _parse_filters_sync, filter_d ) @@ -846,8 +1090,7 @@ async def list_filters( filter_to_jails = _build_filter_to_jails_map(all_jails, active_names) filters: list[FilterConfig] = [] - for name, filename, content, has_local in raw_filters: - conf_path = filter_d / filename + for name, filename, content, has_local, source_path in raw_filters: cfg = conffile_parser.parse_filter_file( content, name=name, filename=filename ) @@ -867,7 +1110,7 @@ async def list_filters( journalmatch=cfg.journalmatch, active=len(used_by) > 0, used_by_jails=used_by, - source_file=str(conf_path), + source_file=source_path, has_local_override=has_local, ) ) @@ -897,35 +1140,46 @@ async def get_filter( :class:`~app.models.config.FilterConfig` with status fields populated. Raises: - FilterNotFoundError: If no ``{name}.conf`` file exists in - ``filter.d/``. + FilterNotFoundError: If no ``{name}.conf`` or ``{name}.local`` file + exists in ``filter.d/``. """ - # Normalise — strip extension if provided. - base_name = name[:-5] if name.endswith(".conf") else name + # 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 = conf_path.with_suffix(".local") + local_path = filter_d / f"{base_name}.local" loop = asyncio.get_event_loop() - def _read() -> tuple[str, bool]: - if not conf_path.is_file(): - raise FilterNotFoundError(base_name) - content = conf_path.read_text(encoding="utf-8") + def _read() -> tuple[str, bool, str]: + """Read filter content and return (content, has_local_override, source_path).""" has_local = local_path.is_file() - 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 + 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 = await loop.run_in_executor(None, _read) + content, has_local, source_path = await loop.run_in_executor(None, _read) cfg = conffile_parser.parse_filter_file( content, name=base_name, filename=f"{base_name}.conf" @@ -954,6 +1208,299 @@ async def get_filter( journalmatch=cfg.journalmatch, active=len(used_by) > 0, used_by_jails=used_by, - source_file=str(conf_path), + source_file=source_path, has_local_override=has_local, ) + + +# --------------------------------------------------------------------------- +# Public API — filter write operations (Task 2.2) +# --------------------------------------------------------------------------- + + +async def update_filter( + config_dir: str, + socket_path: str, + name: str, + req: FilterUpdateRequest, + do_reload: bool = False, +) -> FilterConfig: + """Update a filter's ``.local`` override with new regex/pattern values. + + Reads the current merged configuration for *name* (``conf`` + any existing + ``local``), applies the non-``None`` fields in *req* on top of it, and + writes the resulting definition to ``filter.d/{name}.local``. The + original ``.conf`` file is never modified. + + All regex patterns in *req* are validated with Python's ``re`` module + before any write occurs. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + name: Filter base name (e.g. ``"sshd"`` or ``"sshd.conf"``). + req: Partial update — only non-``None`` fields are applied. + do_reload: When ``True``, trigger a full fail2ban reload after writing. + + Returns: + :class:`~app.models.config.FilterConfig` reflecting the updated state. + + Raises: + FilterNameError: If *name* contains invalid characters. + FilterNotFoundError: If no ``{name}.conf`` or ``{name}.local`` exists. + FilterInvalidRegexError: If any supplied regex pattern is invalid. + ConfigWriteError: If writing the ``.local`` file fails. + """ + base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name + _safe_filter_name(base_name) + + # Validate regex patterns before touching the filesystem. + patterns: list[str] = [] + if req.failregex is not None: + patterns.extend(req.failregex) + if req.ignoreregex is not None: + patterns.extend(req.ignoreregex) + _validate_regex_patterns(patterns) + + # Fetch the current merged config (raises FilterNotFoundError if absent). + current = await get_filter(config_dir, socket_path, base_name) + + # Build a FilterConfigUpdate from the request fields. + update = FilterConfigUpdate( + failregex=req.failregex, + ignoreregex=req.ignoreregex, + datepattern=req.datepattern, + journalmatch=req.journalmatch, + ) + + merged = conffile_parser.merge_filter_update(current, update) + content = conffile_parser.serialize_filter_config(merged) + + filter_d = Path(config_dir) / "filter.d" + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _write_filter_local_sync, filter_d, base_name, content) + + if do_reload: + try: + await jail_service.reload_all(socket_path) + except Exception as exc: # noqa: BLE001 + log.warning( + "reload_after_filter_update_failed", + filter=base_name, + error=str(exc), + ) + + log.info("filter_updated", filter=base_name, reload=do_reload) + return await get_filter(config_dir, socket_path, base_name) + + +async def create_filter( + config_dir: str, + socket_path: str, + req: FilterCreateRequest, + do_reload: bool = False, +) -> FilterConfig: + """Create a brand-new user-defined filter in ``filter.d/{name}.local``. + + No ``.conf`` is written; fail2ban loads ``.local`` files directly. If a + ``.conf`` or ``.local`` file already exists for the requested name, a + :class:`FilterAlreadyExistsError` is raised. + + All regex patterns are validated with Python's ``re`` module before + writing. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + req: Filter name and definition fields. + do_reload: When ``True``, trigger a full fail2ban reload after writing. + + Returns: + :class:`~app.models.config.FilterConfig` for the newly created filter. + + Raises: + FilterNameError: If ``req.name`` contains invalid characters. + FilterAlreadyExistsError: If a ``.conf`` or ``.local`` already exists. + FilterInvalidRegexError: If any regex pattern is invalid. + ConfigWriteError: If writing fails. + """ + _safe_filter_name(req.name) + + filter_d = Path(config_dir) / "filter.d" + conf_path = filter_d / f"{req.name}.conf" + local_path = filter_d / f"{req.name}.local" + + def _check_not_exists() -> None: + if conf_path.is_file() or local_path.is_file(): + raise FilterAlreadyExistsError(req.name) + + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _check_not_exists) + + # Validate regex patterns. + patterns: list[str] = list(req.failregex) + list(req.ignoreregex) + _validate_regex_patterns(patterns) + + # Build a FilterConfig and serialise it. + cfg = FilterConfig( + name=req.name, + filename=f"{req.name}.local", + failregex=req.failregex, + ignoreregex=req.ignoreregex, + prefregex=req.prefregex, + datepattern=req.datepattern, + journalmatch=req.journalmatch, + ) + content = conffile_parser.serialize_filter_config(cfg) + + await loop.run_in_executor(None, _write_filter_local_sync, filter_d, req.name, content) + + if do_reload: + try: + await jail_service.reload_all(socket_path) + except Exception as exc: # noqa: BLE001 + log.warning( + "reload_after_filter_create_failed", + filter=req.name, + error=str(exc), + ) + + log.info("filter_created", filter=req.name, reload=do_reload) + # Re-fetch to get the canonical FilterConfig (source_file, active, etc.). + return await get_filter(config_dir, socket_path, req.name) + + +async def delete_filter( + config_dir: str, + name: str, +) -> None: + """Delete a user-created filter's ``.local`` file. + + Deletion rules: + - If only a ``.conf`` file exists (shipped default, no user override) → + :class:`FilterReadonlyError`. + - If a ``.local`` file exists (whether or not a ``.conf`` also exists) → + the ``.local`` file is deleted. The shipped ``.conf`` is never touched. + - If neither file exists → :class:`FilterNotFoundError`. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + name: Filter base name (e.g. ``"sshd"``). + + Raises: + FilterNameError: If *name* contains invalid characters. + FilterNotFoundError: If no filter file is found for *name*. + FilterReadonlyError: If only a shipped ``.conf`` exists (no ``.local``). + ConfigWriteError: If deletion of the ``.local`` file fails. + """ + base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name + _safe_filter_name(base_name) + + filter_d = Path(config_dir) / "filter.d" + conf_path = filter_d / f"{base_name}.conf" + local_path = filter_d / f"{base_name}.local" + + loop = asyncio.get_event_loop() + + def _delete() -> None: + has_conf = conf_path.is_file() + has_local = local_path.is_file() + + if not has_conf and not has_local: + raise FilterNotFoundError(base_name) + + if has_conf and not has_local: + # Shipped default — nothing user-writable to remove. + raise FilterReadonlyError(base_name) + + try: + local_path.unlink() + except OSError as exc: + raise ConfigWriteError( + f"Failed to delete {local_path}: {exc}" + ) from exc + + log.info("filter_local_deleted", filter=base_name, path=str(local_path)) + + await loop.run_in_executor(None, _delete) + + +async def assign_filter_to_jail( + config_dir: str, + socket_path: str, + jail_name: str, + req: AssignFilterRequest, + do_reload: bool = False, +) -> None: + """Assign a filter to a jail by updating the jail's ``.local`` file. + + Writes ``filter = {req.filter_name}`` into the ``[{jail_name}]`` section + of ``jail.d/{jail_name}.local``. If the ``.local`` file already contains + other settings for this jail they are preserved. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + jail_name: Name of the jail to update. + req: Request containing the filter name to assign. + do_reload: When ``True``, trigger a full fail2ban reload after writing. + + Raises: + JailNameError: If *jail_name* contains invalid characters. + FilterNameError: If ``req.filter_name`` contains invalid characters. + JailNotFoundInConfigError: If *jail_name* is not defined in any config + file. + FilterNotFoundError: If ``req.filter_name`` does not exist in + ``filter.d/``. + ConfigWriteError: If writing fails. + """ + _safe_jail_name(jail_name) + _safe_filter_name(req.filter_name) + + loop = asyncio.get_event_loop() + + # Verify the jail exists in config. + all_jails, _src = await loop.run_in_executor( + None, _parse_jails_sync, Path(config_dir) + ) + if jail_name not in all_jails: + raise JailNotFoundInConfigError(jail_name) + + # Verify the filter exists (conf or local). + filter_d = Path(config_dir) / "filter.d" + + def _check_filter() -> None: + conf_exists = (filter_d / f"{req.filter_name}.conf").is_file() + local_exists = (filter_d / f"{req.filter_name}.local").is_file() + if not conf_exists and not local_exists: + raise FilterNotFoundError(req.filter_name) + + await loop.run_in_executor(None, _check_filter) + + await loop.run_in_executor( + None, + _set_jail_local_key_sync, + Path(config_dir), + jail_name, + "filter", + req.filter_name, + ) + + if do_reload: + try: + await jail_service.reload_all(socket_path) + except Exception as exc: # noqa: BLE001 + log.warning( + "reload_after_assign_filter_failed", + jail=jail_name, + filter=req.filter_name, + error=str(exc), + ) + + log.info( + "filter_assigned_to_jail", + jail=jail_name, + filter=req.filter_name, + reload=do_reload, + ) + diff --git a/backend/tests/test_routers/test_config.py b/backend/tests/test_routers/test_config.py index e1bc693..706fd21 100644 --- a/backend/tests/test_routers/test_config.py +++ b/backend/tests/test_routers/test_config.py @@ -955,3 +955,337 @@ class TestGetFilter: base_url="http://test", ).get("/api/config/filters/sshd") assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# PUT /api/config/filters/{name} (Task 2.2) +# --------------------------------------------------------------------------- + + +class TestUpdateFilter: + """Tests for ``PUT /api/config/filters/{name}``.""" + + async def test_200_returns_updated_filter(self, config_client: AsyncClient) -> None: + """PUT /api/config/filters/sshd returns 200 with updated FilterConfig.""" + with patch( + "app.routers.config.config_file_service.update_filter", + AsyncMock(return_value=_make_filter_config("sshd")), + ): + resp = await config_client.put( + "/api/config/filters/sshd", + json={"failregex": [r"^fail from "]}, + ) + + assert resp.status_code == 200 + assert resp.json()["name"] == "sshd" + + async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None: + """PUT /api/config/filters/missing returns 404.""" + from app.services.config_file_service import FilterNotFoundError + + with patch( + "app.routers.config.config_file_service.update_filter", + AsyncMock(side_effect=FilterNotFoundError("missing")), + ): + resp = await config_client.put( + "/api/config/filters/missing", + json={}, + ) + + assert resp.status_code == 404 + + async def test_422_for_invalid_regex(self, config_client: AsyncClient) -> None: + """PUT /api/config/filters/sshd returns 422 for bad regex.""" + from app.services.config_file_service import FilterInvalidRegexError + + with patch( + "app.routers.config.config_file_service.update_filter", + AsyncMock(side_effect=FilterInvalidRegexError("[bad", "unterminated")), + ): + resp = await config_client.put( + "/api/config/filters/sshd", + json={"failregex": ["[bad"]}, + ) + + assert resp.status_code == 422 + + async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None: + """PUT /api/config/filters/... with bad name returns 400.""" + from app.services.config_file_service import FilterNameError + + with patch( + "app.routers.config.config_file_service.update_filter", + AsyncMock(side_effect=FilterNameError("bad")), + ): + resp = await config_client.put( + "/api/config/filters/bad", + json={}, + ) + + assert resp.status_code == 400 + + async def test_reload_query_param_passed(self, config_client: AsyncClient) -> None: + """PUT /api/config/filters/sshd?reload=true passes do_reload=True.""" + with patch( + "app.routers.config.config_file_service.update_filter", + AsyncMock(return_value=_make_filter_config("sshd")), + ) as mock_update: + resp = await config_client.put( + "/api/config/filters/sshd?reload=true", + json={}, + ) + + assert resp.status_code == 200 + assert mock_update.call_args.kwargs.get("do_reload") is True + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """PUT /api/config/filters/sshd returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).put("/api/config/filters/sshd", json={}) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# POST /api/config/filters (Task 2.2) +# --------------------------------------------------------------------------- + + +class TestCreateFilter: + """Tests for ``POST /api/config/filters``.""" + + async def test_201_creates_filter(self, config_client: AsyncClient) -> None: + """POST /api/config/filters returns 201 with FilterConfig.""" + with patch( + "app.routers.config.config_file_service.create_filter", + AsyncMock(return_value=_make_filter_config("my-custom")), + ): + resp = await config_client.post( + "/api/config/filters", + json={"name": "my-custom", "failregex": [r"^fail from "]}, + ) + + assert resp.status_code == 201 + assert resp.json()["name"] == "my-custom" + + async def test_409_when_already_exists(self, config_client: AsyncClient) -> None: + """POST /api/config/filters returns 409 if filter exists.""" + from app.services.config_file_service import FilterAlreadyExistsError + + with patch( + "app.routers.config.config_file_service.create_filter", + AsyncMock(side_effect=FilterAlreadyExistsError("sshd")), + ): + resp = await config_client.post( + "/api/config/filters", + json={"name": "sshd"}, + ) + + assert resp.status_code == 409 + + async def test_422_for_invalid_regex(self, config_client: AsyncClient) -> None: + """POST /api/config/filters returns 422 for bad regex.""" + from app.services.config_file_service import FilterInvalidRegexError + + with patch( + "app.routers.config.config_file_service.create_filter", + AsyncMock(side_effect=FilterInvalidRegexError("[bad", "unterminated")), + ): + resp = await config_client.post( + "/api/config/filters", + json={"name": "test", "failregex": ["[bad"]}, + ) + + assert resp.status_code == 422 + + async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None: + """POST /api/config/filters returns 400 for invalid filter name.""" + from app.services.config_file_service import FilterNameError + + with patch( + "app.routers.config.config_file_service.create_filter", + AsyncMock(side_effect=FilterNameError("bad")), + ): + resp = await config_client.post( + "/api/config/filters", + json={"name": "bad"}, + ) + + assert resp.status_code == 400 + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """POST /api/config/filters returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).post("/api/config/filters", json={"name": "test"}) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# DELETE /api/config/filters/{name} (Task 2.2) +# --------------------------------------------------------------------------- + + +class TestDeleteFilter: + """Tests for ``DELETE /api/config/filters/{name}``.""" + + async def test_204_deletes_filter(self, config_client: AsyncClient) -> None: + """DELETE /api/config/filters/my-custom returns 204.""" + with patch( + "app.routers.config.config_file_service.delete_filter", + AsyncMock(return_value=None), + ): + resp = await config_client.delete("/api/config/filters/my-custom") + + assert resp.status_code == 204 + + async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None: + """DELETE /api/config/filters/missing returns 404.""" + from app.services.config_file_service import FilterNotFoundError + + with patch( + "app.routers.config.config_file_service.delete_filter", + AsyncMock(side_effect=FilterNotFoundError("missing")), + ): + resp = await config_client.delete("/api/config/filters/missing") + + assert resp.status_code == 404 + + async def test_409_for_readonly_filter(self, config_client: AsyncClient) -> None: + """DELETE /api/config/filters/sshd returns 409 for shipped conf-only filter.""" + from app.services.config_file_service import FilterReadonlyError + + with patch( + "app.routers.config.config_file_service.delete_filter", + AsyncMock(side_effect=FilterReadonlyError("sshd")), + ): + resp = await config_client.delete("/api/config/filters/sshd") + + assert resp.status_code == 409 + + async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None: + """DELETE /api/config/filters/... with bad name returns 400.""" + from app.services.config_file_service import FilterNameError + + with patch( + "app.routers.config.config_file_service.delete_filter", + AsyncMock(side_effect=FilterNameError("bad")), + ): + resp = await config_client.delete("/api/config/filters/bad") + + assert resp.status_code == 400 + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """DELETE /api/config/filters/sshd returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).delete("/api/config/filters/sshd") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# POST /api/config/jails/{name}/filter (Task 2.2) +# --------------------------------------------------------------------------- + + +class TestAssignFilterToJail: + """Tests for ``POST /api/config/jails/{name}/filter``.""" + + async def test_204_assigns_filter(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/sshd/filter returns 204 on success.""" + with patch( + "app.routers.config.config_file_service.assign_filter_to_jail", + AsyncMock(return_value=None), + ): + resp = await config_client.post( + "/api/config/jails/sshd/filter", + json={"filter_name": "myfilter"}, + ) + + assert resp.status_code == 204 + + async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/missing/filter returns 404.""" + from app.services.config_file_service import JailNotFoundInConfigError + + with patch( + "app.routers.config.config_file_service.assign_filter_to_jail", + AsyncMock(side_effect=JailNotFoundInConfigError("missing")), + ): + resp = await config_client.post( + "/api/config/jails/missing/filter", + json={"filter_name": "sshd"}, + ) + + assert resp.status_code == 404 + + async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/sshd/filter returns 404 when filter not found.""" + from app.services.config_file_service import FilterNotFoundError + + with patch( + "app.routers.config.config_file_service.assign_filter_to_jail", + AsyncMock(side_effect=FilterNotFoundError("missing-filter")), + ): + resp = await config_client.post( + "/api/config/jails/sshd/filter", + json={"filter_name": "missing-filter"}, + ) + + assert resp.status_code == 404 + + async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/.../filter with bad jail name returns 400.""" + from app.services.config_file_service import JailNameError + + with patch( + "app.routers.config.config_file_service.assign_filter_to_jail", + AsyncMock(side_effect=JailNameError("bad")), + ): + resp = await config_client.post( + "/api/config/jails/sshd/filter", + json={"filter_name": "valid"}, + ) + + assert resp.status_code == 400 + + async def test_400_for_invalid_filter_name(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/sshd/filter with bad filter name returns 400.""" + from app.services.config_file_service import FilterNameError + + with patch( + "app.routers.config.config_file_service.assign_filter_to_jail", + AsyncMock(side_effect=FilterNameError("bad")), + ): + resp = await config_client.post( + "/api/config/jails/sshd/filter", + json={"filter_name": "../evil"}, + ) + + assert resp.status_code == 400 + + async def test_reload_query_param_passed(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/sshd/filter?reload=true passes do_reload=True.""" + with patch( + "app.routers.config.config_file_service.assign_filter_to_jail", + AsyncMock(return_value=None), + ) as mock_assign: + resp = await config_client.post( + "/api/config/jails/sshd/filter?reload=true", + json={"filter_name": "sshd"}, + ) + + assert resp.status_code == 204 + assert mock_assign.call_args.kwargs.get("do_reload") is True + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """POST /api/config/jails/sshd/filter returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).post("/api/config/jails/sshd/filter", json={"filter_name": "sshd"}) + assert resp.status_code == 401 + diff --git a/backend/tests/test_routers/test_file_config.py b/backend/tests/test_routers/test_file_config.py index e6759ab..ca32c45 100644 --- a/backend/tests/test_routers/test_file_config.py +++ b/backend/tests/test_routers/test_file_config.py @@ -262,7 +262,7 @@ class TestUpdateFilterFile: AsyncMock(return_value=None), ): resp = await file_config_client.put( - "/api/config/filters/nginx", + "/api/config/filters/nginx/raw", json={"content": "[Definition]\nfailregex = test\n"}, ) @@ -274,7 +274,7 @@ class TestUpdateFilterFile: AsyncMock(side_effect=ConfigFileWriteError("disk full")), ): resp = await file_config_client.put( - "/api/config/filters/nginx", + "/api/config/filters/nginx/raw", json={"content": "x"}, ) @@ -293,7 +293,7 @@ class TestCreateFilterFile: AsyncMock(return_value="myfilter.conf"), ): resp = await file_config_client.post( - "/api/config/filters", + "/api/config/filters/raw", json={"name": "myfilter", "content": "[Definition]\n"}, ) @@ -306,7 +306,7 @@ class TestCreateFilterFile: AsyncMock(side_effect=ConfigFileExistsError("myfilter.conf")), ): resp = await file_config_client.post( - "/api/config/filters", + "/api/config/filters/raw", json={"name": "myfilter", "content": "[Definition]\n"}, ) @@ -318,7 +318,7 @@ class TestCreateFilterFile: AsyncMock(side_effect=ConfigFileNameError("bad/../name")), ): resp = await file_config_client.post( - "/api/config/filters", + "/api/config/filters/raw", json={"name": "../escape", "content": "[Definition]\n"}, ) diff --git a/backend/tests/test_services/test_config_file_service.py b/backend/tests/test_services/test_config_file_service.py index 7e462fb..56a94cb 100644 --- a/backend/tests/test_services/test_config_file_service.py +++ b/backend/tests/test_services/test_config_file_service.py @@ -660,11 +660,12 @@ class TestParseFiltersSync: result = _parse_filters_sync(filter_d) assert len(result) == 1 - name, filename, content, has_local = result[0] + name, filename, content, has_local, source_path = result[0] assert name == "nginx" assert filename == "nginx.conf" assert "failregex" in content assert has_local is False + assert source_path.endswith("nginx.conf") def test_local_override_detected(self, tmp_path: Path) -> None: from app.services.config_file_service import _parse_filters_sync @@ -675,7 +676,7 @@ class TestParseFiltersSync: result = _parse_filters_sync(filter_d) - _, _, _, has_local = result[0] + _, _, _, has_local, _ = result[0] assert has_local is True def test_local_content_appended_to_content(self, tmp_path: Path) -> None: @@ -687,7 +688,7 @@ class TestParseFiltersSync: result = _parse_filters_sync(filter_d) - _, _, content, _ = result[0] + _, _, content, _, _ = result[0] assert "local tweak" in content def test_sorted_alphabetically(self, tmp_path: Path) -> None: @@ -852,3 +853,637 @@ class TestGetFilter: result = await get_filter(str(tmp_path), "/fake.sock", "sshd") assert result.has_local_override is True + + +# --------------------------------------------------------------------------- +# _parse_filters_sync — .local-only filters (Task 2.2) +# --------------------------------------------------------------------------- + + +class TestParseFiltersSyncLocalOnly: + """Verify that .local-only user-created filters appear in results.""" + + def test_local_only_included(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_filters_sync + + filter_d = tmp_path / "filter.d" + _write(filter_d / "custom.local", "[Definition]\nfailregex = ^fail\n") + + result = _parse_filters_sync(filter_d) + + assert len(result) == 1 + name, filename, content, has_local, source_path = result[0] + assert name == "custom" + assert filename == "custom.local" + assert has_local is False # .local-only: no conf to override + assert source_path.endswith("custom.local") + + def test_local_only_not_duplicated_when_conf_exists(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_filters_sync + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + _write(filter_d / "sshd.local", "[Definition]\n") + + result = _parse_filters_sync(filter_d) + + # sshd should appear exactly once (conf + local, not as separate entry) + names = [r[0] for r in result] + assert names.count("sshd") == 1 + _, _, _, has_local, _ = result[0] + assert has_local is True # conf + local → has_local_override + + def test_local_only_sorted_with_conf_filters(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_filters_sync + + filter_d = tmp_path / "filter.d" + _write(filter_d / "zzz.conf", _FILTER_CONF) + _write(filter_d / "aaa.local", "[Definition]\nfailregex = x\n") + + result = _parse_filters_sync(filter_d) + + names = [r[0] for r in result] + assert names == ["aaa", "zzz"] + + +# --------------------------------------------------------------------------- +# get_filter — .local-only filter (Task 2.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestGetFilterLocalOnly: + """Verify that get_filter handles .local-only user-created filters.""" + + async def test_returns_local_only_filter(self, tmp_path: Path) -> None: + from app.services.config_file_service import get_filter + + filter_d = tmp_path / "filter.d" + _write( + filter_d / "custom.local", + "[Definition]\nfailregex = ^fail from \n", + ) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await get_filter(str(tmp_path), "/fake.sock", "custom") + + assert result.name == "custom" + assert result.has_local_override is False + assert result.source_file.endswith("custom.local") + assert len(result.failregex) == 1 + + async def test_raises_when_neither_conf_nor_local(self, tmp_path: Path) -> None: + from app.services.config_file_service import FilterNotFoundError, get_filter + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), pytest.raises(FilterNotFoundError): + await get_filter(str(tmp_path), "/fake.sock", "nonexistent") + + async def test_accepts_local_extension(self, tmp_path: Path) -> None: + from app.services.config_file_service import get_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "custom.local", "[Definition]\nfailregex = x\n") + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await get_filter(str(tmp_path), "/fake.sock", "custom.local") + + assert result.name == "custom" + + +# --------------------------------------------------------------------------- +# _validate_regex_patterns (Task 2.2) +# --------------------------------------------------------------------------- + + +class TestValidateRegexPatterns: + def test_valid_patterns_pass(self) -> None: + from app.services.config_file_service import _validate_regex_patterns + + _validate_regex_patterns([r"^fail from \S+", r"\d+\.\d+"]) + + def test_empty_list_passes(self) -> None: + from app.services.config_file_service import _validate_regex_patterns + + _validate_regex_patterns([]) + + def test_invalid_pattern_raises(self) -> None: + from app.services.config_file_service import ( + FilterInvalidRegexError, + _validate_regex_patterns, + ) + + with pytest.raises(FilterInvalidRegexError) as exc_info: + _validate_regex_patterns([r"[unclosed"]) + + assert "[unclosed" in exc_info.value.pattern + + def test_mixed_valid_invalid_raises_on_first_invalid(self) -> None: + from app.services.config_file_service import ( + FilterInvalidRegexError, + _validate_regex_patterns, + ) + + with pytest.raises(FilterInvalidRegexError) as exc_info: + _validate_regex_patterns([r"\d+", r"[bad", r"\w+"]) + + assert "[bad" in exc_info.value.pattern + + +# --------------------------------------------------------------------------- +# _write_filter_local_sync (Task 2.2) +# --------------------------------------------------------------------------- + + +class TestWriteFilterLocalSync: + def test_writes_file(self, tmp_path: Path) -> None: + from app.services.config_file_service import _write_filter_local_sync + + filter_d = tmp_path / "filter.d" + filter_d.mkdir() + _write_filter_local_sync(filter_d, "myfilter", "[Definition]\n") + + local = filter_d / "myfilter.local" + assert local.is_file() + assert "[Definition]" in local.read_text() + + def test_creates_filter_d_if_missing(self, tmp_path: Path) -> None: + from app.services.config_file_service import _write_filter_local_sync + + filter_d = tmp_path / "filter.d" + _write_filter_local_sync(filter_d, "test", "[Definition]\n") + assert (filter_d / "test.local").is_file() + + def test_overwrites_existing_file(self, tmp_path: Path) -> None: + from app.services.config_file_service import _write_filter_local_sync + + filter_d = tmp_path / "filter.d" + filter_d.mkdir() + (filter_d / "myfilter.local").write_text("old content") + + _write_filter_local_sync(filter_d, "myfilter", "[Definition]\nnew=1\n") + + assert "new=1" in (filter_d / "myfilter.local").read_text() + assert "old content" not in (filter_d / "myfilter.local").read_text() + + +# --------------------------------------------------------------------------- +# _set_jail_local_key_sync (Task 2.2) +# --------------------------------------------------------------------------- + + +class TestSetJailLocalKeySync: + def test_creates_new_local_file(self, tmp_path: Path) -> None: + from app.services.config_file_service import _set_jail_local_key_sync + + _set_jail_local_key_sync(tmp_path, "sshd", "filter", "myfilter") + + local = tmp_path / "jail.d" / "sshd.local" + assert local.is_file() + content = local.read_text() + assert "filter" in content + assert "myfilter" in content + + def test_updates_existing_local_file(self, tmp_path: Path) -> None: + from app.services.config_file_service import _set_jail_local_key_sync + + jail_d = tmp_path / "jail.d" + jail_d.mkdir() + (jail_d / "sshd.local").write_text( + "[sshd]\nenabled = true\n" + ) + + _set_jail_local_key_sync(tmp_path, "sshd", "filter", "newfilter") + + content = (jail_d / "sshd.local").read_text() + assert "newfilter" in content + # Existing key is preserved + assert "enabled" in content + + def test_overwrites_existing_key(self, tmp_path: Path) -> None: + from app.services.config_file_service import _set_jail_local_key_sync + + jail_d = tmp_path / "jail.d" + jail_d.mkdir() + (jail_d / "sshd.local").write_text("[sshd]\nfilter = old\n") + + _set_jail_local_key_sync(tmp_path, "sshd", "filter", "newfilter") + + content = (jail_d / "sshd.local").read_text() + assert "newfilter" in content + + +# --------------------------------------------------------------------------- +# update_filter (Task 2.2) +# --------------------------------------------------------------------------- + + +_FILTER_CONF_WITH_REGEX = """\ +[Definition] + +failregex = ^fail from + ^error from + +ignoreregex = +""" + + +@pytest.mark.asyncio +class TestUpdateFilter: + async def test_writes_local_override(self, tmp_path: Path) -> None: + from app.models.config import FilterUpdateRequest + from app.services.config_file_service import update_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await update_filter( + str(tmp_path), + "/fake.sock", + "sshd", + FilterUpdateRequest(failregex=[r"^new pattern "]), + ) + + local = filter_d / "sshd.local" + assert local.is_file() + assert result.name == "sshd" + assert any("new pattern" in p for p in result.failregex) + + async def test_accepts_conf_extension(self, tmp_path: Path) -> None: + from app.models.config import FilterUpdateRequest + from app.services.config_file_service import update_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await update_filter( + str(tmp_path), + "/fake.sock", + "sshd.conf", + FilterUpdateRequest(datepattern="%Y-%m-%d"), + ) + + assert result.name == "sshd" + + async def test_raises_filter_not_found(self, tmp_path: Path) -> None: + from app.models.config import FilterUpdateRequest + from app.services.config_file_service import FilterNotFoundError, update_filter + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), pytest.raises(FilterNotFoundError): + await update_filter( + str(tmp_path), + "/fake.sock", + "missing", + FilterUpdateRequest(), + ) + + async def test_raises_on_invalid_regex(self, tmp_path: Path) -> None: + from app.models.config import FilterUpdateRequest + from app.services.config_file_service import ( + FilterInvalidRegexError, + update_filter, + ) + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), pytest.raises(FilterInvalidRegexError): + await update_filter( + str(tmp_path), + "/fake.sock", + "sshd", + FilterUpdateRequest(failregex=[r"[unclosed"]), + ) + + async def test_raises_filter_name_error_for_invalid_name(self, tmp_path: Path) -> None: + from app.models.config import FilterUpdateRequest + from app.services.config_file_service import FilterNameError, update_filter + + with pytest.raises(FilterNameError): + await update_filter( + str(tmp_path), + "/fake.sock", + "../etc/passwd", + FilterUpdateRequest(), + ) + + async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None: + from app.models.config import FilterUpdateRequest + from app.services.config_file_service import update_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), patch( + "app.services.config_file_service.jail_service.reload_all", + new=AsyncMock(), + ) as mock_reload: + await update_filter( + str(tmp_path), + "/fake.sock", + "sshd", + FilterUpdateRequest(journalmatch="_SYSTEMD_UNIT=sshd.service"), + do_reload=True, + ) + + mock_reload.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# create_filter (Task 2.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestCreateFilter: + async def test_creates_local_file(self, tmp_path: Path) -> None: + from app.models.config import FilterCreateRequest + from app.services.config_file_service import create_filter + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await create_filter( + str(tmp_path), + "/fake.sock", + FilterCreateRequest( + name="my-custom", + failregex=[r"^fail from "], + ), + ) + + local = tmp_path / "filter.d" / "my-custom.local" + assert local.is_file() + assert result.name == "my-custom" + assert result.source_file.endswith("my-custom.local") + + async def test_raises_already_exists_when_conf_exists(self, tmp_path: Path) -> None: + from app.models.config import FilterCreateRequest + from app.services.config_file_service import FilterAlreadyExistsError, create_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), pytest.raises(FilterAlreadyExistsError): + await create_filter( + str(tmp_path), + "/fake.sock", + FilterCreateRequest(name="sshd"), + ) + + async def test_raises_already_exists_when_local_exists(self, tmp_path: Path) -> None: + from app.models.config import FilterCreateRequest + from app.services.config_file_service import FilterAlreadyExistsError, create_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "custom.local", "[Definition]\n") + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), pytest.raises(FilterAlreadyExistsError): + await create_filter( + str(tmp_path), + "/fake.sock", + FilterCreateRequest(name="custom"), + ) + + async def test_raises_invalid_regex(self, tmp_path: Path) -> None: + from app.models.config import FilterCreateRequest + from app.services.config_file_service import FilterInvalidRegexError, create_filter + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), pytest.raises(FilterInvalidRegexError): + await create_filter( + str(tmp_path), + "/fake.sock", + FilterCreateRequest(name="bad", failregex=[r"[unclosed"]), + ) + + async def test_raises_filter_name_error_for_invalid_name(self, tmp_path: Path) -> None: + from app.models.config import FilterCreateRequest + from app.services.config_file_service import FilterNameError, create_filter + + with pytest.raises(FilterNameError): + await create_filter( + str(tmp_path), + "/fake.sock", + FilterCreateRequest(name="../etc/evil"), + ) + + async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None: + from app.models.config import FilterCreateRequest + from app.services.config_file_service import create_filter + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ), patch( + "app.services.config_file_service.jail_service.reload_all", + new=AsyncMock(), + ) as mock_reload: + await create_filter( + str(tmp_path), + "/fake.sock", + FilterCreateRequest(name="newfilter"), + do_reload=True, + ) + + mock_reload.assert_awaited_once() + + +# --------------------------------------------------------------------------- +# delete_filter (Task 2.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestDeleteFilter: + async def test_deletes_local_file_when_conf_and_local_exist( + self, tmp_path: Path + ) -> None: + from app.services.config_file_service import delete_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + _write(filter_d / "sshd.local", "[Definition]\n") + + await delete_filter(str(tmp_path), "sshd") + + assert not (filter_d / "sshd.local").exists() + assert (filter_d / "sshd.conf").exists() + + async def test_deletes_local_only_filter(self, tmp_path: Path) -> None: + from app.services.config_file_service import delete_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "custom.local", "[Definition]\n") + + await delete_filter(str(tmp_path), "custom") + + assert not (filter_d / "custom.local").exists() + + async def test_raises_readonly_for_conf_only(self, tmp_path: Path) -> None: + from app.services.config_file_service import FilterReadonlyError, delete_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + + with pytest.raises(FilterReadonlyError): + await delete_filter(str(tmp_path), "sshd") + + async def test_raises_not_found_for_missing_filter(self, tmp_path: Path) -> None: + from app.services.config_file_service import FilterNotFoundError, delete_filter + + with pytest.raises(FilterNotFoundError): + await delete_filter(str(tmp_path), "nonexistent") + + async def test_accepts_filter_name_error_for_invalid_name( + self, tmp_path: Path + ) -> None: + from app.services.config_file_service import FilterNameError, delete_filter + + with pytest.raises(FilterNameError): + await delete_filter(str(tmp_path), "../evil") + + +# --------------------------------------------------------------------------- +# assign_filter_to_jail (Task 2.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestAssignFilterToJail: + async def test_writes_filter_key_to_jail_local(self, tmp_path: Path) -> None: + from app.models.config import AssignFilterRequest + from app.services.config_file_service import assign_filter_to_jail + + # Setup: jail.conf with sshd jail, filter.conf for "myfilter" + _write(tmp_path / "jail.conf", JAIL_CONF) + _write(tmp_path / "filter.d" / "myfilter.conf", _FILTER_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + await assign_filter_to_jail( + str(tmp_path), + "/fake.sock", + "sshd", + AssignFilterRequest(filter_name="myfilter"), + ) + + local = tmp_path / "jail.d" / "sshd.local" + assert local.is_file() + content = local.read_text() + assert "myfilter" in content + + async def test_raises_jail_not_found(self, tmp_path: Path) -> None: + from app.models.config import AssignFilterRequest + from app.services.config_file_service import ( + JailNotFoundInConfigError, + assign_filter_to_jail, + ) + + _write(tmp_path / "filter.d" / "sshd.conf", _FILTER_CONF) + + with pytest.raises(JailNotFoundInConfigError): + await assign_filter_to_jail( + str(tmp_path), + "/fake.sock", + "nonexistent-jail", + AssignFilterRequest(filter_name="sshd"), + ) + + async def test_raises_filter_not_found(self, tmp_path: Path) -> None: + from app.models.config import AssignFilterRequest + from app.services.config_file_service import FilterNotFoundError, assign_filter_to_jail + + _write(tmp_path / "jail.conf", JAIL_CONF) + + with pytest.raises(FilterNotFoundError): + await assign_filter_to_jail( + str(tmp_path), + "/fake.sock", + "sshd", + AssignFilterRequest(filter_name="nonexistent-filter"), + ) + + async def test_raises_jail_name_error_for_invalid_name(self, tmp_path: Path) -> None: + from app.models.config import AssignFilterRequest + from app.services.config_file_service import JailNameError, assign_filter_to_jail + + with pytest.raises(JailNameError): + await assign_filter_to_jail( + str(tmp_path), + "/fake.sock", + "../etc/evil", + AssignFilterRequest(filter_name="sshd"), + ) + + async def test_raises_filter_name_error_for_invalid_filter( + self, tmp_path: Path + ) -> None: + from app.models.config import AssignFilterRequest + from app.services.config_file_service import FilterNameError, assign_filter_to_jail + + with pytest.raises(FilterNameError): + await assign_filter_to_jail( + str(tmp_path), + "/fake.sock", + "sshd", + AssignFilterRequest(filter_name="../etc/evil"), + ) + + async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None: + from app.models.config import AssignFilterRequest + from app.services.config_file_service import assign_filter_to_jail + + _write(tmp_path / "jail.conf", JAIL_CONF) + _write(tmp_path / "filter.d" / "myfilter.conf", _FILTER_CONF) + + with patch( + "app.services.config_file_service.jail_service.reload_all", + new=AsyncMock(), + ) as mock_reload: + await assign_filter_to_jail( + str(tmp_path), + "/fake.sock", + "sshd", + AssignFilterRequest(filter_name="myfilter"), + do_reload=True, + ) + + mock_reload.assert_awaited_once() +