diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 535d7e4..5ddd67e 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -121,7 +121,7 @@ Currently BanGUI only shows jails that are actively running in fail2ban. fail2ba fail2ban ships with a large collection of filter definitions in `filter.d/` (over 80 files). Users need to see all available filters — both those currently in use by active jails and those available but unused — and assign them to jails. -### Task 2.1 — Backend: List All Available Filters with Active/Inactive Status +### Task 2.1 — Backend: List All Available Filters with Active/Inactive Status ✅ DONE **Goal:** Enumerate all filter config files and mark each as active or inactive. diff --git a/backend/app/models/config.py b/backend/app/models/config.py index 5d324d4..55799ec 100644 --- a/backend/app/models/config.py +++ b/backend/app/models/config.py @@ -275,7 +275,15 @@ class MapColorThresholdsUpdate(BaseModel): class FilterConfig(BaseModel): - """Structured representation of a ``filter.d/*.conf`` file.""" + """Structured representation of a ``filter.d/*.conf`` file. + + The ``active``, ``used_by_jails``, ``source_file``, and + ``has_local_override`` fields are populated by + :func:`~app.services.config_file_service.list_filters` and + :func:`~app.services.config_file_service.get_filter`. When the model is + returned from the raw file-based endpoints (``/filters/{name}/parsed``), + these fields carry their default values. + """ model_config = ConfigDict(strict=True) @@ -314,6 +322,33 @@ class FilterConfig(BaseModel): default=None, description="Systemd journal match expression.", ) + # Active-status fields — populated by config_file_service.list_filters / + # get_filter; default to safe "inactive" values when not computed. + active: bool = Field( + default=False, + description=( + "``True`` when this filter is referenced by at least one currently " + "enabled (running) jail." + ), + ) + used_by_jails: list[str] = Field( + default_factory=list, + description=( + "Names of currently enabled jails that reference this filter. " + "Empty when ``active`` is ``False``." + ), + ) + source_file: str = Field( + default="", + description="Absolute path to the ``.conf`` source file for this filter.", + ) + has_local_override: bool = Field( + default=False, + description=( + "``True`` when a ``.local`` override file exists alongside the " + "base ``.conf`` file." + ), + ) class FilterConfigUpdate(BaseModel): @@ -335,6 +370,21 @@ class FilterConfigUpdate(BaseModel): journalmatch: str | None = Field(default=None) +class FilterListResponse(BaseModel): + """Response for ``GET /api/config/filters``.""" + + model_config = ConfigDict(strict=True) + + filters: list[FilterConfig] = Field( + default_factory=list, + description=( + "All discovered filters, each annotated with active/inactive status " + "and the jails that reference them." + ), + ) + total: int = Field(..., ge=0, description="Total number of filters found.") + + # --------------------------------------------------------------------------- # Parsed action file models # --------------------------------------------------------------------------- diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py index 6e78de2..5425b74 100644 --- a/backend/app/routers/config.py +++ b/backend/app/routers/config.py @@ -15,6 +15,8 @@ global settings, test regex patterns, add log paths, and preview log files. * ``POST /api/config/regex-test`` — test a regex pattern * ``POST /api/config/jails/{name}/logpath`` — add a log path to a jail * ``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 """ from __future__ import annotations @@ -27,6 +29,8 @@ from app.dependencies import AuthDep from app.models.config import ( ActivateJailRequest, AddLogPathRequest, + FilterConfig, + FilterListResponse, GlobalConfigResponse, GlobalConfigUpdate, InactiveJailListResponse, @@ -44,6 +48,7 @@ from app.models.config import ( from app.services import config_file_service, config_service, jail_service from app.services.config_file_service import ( ConfigWriteError, + FilterNotFoundError, JailAlreadyActiveError, JailAlreadyInactiveError, JailNameError, @@ -646,3 +651,83 @@ async def deactivate_jail( ) from exc except Fail2BanConnectionError as exc: raise _bad_gateway(exc) from exc + + +# --------------------------------------------------------------------------- +# Filter discovery endpoints (Task 2.1) +# --------------------------------------------------------------------------- + + +@router.get( + "/filters", + response_model=FilterListResponse, + summary="List all available filters with active/inactive status", +) +async def list_filters( + request: Request, + _auth: AuthDep, +) -> FilterListResponse: + """Return all filters discovered in ``filter.d/`` with active/inactive status. + + Scans ``{config_dir}/filter.d/`` for ``.conf`` files, merges any + corresponding ``.local`` overrides, and cross-references each filter's + name against the ``filter`` fields of currently running jails to determine + whether it is active. + + Active filters (those used by at least one running jail) are sorted to the + top of the list; inactive filters follow. Both groups are sorted + alphabetically within themselves. + + Args: + request: FastAPI request object. + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.config.FilterListResponse` with all discovered + filters. + """ + config_dir: str = request.app.state.settings.fail2ban_config_dir + socket_path: str = request.app.state.settings.fail2ban_socket + result = await config_file_service.list_filters(config_dir, socket_path) + # Sort: active first (by name), then inactive (by name). + result.filters.sort(key=lambda f: (not f.active, f.name.lower())) + return result + + +@router.get( + "/filters/{name}", + response_model=FilterConfig, + summary="Return full parsed detail for a single filter", +) +async def get_filter( + request: Request, + _auth: AuthDep, + name: Annotated[str, Path(description="Filter base name, e.g. ``sshd`` or ``sshd.conf``.")], +) -> FilterConfig: + """Return the full parsed configuration and active/inactive status for one filter. + + Reads ``{config_dir}/filter.d/{name}.conf``, merges any corresponding + ``.local`` override, and annotates the result with ``active``, + ``used_by_jails``, ``source_file``, and ``has_local_override``. + + Args: + request: FastAPI request object. + _auth: Validated session — enforces authentication. + name: Filter base name (with or without ``.conf`` extension). + + Returns: + :class:`~app.models.config.FilterConfig`. + + Raises: + HTTPException: 404 if the filter is not found in ``filter.d/``. + HTTPException: 502 if fail2ban is unreachable. + """ + 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.get_filter(config_dir, socket_path, name) + except FilterNotFoundError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Filter not found: {name!r}", + ) from None diff --git a/backend/app/routers/file_config.py b/backend/app/routers/file_config.py index 57a199d..76e3030 100644 --- a/backend/app/routers/file_config.py +++ b/backend/app/routers/file_config.py @@ -8,8 +8,7 @@ Endpoints: * ``GET /api/config/jail-files/{filename}`` — get one jail config file (with content) * ``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`` — list all filter files -* ``GET /api/config/filters/{name}`` — get one filter file (with content) +* ``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 * ``GET /api/config/filters/{name}/parsed`` — parse a filter file into a structured model @@ -20,6 +19,11 @@ Endpoints: * ``POST /api/config/actions`` — create a new action file * ``GET /api/config/actions/{name}/parsed`` — parse an action file into a structured model * ``PUT /api/config/actions/{name}/parsed`` — update an action file from a structured model + +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``. """ from __future__ import annotations @@ -303,41 +307,20 @@ async def create_jail_config_file( @router.get( - "/filters", - response_model=ConfFilesResponse, - summary="List all filter definition files", -) -async def list_filter_files( - request: Request, - _auth: AuthDep, -) -> ConfFilesResponse: - """Return a list of every ``.conf`` and ``.local`` file in ``filter.d/``. - - Args: - request: Incoming request. - _auth: Validated session. - - Returns: - :class:`~app.models.file_config.ConfFilesResponse`. - """ - config_dir: str = request.app.state.settings.fail2ban_config_dir - try: - return await file_config_service.list_filter_files(config_dir) - except ConfigDirError as exc: - raise _service_unavailable(str(exc)) from exc - - -@router.get( - "/filters/{name}", + "/filters/{name}/raw", response_model=ConfFileContent, - summary="Return a filter definition file with its content", + summary="Return a filter definition file's raw content", ) -async def get_filter_file( +async def get_filter_file_raw( request: Request, _auth: AuthDep, name: _NamePath, ) -> ConfFileContent: - """Return the content of a filter definition file. + """Return the raw content of a filter definition file. + + This endpoint provides direct access to the file bytes for the raw + config editor. For structured parsing with active/inactive status use + ``GET /api/config/filters/{name}`` (served by the config router). Args: request: Incoming request. diff --git a/backend/app/services/config_file_service.py b/backend/app/services/config_file_service.py index c0791d0..2f4c5aa 100644 --- a/backend/app/services/config_file_service.py +++ b/backend/app/services/config_file_service.py @@ -22,6 +22,7 @@ from __future__ import annotations import asyncio import configparser +import contextlib import os import re import tempfile @@ -32,11 +33,13 @@ import structlog from app.models.config import ( ActivateJailRequest, + FilterConfig, + FilterListResponse, InactiveJail, InactiveJailListResponse, JailActivationResponse, ) -from app.services import jail_service +from app.services import conffile_parser, jail_service from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError log: structlog.stdlib.BoundLogger = structlog.get_logger() @@ -462,10 +465,8 @@ def _write_local_override_sync( os.replace(tmp_name, local_path) except OSError as exc: # Clean up temp file if rename failed. - try: + with contextlib.suppress(OSError): os.unlink(tmp_name) # noqa: F821 — only reachable when tmp_name is set - except OSError: - pass raise ConfigWriteError( f"Failed to write {local_path}: {exc}" ) from exc @@ -664,3 +665,295 @@ async def deactivate_jail( active=False, message=f"Jail {name!r} deactivated successfully.", ) + + +# --------------------------------------------------------------------------- +# Filter discovery helpers (Task 2.1) +# --------------------------------------------------------------------------- + +# Allowlist pattern for filter names used in path construction. +_SAFE_FILTER_NAME_RE: re.Pattern[str] = re.compile( + r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" +) + + +class FilterNotFoundError(Exception): + """Raised when the requested filter name is not found in ``filter.d/``.""" + + def __init__(self, name: str) -> None: + """Initialise with the filter name that was not found. + + Args: + name: The filter name that could not be located. + """ + self.name: str = name + super().__init__(f"Filter not found: {name!r}") + + +def _extract_filter_base_name(filter_raw: str) -> str: + """Extract the base filter name from a raw fail2ban filter string. + + fail2ban jail configs may specify a filter with an optional mode suffix, + e.g. ``sshd``, ``sshd[mode=aggressive]``, or + ``%(__name__)s[mode=%(mode)s]``. This function strips the ``[…]`` mode + block and any leading/trailing whitespace to return just the file-system + base name used to look up ``filter.d/{name}.conf``. + + Args: + filter_raw: Raw ``filter`` value from a jail config (already + with ``%(__name__)s`` substituted by the caller). + + Returns: + Base filter name, e.g. ``"sshd"``. + """ + bracket = filter_raw.find("[") + if bracket != -1: + return filter_raw[:bracket].strip() + return filter_raw.strip() + + +def _build_filter_to_jails_map( + all_jails: dict[str, dict[str, str]], + active_names: set[str], +) -> dict[str, list[str]]: + """Return a mapping of filter base name → list of active jail names. + + Iterates over every jail whose name is in *active_names*, resolves its + ``filter`` config key, and records the jail against the base filter name. + + Args: + all_jails: Merged jail config dict — ``{jail_name: {key: value}}``. + active_names: Set of jail names currently running in fail2ban. + + Returns: + ``{filter_base_name: [jail_name, …]}``. + """ + mapping: dict[str, list[str]] = {} + for jail_name, settings in all_jails.items(): + if jail_name not in active_names: + continue + raw_filter = settings.get("filter", "") + mode = settings.get("mode", "normal") + resolved = _resolve_filter(raw_filter, jail_name, mode) if raw_filter else jail_name + base = _extract_filter_base_name(resolved) + if base: + mapping.setdefault(base, []).append(jail_name) + return mapping + + +def _parse_filters_sync( + filter_d: Path, +) -> list[tuple[str, str, str, bool]]: + """Synchronously scan ``filter.d/`` and return per-filter tuples. + + Each tuple contains: + + - ``name`` — filter base name (``"sshd"``). + - ``filename`` — actual filename (``"sshd.conf"``). + - ``content`` — merged file content (``conf`` overridden by ``local``). + - ``has_local`` — whether a ``.local`` override exists. + + Args: + filter_d: Path to the ``filter.d`` directory. + + Returns: + List of ``(name, filename, content, has_local)`` 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]] = [] + for conf_path in sorted(filter_d.glob("*.conf")): + if not conf_path.is_file(): + continue + name = conf_path.stem + filename = conf_path.name + local_path = conf_path.with_suffix(".local") + has_local = local_path.is_file() + + try: + content = conf_path.read_text(encoding="utf-8") + except OSError as exc: + log.warning( + "filter_read_error", name=name, path=str(conf_path), error=str(exc) + ) + continue + + if has_local: + try: + local_content = local_path.read_text(encoding="utf-8") + # Append local content after conf so configparser reads local + # values last (higher priority). + content = content + "\n" + local_content + except OSError as exc: + log.warning( + "filter_local_read_error", + name=name, + path=str(local_path), + error=str(exc), + ) + + results.append((name, filename, content, has_local)) + + log.debug("filters_scanned", count=len(results), filter_d=str(filter_d)) + return results + + +# --------------------------------------------------------------------------- +# Public API — filter discovery (Task 2.1) +# --------------------------------------------------------------------------- + + +async def list_filters( + config_dir: str, + socket_path: str, +) -> FilterListResponse: + """Return all available filters from ``filter.d/`` with active/inactive status. + + Scans ``{config_dir}/filter.d/`` for ``.conf`` files, merges any + corresponding ``.local`` overrides, parses each file into a + :class:`~app.models.config.FilterConfig`, and cross-references with the + currently running jails to determine which filters are active. + + A filter is considered *active* when its base name matches the ``filter`` + field of at least one currently running jail. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + + Returns: + :class:`~app.models.config.FilterListResponse` with all filters + sorted alphabetically, active ones carrying non-empty + ``used_by_jails`` lists. + """ + filter_d = Path(config_dir) / "filter.d" + 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( + None, _parse_filters_sync, filter_d + ) + + # Fetch active jail names and their configs concurrently. + all_jails_result, active_names = await asyncio.gather( + loop.run_in_executor(None, _parse_jails_sync, Path(config_dir)), + _get_active_jail_names(socket_path), + ) + all_jails, _source_files = all_jails_result + + filter_to_jails = _build_filter_to_jails_map(all_jails, active_names) + + filters: list[FilterConfig] = [] + for name, filename, content, has_local in raw_filters: + conf_path = filter_d / filename + cfg = conffile_parser.parse_filter_file( + content, name=name, filename=filename + ) + used_by = sorted(filter_to_jails.get(name, [])) + filters.append( + FilterConfig( + name=cfg.name, + filename=cfg.filename, + before=cfg.before, + after=cfg.after, + variables=cfg.variables, + prefregex=cfg.prefregex, + failregex=cfg.failregex, + ignoreregex=cfg.ignoreregex, + maxlines=cfg.maxlines, + datepattern=cfg.datepattern, + journalmatch=cfg.journalmatch, + active=len(used_by) > 0, + used_by_jails=used_by, + source_file=str(conf_path), + has_local_override=has_local, + ) + ) + + log.info("filters_listed", total=len(filters), active=sum(1 for f in filters if f.active)) + return FilterListResponse(filters=filters, total=len(filters)) + + +async def get_filter( + config_dir: str, + socket_path: str, + name: str, +) -> FilterConfig: + """Return a single filter from ``filter.d/`` with active/inactive status. + + Reads ``{config_dir}/filter.d/{name}.conf``, merges any ``.local`` + override, and enriches the parsed :class:`~app.models.config.FilterConfig` + with ``active``, ``used_by_jails``, ``source_file``, and + ``has_local_override``. + + Args: + config_dir: Absolute path to the fail2ban configuration directory. + socket_path: Path to the fail2ban Unix domain socket. + name: Filter base name (e.g. ``"sshd"`` or ``"sshd.conf"``). + + Returns: + :class:`~app.models.config.FilterConfig` with status fields populated. + + Raises: + FilterNotFoundError: If no ``{name}.conf`` file exists in + ``filter.d/``. + """ + # Normalise — strip extension if provided. + base_name = name[:-5] if name.endswith(".conf") else name + + filter_d = Path(config_dir) / "filter.d" + conf_path = filter_d / f"{base_name}.conf" + local_path = conf_path.with_suffix(".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") + 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 + + content, has_local = await loop.run_in_executor(None, _read) + + cfg = conffile_parser.parse_filter_file( + content, name=base_name, filename=f"{base_name}.conf" + ) + + all_jails_result, active_names = await asyncio.gather( + loop.run_in_executor(None, _parse_jails_sync, Path(config_dir)), + _get_active_jail_names(socket_path), + ) + all_jails, _source_files = all_jails_result + filter_to_jails = _build_filter_to_jails_map(all_jails, active_names) + + used_by = sorted(filter_to_jails.get(base_name, [])) + log.info("filter_fetched", name=base_name, active=len(used_by) > 0) + return FilterConfig( + name=cfg.name, + filename=cfg.filename, + before=cfg.before, + after=cfg.after, + variables=cfg.variables, + prefregex=cfg.prefregex, + failregex=cfg.failregex, + ignoreregex=cfg.ignoreregex, + maxlines=cfg.maxlines, + datepattern=cfg.datepattern, + journalmatch=cfg.journalmatch, + active=len(used_by) > 0, + used_by_jails=used_by, + source_file=str(conf_path), + has_local_override=has_local, + ) diff --git a/backend/tests/test_routers/test_config.py b/backend/tests/test_routers/test_config.py index a143123..e1bc693 100644 --- a/backend/tests/test_routers/test_config.py +++ b/backend/tests/test_routers/test_config.py @@ -13,6 +13,7 @@ from app.config import Settings from app.db import init_db from app.main import create_app from app.models.config import ( + FilterConfig, GlobalConfigResponse, JailConfig, JailConfigListResponse, @@ -817,3 +818,140 @@ class TestDeactivateJail: base_url="http://test", ).post("/api/config/jails/sshd/deactivate") assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# GET /api/config/filters +# --------------------------------------------------------------------------- + + +def _make_filter_config(name: str, active: bool = False) -> FilterConfig: + return FilterConfig( + name=name, + filename=f"{name}.conf", + before=None, + after=None, + variables={}, + prefregex=None, + failregex=[], + ignoreregex=[], + maxlines=None, + datepattern=None, + journalmatch=None, + active=active, + used_by_jails=[name] if active else [], + source_file=f"/etc/fail2ban/filter.d/{name}.conf", + has_local_override=False, + ) + + +class TestListFilters: + """Tests for ``GET /api/config/filters``.""" + + async def test_200_returns_filter_list(self, config_client: AsyncClient) -> None: + """GET /api/config/filters returns 200 with FilterListResponse.""" + from app.models.config import FilterListResponse + + mock_response = FilterListResponse( + filters=[_make_filter_config("sshd", active=True)], + total=1, + ) + with patch( + "app.routers.config.config_file_service.list_filters", + AsyncMock(return_value=mock_response), + ): + resp = await config_client.get("/api/config/filters") + + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["filters"][0]["name"] == "sshd" + assert data["filters"][0]["active"] is True + + async def test_200_empty_filter_list(self, config_client: AsyncClient) -> None: + """GET /api/config/filters returns 200 with empty list when no filters found.""" + from app.models.config import FilterListResponse + + with patch( + "app.routers.config.config_file_service.list_filters", + AsyncMock(return_value=FilterListResponse(filters=[], total=0)), + ): + resp = await config_client.get("/api/config/filters") + + assert resp.status_code == 200 + assert resp.json()["total"] == 0 + assert resp.json()["filters"] == [] + + async def test_active_filters_sorted_before_inactive( + self, config_client: AsyncClient + ) -> None: + """GET /api/config/filters returns active filters before inactive ones.""" + from app.models.config import FilterListResponse + + mock_response = FilterListResponse( + filters=[ + _make_filter_config("nginx", active=False), + _make_filter_config("sshd", active=True), + ], + total=2, + ) + with patch( + "app.routers.config.config_file_service.list_filters", + AsyncMock(return_value=mock_response), + ): + resp = await config_client.get("/api/config/filters") + + data = resp.json() + assert data["filters"][0]["name"] == "sshd" # active first + assert data["filters"][1]["name"] == "nginx" # inactive second + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """GET /api/config/filters returns 401 without a valid session.""" + resp = await AsyncClient( + transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).get("/api/config/filters") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# GET /api/config/filters/{name} +# --------------------------------------------------------------------------- + + +class TestGetFilter: + """Tests for ``GET /api/config/filters/{name}``.""" + + async def test_200_returns_filter(self, config_client: AsyncClient) -> None: + """GET /api/config/filters/sshd returns 200 with FilterConfig.""" + with patch( + "app.routers.config.config_file_service.get_filter", + AsyncMock(return_value=_make_filter_config("sshd")), + ): + resp = await config_client.get("/api/config/filters/sshd") + + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "sshd" + assert "failregex" in data + assert "active" in data + + async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None: + """GET /api/config/filters/missing returns 404.""" + from app.services.config_file_service import FilterNotFoundError + + with patch( + "app.routers.config.config_file_service.get_filter", + AsyncMock(side_effect=FilterNotFoundError("missing")), + ): + resp = await config_client.get("/api/config/filters/missing") + + assert resp.status_code == 404 + + async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None: + """GET /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", + ).get("/api/config/filters/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 cede4f3..e6759ab 100644 --- a/backend/tests/test_routers/test_file_config.py +++ b/backend/tests/test_routers/test_file_config.py @@ -218,45 +218,24 @@ class TestSetJailConfigEnabled: # --------------------------------------------------------------------------- -# GET /api/config/filters +# GET /api/config/filters/{name}/raw # --------------------------------------------------------------------------- -class TestListFilterFiles: - async def test_200_returns_files(self, file_config_client: AsyncClient) -> None: - with patch( - "app.routers.file_config.file_config_service.list_filter_files", - AsyncMock(return_value=_conf_files_resp()), - ): - resp = await file_config_client.get("/api/config/filters") +class TestGetFilterFileRaw: + """Tests for the renamed ``GET /api/config/filters/{name}/raw`` endpoint. - assert resp.status_code == 200 - assert resp.json()["total"] == 1 + The simple list (``GET /api/config/filters``) and the structured detail + (``GET /api/config/filters/{name}``) are now served by the config router. + This endpoint returns the raw file content only. + """ - async def test_503_on_config_dir_error( - self, file_config_client: AsyncClient - ) -> None: - with patch( - "app.routers.file_config.file_config_service.list_filter_files", - AsyncMock(side_effect=ConfigDirError("x")), - ): - resp = await file_config_client.get("/api/config/filters") - - assert resp.status_code == 503 - - -# --------------------------------------------------------------------------- -# GET /api/config/filters/{name} -# --------------------------------------------------------------------------- - - -class TestGetFilterFile: async def test_200_returns_content(self, file_config_client: AsyncClient) -> None: with patch( "app.routers.file_config.file_config_service.get_filter_file", AsyncMock(return_value=_conf_file_content("nginx")), ): - resp = await file_config_client.get("/api/config/filters/nginx") + resp = await file_config_client.get("/api/config/filters/nginx/raw") assert resp.status_code == 200 assert resp.json()["name"] == "nginx" @@ -266,7 +245,7 @@ class TestGetFilterFile: "app.routers.file_config.file_config_service.get_filter_file", AsyncMock(side_effect=ConfigFileNotFoundError("missing")), ): - resp = await file_config_client.get("/api/config/filters/missing") + resp = await file_config_client.get("/api/config/filters/missing/raw") assert resp.status_code == 404 diff --git a/backend/tests/test_services/test_config_file_service.py b/backend/tests/test_services/test_config_file_service.py index 37b4bd2..7e462fb 100644 --- a/backend/tests/test_services/test_config_file_service.py +++ b/backend/tests/test_services/test_config_file_service.py @@ -462,10 +462,9 @@ class TestActivateJail: patch( "app.services.config_file_service._get_active_jail_names", new=AsyncMock(return_value=set()), - ), + ),pytest.raises(JailNotFoundInConfigError) ): - with pytest.raises(JailNotFoundInConfigError): - await activate_jail(str(tmp_path), "/fake.sock", "nonexistent", req) + await activate_jail(str(tmp_path), "/fake.sock", "nonexistent", req) async def test_raises_already_active(self, tmp_path: Path) -> None: _write(tmp_path / "jail.conf", JAIL_CONF) @@ -476,10 +475,9 @@ class TestActivateJail: patch( "app.services.config_file_service._get_active_jail_names", new=AsyncMock(return_value={"sshd"}), - ), + ),pytest.raises(JailAlreadyActiveError) ): - with pytest.raises(JailAlreadyActiveError): - await activate_jail(str(tmp_path), "/fake.sock", "sshd", req) + await activate_jail(str(tmp_path), "/fake.sock", "sshd", req) async def test_raises_name_error_for_bad_name(self, tmp_path: Path) -> None: from app.models.config import ActivateJailRequest @@ -538,10 +536,9 @@ class TestDeactivateJail: patch( "app.services.config_file_service._get_active_jail_names", new=AsyncMock(return_value={"sshd"}), - ), + ),pytest.raises(JailNotFoundInConfigError) ): - with pytest.raises(JailNotFoundInConfigError): - await deactivate_jail(str(tmp_path), "/fake.sock", "nonexistent") + await deactivate_jail(str(tmp_path), "/fake.sock", "nonexistent") async def test_raises_already_inactive(self, tmp_path: Path) -> None: _write(tmp_path / "jail.conf", JAIL_CONF) @@ -549,11 +546,309 @@ class TestDeactivateJail: patch( "app.services.config_file_service._get_active_jail_names", new=AsyncMock(return_value=set()), - ), + ),pytest.raises(JailAlreadyInactiveError) ): - with pytest.raises(JailAlreadyInactiveError): - await deactivate_jail(str(tmp_path), "/fake.sock", "apache-auth") + await deactivate_jail(str(tmp_path), "/fake.sock", "apache-auth") async def test_raises_name_error(self, tmp_path: Path) -> None: with pytest.raises(JailNameError): await deactivate_jail(str(tmp_path), "/fake.sock", "a/b") + + +# --------------------------------------------------------------------------- +# _extract_filter_base_name +# --------------------------------------------------------------------------- + + +class TestExtractFilterBaseName: + def test_simple_name(self) -> None: + from app.services.config_file_service import _extract_filter_base_name + + assert _extract_filter_base_name("sshd") == "sshd" + + def test_name_with_mode(self) -> None: + from app.services.config_file_service import _extract_filter_base_name + + assert _extract_filter_base_name("sshd[mode=aggressive]") == "sshd" + + def test_name_with_variable_mode(self) -> None: + from app.services.config_file_service import _extract_filter_base_name + + assert _extract_filter_base_name("sshd[mode=%(mode)s]") == "sshd" + + def test_whitespace_stripped(self) -> None: + from app.services.config_file_service import _extract_filter_base_name + + assert _extract_filter_base_name(" nginx ") == "nginx" + + def test_empty_string(self) -> None: + from app.services.config_file_service import _extract_filter_base_name + + assert _extract_filter_base_name("") == "" + + +# --------------------------------------------------------------------------- +# _build_filter_to_jails_map +# --------------------------------------------------------------------------- + + +class TestBuildFilterToJailsMap: + def test_active_jail_maps_to_filter(self) -> None: + from app.services.config_file_service import _build_filter_to_jails_map + + result = _build_filter_to_jails_map({"sshd": {"filter": "sshd"}}, {"sshd"}) + assert result == {"sshd": ["sshd"]} + + def test_inactive_jail_not_included(self) -> None: + from app.services.config_file_service import _build_filter_to_jails_map + + result = _build_filter_to_jails_map( + {"apache-auth": {"filter": "apache-auth"}}, set() + ) + assert result == {} + + def test_multiple_jails_sharing_filter(self) -> None: + from app.services.config_file_service import _build_filter_to_jails_map + + all_jails = { + "sshd": {"filter": "sshd"}, + "sshd-ddos": {"filter": "sshd"}, + } + result = _build_filter_to_jails_map(all_jails, {"sshd", "sshd-ddos"}) + assert sorted(result["sshd"]) == ["sshd", "sshd-ddos"] + + def test_mode_suffix_stripped(self) -> None: + from app.services.config_file_service import _build_filter_to_jails_map + + result = _build_filter_to_jails_map( + {"sshd": {"filter": "sshd[mode=aggressive]"}}, {"sshd"} + ) + assert "sshd" in result + + def test_missing_filter_key_falls_back_to_jail_name(self) -> None: + from app.services.config_file_service import _build_filter_to_jails_map + + # When jail has no "filter" key the code falls back to the jail name. + result = _build_filter_to_jails_map({"sshd": {}}, {"sshd"}) + assert "sshd" in result + + +# --------------------------------------------------------------------------- +# _parse_filters_sync +# --------------------------------------------------------------------------- + +_FILTER_CONF = """\ +[Definition] +failregex = ^Host: +ignoreregex = +""" + + +class TestParseFiltersSync: + def test_returns_empty_for_missing_dir(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_filters_sync + + result = _parse_filters_sync(tmp_path / "nonexistent") + assert result == [] + + def test_single_filter_returned(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_filters_sync + + filter_d = tmp_path / "filter.d" + _write(filter_d / "nginx.conf", _FILTER_CONF) + + result = _parse_filters_sync(filter_d) + + assert len(result) == 1 + name, filename, content, has_local = result[0] + assert name == "nginx" + assert filename == "nginx.conf" + assert "failregex" in content + assert has_local is False + + def test_local_override_detected(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_filters_sync + + filter_d = tmp_path / "filter.d" + _write(filter_d / "nginx.conf", _FILTER_CONF) + _write(filter_d / "nginx.local", "[Definition]\nignoreregex = ^safe\n") + + result = _parse_filters_sync(filter_d) + + _, _, _, has_local = result[0] + assert has_local is True + + def test_local_content_appended_to_content(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_filters_sync + + filter_d = tmp_path / "filter.d" + _write(filter_d / "nginx.conf", _FILTER_CONF) + _write(filter_d / "nginx.local", "[Definition]\n# local tweak\n") + + result = _parse_filters_sync(filter_d) + + _, _, content, _ = result[0] + assert "local tweak" in content + + def test_sorted_alphabetically(self, tmp_path: Path) -> None: + from app.services.config_file_service import _parse_filters_sync + + filter_d = tmp_path / "filter.d" + for name in ("zzz", "aaa", "mmm"): + _write(filter_d / f"{name}.conf", _FILTER_CONF) + + result = _parse_filters_sync(filter_d) + + names = [r[0] for r in result] + assert names == ["aaa", "mmm", "zzz"] + + +# --------------------------------------------------------------------------- +# list_filters +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestListFilters: + async def test_returns_all_filters(self, tmp_path: Path) -> None: + from app.services.config_file_service import list_filters + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + _write(filter_d / "nginx.conf", _FILTER_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await list_filters(str(tmp_path), "/fake.sock") + + assert result.total == 2 + names = {f.name for f in result.filters} + assert "sshd" in names + assert "nginx" in names + + async def test_active_flag_set_for_used_filter(self, tmp_path: Path) -> None: + from app.services.config_file_service import list_filters + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + _write(tmp_path / "jail.conf", JAIL_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value={"sshd"}), + ): + result = await list_filters(str(tmp_path), "/fake.sock") + + sshd = next(f for f in result.filters if f.name == "sshd") + assert sshd.active is True + assert "sshd" in sshd.used_by_jails + + async def test_inactive_filter_not_marked_active(self, tmp_path: Path) -> None: + from app.services.config_file_service import list_filters + + filter_d = tmp_path / "filter.d" + _write(filter_d / "nginx.conf", _FILTER_CONF) + _write(tmp_path / "jail.conf", JAIL_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value={"sshd"}), + ): + result = await list_filters(str(tmp_path), "/fake.sock") + + nginx = next(f for f in result.filters if f.name == "nginx") + assert nginx.active is False + assert nginx.used_by_jails == [] + + async def test_has_local_override_detected(self, tmp_path: Path) -> None: + from app.services.config_file_service import list_filters + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + _write(filter_d / "sshd.local", "[Definition]\n") + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await list_filters(str(tmp_path), "/fake.sock") + + sshd = next(f for f in result.filters if f.name == "sshd") + assert sshd.has_local_override is True + + async def test_empty_filter_d_returns_empty(self, tmp_path: Path) -> None: + from app.services.config_file_service import list_filters + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await list_filters(str(tmp_path), "/fake.sock") + + assert result.filters == [] + assert result.total == 0 + + +# --------------------------------------------------------------------------- +# get_filter +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestGetFilter: + async def test_returns_filter_config(self, tmp_path: Path) -> None: + from app.services.config_file_service import get_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + _write(tmp_path / "jail.conf", JAIL_CONF) + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value={"sshd"}), + ): + result = await get_filter(str(tmp_path), "/fake.sock", "sshd") + + assert result.name == "sshd" + assert result.active is True + assert "sshd" in result.used_by_jails + + async def test_accepts_conf_extension(self, tmp_path: Path) -> None: + from app.services.config_file_service import get_filter + + 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()), + ): + result = await get_filter(str(tmp_path), "/fake.sock", "sshd.conf") + + assert result.name == "sshd" + + async def test_raises_filter_not_found(self, tmp_path: Path) -> None: + from app.services.config_file_service import FilterNotFoundError, get_filter + + 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_has_local_override_detected(self, tmp_path: Path) -> None: + from app.services.config_file_service import get_filter + + filter_d = tmp_path / "filter.d" + _write(filter_d / "sshd.conf", _FILTER_CONF) + _write(filter_d / "sshd.local", "[Definition]\n") + + with patch( + "app.services.config_file_service._get_active_jail_names", + new=AsyncMock(return_value=set()), + ): + result = await get_filter(str(tmp_path), "/fake.sock", "sshd") + + assert result.has_local_override is True diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 7396385..7933f3d 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -15,6 +15,7 @@ import type { ConfFileUpdateRequest, FilterConfig, FilterConfigUpdate, + FilterListResponse, GlobalConfig, GlobalConfigUpdate, InactiveJailListResponse, @@ -200,15 +201,27 @@ export async function setJailConfigFileEnabled( } // --------------------------------------------------------------------------- -// Filter files (Task 4d) +// Filter files (Task 4d) — raw file management // --------------------------------------------------------------------------- +/** + * Return a lightweight name/filename list of all filter files. + * + * Internally calls the enriched ``GET /config/filters`` endpoint (which also + * returns active-status data) and maps the result down to the simpler + * ``ConfFilesResponse`` shape expected by the raw-file editor and export tab. + */ export async function fetchFilterFiles(): Promise { - return get(ENDPOINTS.configFilters); + const result = await fetchFilters(); + return { + files: result.filters.map((f) => ({ name: f.name, filename: f.filename })), + total: result.total, + }; } +/** Fetch the raw content of a filter definition file for the raw editor. */ export async function fetchFilterFile(name: string): Promise { - return get(ENDPOINTS.configFilter(name)); + return get(ENDPOINTS.configFilterRaw(name)); } export async function updateFilterFile( @@ -264,6 +277,32 @@ export async function updateParsedFilter( await put(ENDPOINTS.configFilterParsed(name), update); } +// --------------------------------------------------------------------------- +// Filter discovery with active/inactive status (Task 2.1) +// --------------------------------------------------------------------------- + +/** + * Fetch all filters from filter.d/ with active/inactive status. + * + * Active filters (those referenced by running jails) are returned first, + * followed by inactive ones. Both groups are sorted alphabetically. + * + * @returns FilterListResponse with all discovered filters and status. + */ +export async function fetchFilters(): Promise { + return get(ENDPOINTS.configFilters); +} + +/** + * Fetch full parsed detail for a single filter with active/inactive status. + * + * @param name - Filter base name (e.g. "sshd" or "sshd.conf"). + * @returns FilterConfig with active, used_by_jails, source_file populated. + */ +export async function fetchFilter(name: string): Promise { + return get(ENDPOINTS.configFilter(name)); +} + // --------------------------------------------------------------------------- // Parsed action config (Task 3.2) // --------------------------------------------------------------------------- diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 6483802..2528eae 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -85,6 +85,7 @@ export const ENDPOINTS = { `/config/jail-files/${encodeURIComponent(filename)}/parsed`, configFilters: "/config/filters", configFilter: (name: string): string => `/config/filters/${encodeURIComponent(name)}`, + configFilterRaw: (name: string): string => `/config/filters/${encodeURIComponent(name)}/raw`, configFilterParsed: (name: string): string => `/config/filters/${encodeURIComponent(name)}/parsed`, configActions: "/config/actions", diff --git a/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx b/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx index b18ebbe..bad4733 100644 --- a/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx +++ b/frontend/src/components/__tests__/ConfigPageLogPath.test.tsx @@ -99,12 +99,23 @@ vi.mock("../../api/config", () => ({ fetchFilterFile: vi.fn(), updateFilterFile: vi.fn(), createFilterFile: vi.fn(), + fetchFilters: vi.fn().mockResolvedValue({ filters: [], total: 0 }), + fetchFilter: vi.fn(), fetchActionFiles: mockFetchActionFiles, fetchActionFile: vi.fn(), updateActionFile: vi.fn(), createActionFile: vi.fn(), previewLog: vi.fn(), testRegex: vi.fn(), + fetchInactiveJails: vi.fn().mockResolvedValue({ jails: [], total: 0 }), + activateJail: vi.fn(), + deactivateJail: vi.fn(), + fetchParsedFilter: vi.fn(), + updateParsedFilter: vi.fn(), + fetchParsedAction: vi.fn(), + updateParsedAction: vi.fn(), + fetchParsedJailFile: vi.fn(), + updateParsedJailFile: vi.fn(), })); vi.mock("../../api/jails", () => ({ diff --git a/frontend/src/components/config/__tests__/FilterForm.test.tsx b/frontend/src/components/config/__tests__/FilterForm.test.tsx index ad985f0..e2d1e3e 100644 --- a/frontend/src/components/config/__tests__/FilterForm.test.tsx +++ b/frontend/src/components/config/__tests__/FilterForm.test.tsx @@ -23,6 +23,10 @@ const mockConfig: FilterConfig = { maxlines: null, datepattern: null, journalmatch: null, + active: false, + used_by_jails: [], + source_file: "/etc/fail2ban/filter.d/sshd.conf", + has_local_override: false, }; function renderForm(name: string) { diff --git a/frontend/src/hooks/useConfigActiveStatus.ts b/frontend/src/hooks/useConfigActiveStatus.ts index 3a7d1d2..d7f091f 100644 --- a/frontend/src/hooks/useConfigActiveStatus.ts +++ b/frontend/src/hooks/useConfigActiveStatus.ts @@ -12,11 +12,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { fetchJails } from "../api/jails"; -import { - fetchActionFiles, - fetchFilterFiles, - fetchJailConfigs, -} from "../api/config"; +import { fetchJailConfigs } from "../api/config"; import type { JailConfig } from "../types/config"; import type { JailSummary } from "../types/jail"; @@ -44,8 +40,10 @@ export interface UseConfigActiveStatusResult { // --------------------------------------------------------------------------- /** - * Fetch jails, jail configs, filter files, and action files in parallel and - * derive active-status sets for each config type. + * Fetch jails and jail configs, then derive active-status sets for each + * config type. Active status is computed from live jail data; filter and + * action files are not fetched directly because their active state is already + * available via {@link fetchFilters} / {@link fetchActions}. * * @returns Active-status sets, loading flag, error, and refresh function. */ @@ -69,10 +67,8 @@ export function useConfigActiveStatus(): UseConfigActiveStatusResult { Promise.all([ fetchJails(), fetchJailConfigs(), - fetchFilterFiles(), - fetchActionFiles(), ]) - .then(([jailsResp, configsResp, _filterResp, _actionResp]) => { + .then(([jailsResp, configsResp]) => { if (ctrl.signal.aborted) return; const summaries: JailSummary[] = jailsResp.jails; diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index 0c8e5d0..962dd1c 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -274,6 +274,29 @@ export interface FilterConfig { datepattern: string | null; /** journalmatch, or null. */ journalmatch: string | null; + /** + * True when this filter is referenced by at least one currently running jail. + * Defaults to false when the status was not computed (e.g. /parsed endpoint). + */ + active: boolean; + /** + * Names of currently enabled jails that reference this filter. + * Empty when active is false. + */ + used_by_jails: string[]; + /** Absolute path to the .conf source file. Empty string when not computed. */ + source_file: string; + /** True when a .local override file exists alongside the base .conf. */ + has_local_override: boolean; +} + +/** + * Response for GET /api/config/filters. + * Lists all discovered filters with active/inactive status. + */ +export interface FilterListResponse { + filters: FilterConfig[]; + total: number; } /**