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:
@@ -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.
|
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.
|
**Goal:** Enumerate all filter config files and mark each as active or inactive.
|
||||||
|
|
||||||
|
|||||||
@@ -275,7 +275,15 @@ class MapColorThresholdsUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class FilterConfig(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)
|
model_config = ConfigDict(strict=True)
|
||||||
|
|
||||||
@@ -314,6 +322,33 @@ class FilterConfig(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Systemd journal match expression.",
|
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):
|
class FilterConfigUpdate(BaseModel):
|
||||||
@@ -335,6 +370,21 @@ class FilterConfigUpdate(BaseModel):
|
|||||||
journalmatch: str | None = Field(default=None)
|
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
|
# Parsed action file models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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/regex-test`` — test a regex pattern
|
||||||
* ``POST /api/config/jails/{name}/logpath`` — add a log path to a jail
|
* ``POST /api/config/jails/{name}/logpath`` — add a log path to a jail
|
||||||
* ``POST /api/config/preview-log`` — preview log matches
|
* ``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
|
from __future__ import annotations
|
||||||
@@ -27,6 +29,8 @@ from app.dependencies import AuthDep
|
|||||||
from app.models.config import (
|
from app.models.config import (
|
||||||
ActivateJailRequest,
|
ActivateJailRequest,
|
||||||
AddLogPathRequest,
|
AddLogPathRequest,
|
||||||
|
FilterConfig,
|
||||||
|
FilterListResponse,
|
||||||
GlobalConfigResponse,
|
GlobalConfigResponse,
|
||||||
GlobalConfigUpdate,
|
GlobalConfigUpdate,
|
||||||
InactiveJailListResponse,
|
InactiveJailListResponse,
|
||||||
@@ -44,6 +48,7 @@ from app.models.config import (
|
|||||||
from app.services import config_file_service, config_service, jail_service
|
from app.services import config_file_service, config_service, jail_service
|
||||||
from app.services.config_file_service import (
|
from app.services.config_file_service import (
|
||||||
ConfigWriteError,
|
ConfigWriteError,
|
||||||
|
FilterNotFoundError,
|
||||||
JailAlreadyActiveError,
|
JailAlreadyActiveError,
|
||||||
JailAlreadyInactiveError,
|
JailAlreadyInactiveError,
|
||||||
JailNameError,
|
JailNameError,
|
||||||
@@ -646,3 +651,83 @@ async def deactivate_jail(
|
|||||||
) from exc
|
) from exc
|
||||||
except Fail2BanConnectionError as exc:
|
except Fail2BanConnectionError as exc:
|
||||||
raise _bad_gateway(exc) from 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
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ Endpoints:
|
|||||||
* ``GET /api/config/jail-files/{filename}`` — get one jail config file (with content)
|
* ``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}`` — overwrite a jail config file
|
||||||
* ``PUT /api/config/jail-files/{filename}/enabled`` — enable/disable a jail config
|
* ``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}/raw`` — get one filter file raw content
|
||||||
* ``GET /api/config/filters/{name}`` — get one filter file (with content)
|
|
||||||
* ``PUT /api/config/filters/{name}`` — update a filter file
|
* ``PUT /api/config/filters/{name}`` — update a filter file
|
||||||
* ``POST /api/config/filters`` — create a new 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
|
* ``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
|
* ``POST /api/config/actions`` — create a new action file
|
||||||
* ``GET /api/config/actions/{name}/parsed`` — parse an action file into a structured model
|
* ``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
|
* ``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
|
from __future__ import annotations
|
||||||
@@ -303,41 +307,20 @@ async def create_jail_config_file(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/filters",
|
"/filters/{name}/raw",
|
||||||
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}",
|
|
||||||
response_model=ConfFileContent,
|
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,
|
request: Request,
|
||||||
_auth: AuthDep,
|
_auth: AuthDep,
|
||||||
name: _NamePath,
|
name: _NamePath,
|
||||||
) -> ConfFileContent:
|
) -> 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:
|
Args:
|
||||||
request: Incoming request.
|
request: Incoming request.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import configparser
|
import configparser
|
||||||
|
import contextlib
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -32,11 +33,13 @@ import structlog
|
|||||||
|
|
||||||
from app.models.config import (
|
from app.models.config import (
|
||||||
ActivateJailRequest,
|
ActivateJailRequest,
|
||||||
|
FilterConfig,
|
||||||
|
FilterListResponse,
|
||||||
InactiveJail,
|
InactiveJail,
|
||||||
InactiveJailListResponse,
|
InactiveJailListResponse,
|
||||||
JailActivationResponse,
|
JailActivationResponse,
|
||||||
)
|
)
|
||||||
from app.services import jail_service
|
from app.services import conffile_parser, jail_service
|
||||||
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError
|
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError
|
||||||
|
|
||||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||||
@@ -462,10 +465,8 @@ def _write_local_override_sync(
|
|||||||
os.replace(tmp_name, local_path)
|
os.replace(tmp_name, local_path)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
# Clean up temp file if rename failed.
|
# 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
|
os.unlink(tmp_name) # noqa: F821 — only reachable when tmp_name is set
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
raise ConfigWriteError(
|
raise ConfigWriteError(
|
||||||
f"Failed to write {local_path}: {exc}"
|
f"Failed to write {local_path}: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
@@ -664,3 +665,295 @@ async def deactivate_jail(
|
|||||||
active=False,
|
active=False,
|
||||||
message=f"Jail {name!r} deactivated successfully.",
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.config import Settings
|
|||||||
from app.db import init_db
|
from app.db import init_db
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
from app.models.config import (
|
from app.models.config import (
|
||||||
|
FilterConfig,
|
||||||
GlobalConfigResponse,
|
GlobalConfigResponse,
|
||||||
JailConfig,
|
JailConfig,
|
||||||
JailConfigListResponse,
|
JailConfigListResponse,
|
||||||
@@ -817,3 +818,140 @@ class TestDeactivateJail:
|
|||||||
base_url="http://test",
|
base_url="http://test",
|
||||||
).post("/api/config/jails/sshd/deactivate")
|
).post("/api/config/jails/sshd/deactivate")
|
||||||
assert resp.status_code == 401
|
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
|
||||||
|
|||||||
@@ -218,45 +218,24 @@ class TestSetJailConfigEnabled:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /api/config/filters
|
# GET /api/config/filters/{name}/raw
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestListFilterFiles:
|
class TestGetFilterFileRaw:
|
||||||
async def test_200_returns_files(self, file_config_client: AsyncClient) -> None:
|
"""Tests for the renamed ``GET /api/config/filters/{name}/raw`` endpoint.
|
||||||
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")
|
|
||||||
|
|
||||||
assert resp.status_code == 200
|
The simple list (``GET /api/config/filters``) and the structured detail
|
||||||
assert resp.json()["total"] == 1
|
(``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:
|
async def test_200_returns_content(self, file_config_client: AsyncClient) -> None:
|
||||||
with patch(
|
with patch(
|
||||||
"app.routers.file_config.file_config_service.get_filter_file",
|
"app.routers.file_config.file_config_service.get_filter_file",
|
||||||
AsyncMock(return_value=_conf_file_content("nginx")),
|
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.status_code == 200
|
||||||
assert resp.json()["name"] == "nginx"
|
assert resp.json()["name"] == "nginx"
|
||||||
@@ -266,7 +245,7 @@ class TestGetFilterFile:
|
|||||||
"app.routers.file_config.file_config_service.get_filter_file",
|
"app.routers.file_config.file_config_service.get_filter_file",
|
||||||
AsyncMock(side_effect=ConfigFileNotFoundError("missing")),
|
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
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|||||||
@@ -462,10 +462,9 @@ class TestActivateJail:
|
|||||||
patch(
|
patch(
|
||||||
"app.services.config_file_service._get_active_jail_names",
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
new=AsyncMock(return_value=set()),
|
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:
|
async def test_raises_already_active(self, tmp_path: Path) -> None:
|
||||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
@@ -476,10 +475,9 @@ class TestActivateJail:
|
|||||||
patch(
|
patch(
|
||||||
"app.services.config_file_service._get_active_jail_names",
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
new=AsyncMock(return_value={"sshd"}),
|
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:
|
async def test_raises_name_error_for_bad_name(self, tmp_path: Path) -> None:
|
||||||
from app.models.config import ActivateJailRequest
|
from app.models.config import ActivateJailRequest
|
||||||
@@ -538,10 +536,9 @@ class TestDeactivateJail:
|
|||||||
patch(
|
patch(
|
||||||
"app.services.config_file_service._get_active_jail_names",
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
new=AsyncMock(return_value={"sshd"}),
|
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:
|
async def test_raises_already_inactive(self, tmp_path: Path) -> None:
|
||||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||||
@@ -549,11 +546,309 @@ class TestDeactivateJail:
|
|||||||
patch(
|
patch(
|
||||||
"app.services.config_file_service._get_active_jail_names",
|
"app.services.config_file_service._get_active_jail_names",
|
||||||
new=AsyncMock(return_value=set()),
|
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:
|
async def test_raises_name_error(self, tmp_path: Path) -> None:
|
||||||
with pytest.raises(JailNameError):
|
with pytest.raises(JailNameError):
|
||||||
await deactivate_jail(str(tmp_path), "/fake.sock", "a/b")
|
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: <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
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type {
|
|||||||
ConfFileUpdateRequest,
|
ConfFileUpdateRequest,
|
||||||
FilterConfig,
|
FilterConfig,
|
||||||
FilterConfigUpdate,
|
FilterConfigUpdate,
|
||||||
|
FilterListResponse,
|
||||||
GlobalConfig,
|
GlobalConfig,
|
||||||
GlobalConfigUpdate,
|
GlobalConfigUpdate,
|
||||||
InactiveJailListResponse,
|
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<ConfFilesResponse> {
|
export async function fetchFilterFiles(): Promise<ConfFilesResponse> {
|
||||||
return get<ConfFilesResponse>(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<ConfFileContent> {
|
export async function fetchFilterFile(name: string): Promise<ConfFileContent> {
|
||||||
return get<ConfFileContent>(ENDPOINTS.configFilter(name));
|
return get<ConfFileContent>(ENDPOINTS.configFilterRaw(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateFilterFile(
|
export async function updateFilterFile(
|
||||||
@@ -264,6 +277,32 @@ export async function updateParsedFilter(
|
|||||||
await put<undefined>(ENDPOINTS.configFilterParsed(name), update);
|
await put<undefined>(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<FilterListResponse> {
|
||||||
|
return get<FilterListResponse>(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<FilterConfig> {
|
||||||
|
return get<FilterConfig>(ENDPOINTS.configFilter(name));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Parsed action config (Task 3.2)
|
// Parsed action config (Task 3.2)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export const ENDPOINTS = {
|
|||||||
`/config/jail-files/${encodeURIComponent(filename)}/parsed`,
|
`/config/jail-files/${encodeURIComponent(filename)}/parsed`,
|
||||||
configFilters: "/config/filters",
|
configFilters: "/config/filters",
|
||||||
configFilter: (name: string): string => `/config/filters/${encodeURIComponent(name)}`,
|
configFilter: (name: string): string => `/config/filters/${encodeURIComponent(name)}`,
|
||||||
|
configFilterRaw: (name: string): string => `/config/filters/${encodeURIComponent(name)}/raw`,
|
||||||
configFilterParsed: (name: string): string =>
|
configFilterParsed: (name: string): string =>
|
||||||
`/config/filters/${encodeURIComponent(name)}/parsed`,
|
`/config/filters/${encodeURIComponent(name)}/parsed`,
|
||||||
configActions: "/config/actions",
|
configActions: "/config/actions",
|
||||||
|
|||||||
@@ -99,12 +99,23 @@ vi.mock("../../api/config", () => ({
|
|||||||
fetchFilterFile: vi.fn(),
|
fetchFilterFile: vi.fn(),
|
||||||
updateFilterFile: vi.fn(),
|
updateFilterFile: vi.fn(),
|
||||||
createFilterFile: vi.fn(),
|
createFilterFile: vi.fn(),
|
||||||
|
fetchFilters: vi.fn().mockResolvedValue({ filters: [], total: 0 }),
|
||||||
|
fetchFilter: vi.fn(),
|
||||||
fetchActionFiles: mockFetchActionFiles,
|
fetchActionFiles: mockFetchActionFiles,
|
||||||
fetchActionFile: vi.fn(),
|
fetchActionFile: vi.fn(),
|
||||||
updateActionFile: vi.fn(),
|
updateActionFile: vi.fn(),
|
||||||
createActionFile: vi.fn(),
|
createActionFile: vi.fn(),
|
||||||
previewLog: vi.fn(),
|
previewLog: vi.fn(),
|
||||||
testRegex: 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", () => ({
|
vi.mock("../../api/jails", () => ({
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ const mockConfig: FilterConfig = {
|
|||||||
maxlines: null,
|
maxlines: null,
|
||||||
datepattern: null,
|
datepattern: null,
|
||||||
journalmatch: null,
|
journalmatch: null,
|
||||||
|
active: false,
|
||||||
|
used_by_jails: [],
|
||||||
|
source_file: "/etc/fail2ban/filter.d/sshd.conf",
|
||||||
|
has_local_override: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderForm(name: string) {
|
function renderForm(name: string) {
|
||||||
|
|||||||
@@ -12,11 +12,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { fetchJails } from "../api/jails";
|
import { fetchJails } from "../api/jails";
|
||||||
import {
|
import { fetchJailConfigs } from "../api/config";
|
||||||
fetchActionFiles,
|
|
||||||
fetchFilterFiles,
|
|
||||||
fetchJailConfigs,
|
|
||||||
} from "../api/config";
|
|
||||||
import type { JailConfig } from "../types/config";
|
import type { JailConfig } from "../types/config";
|
||||||
import type { JailSummary } from "../types/jail";
|
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
|
* Fetch jails and jail configs, then derive active-status sets for each
|
||||||
* derive active-status sets for each config type.
|
* 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.
|
* @returns Active-status sets, loading flag, error, and refresh function.
|
||||||
*/
|
*/
|
||||||
@@ -69,10 +67,8 @@ export function useConfigActiveStatus(): UseConfigActiveStatusResult {
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
fetchJails(),
|
fetchJails(),
|
||||||
fetchJailConfigs(),
|
fetchJailConfigs(),
|
||||||
fetchFilterFiles(),
|
|
||||||
fetchActionFiles(),
|
|
||||||
])
|
])
|
||||||
.then(([jailsResp, configsResp, _filterResp, _actionResp]) => {
|
.then(([jailsResp, configsResp]) => {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
|
|
||||||
const summaries: JailSummary[] = jailsResp.jails;
|
const summaries: JailSummary[] = jailsResp.jails;
|
||||||
|
|||||||
@@ -274,6 +274,29 @@ export interface FilterConfig {
|
|||||||
datepattern: string | null;
|
datepattern: string | null;
|
||||||
/** journalmatch, or null. */
|
/** journalmatch, or null. */
|
||||||
journalmatch: string | 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user