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

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