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:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user