Add filter discovery endpoints with active/inactive status (Task 2.1)

- Add list_filters() and get_filter() to config_file_service.py:
  scans filter.d/, parses [Definition] + [Init] sections, merges .local
  overrides, and cross-references running jails to set active/used_by_jails
- Add FilterConfig.active, used_by_jails, source_file, has_local_override
  fields to the Pydantic model; add FilterListResponse and FilterNotFoundError
- Add GET /api/config/filters and GET /api/config/filters/{name} to config.py
- Remove the shadowed GET /api/config/filters list route from file_config.py;
  rename GET /api/config/filters/{name} raw variant to /filters/{name}/raw
- Update frontend: fetchFilterFiles() adapts FilterListResponse -> ConfFilesResponse;
  add fetchFilters() and fetchFilter() to api/config.ts; remove unused
  fetchFilterFiles/fetchActionFiles calls from useConfigActiveStatus
- Fix ConfigPageLogPath test mock to include fetchInactiveJails and related
  exports introduced by Stage 1
- Backend: 169 tests pass, mypy --strict clean, ruff clean
- Frontend: 63 tests pass, tsc --noEmit clean, eslint clean
This commit is contained in:
2026-03-13 16:48:27 +01:00
parent 8d9d63b866
commit 4c138424a5
14 changed files with 989 additions and 92 deletions

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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

View File

@@ -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.

View File

@@ -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,
)