Add filter write/create/delete and jail-filter assign endpoints (Task 2.2)

- PUT /api/config/filters/{name}: updates failregex/ignoreregex/datepattern/
  journalmatch in filter.d/{name}.local; validates regex via re.compile();
  supports ?reload=true
- POST /api/config/filters: creates filter.d/{name}.local from FilterCreateRequest;
  returns 409 if file already exists
- DELETE /api/config/filters/{name}: deletes .local only; returns 409 for
  conf-only (readonly) filters
- POST /api/config/jails/{name}/filter: assigns filter to jail by writing
  'filter = {name}' to jail.d/{jail}.local; supports ?reload=true
- New models: FilterUpdateRequest, FilterCreateRequest, AssignFilterRequest
- New service helpers: _safe_filter_name, _validate_regex_patterns,
  _write_filter_local_sync, _set_jail_local_key_sync
- Fixed .local-only filter discovery in _parse_filters_sync (5-tuple return)
- Fixed get_filter extension stripping (.local is 6 chars not 5)
- Renamed file_config.py raw-write routes to /raw suffix
  (PUT /filters/{name}/raw, POST /filters/raw) to avoid routing conflicts
- Full service + router tests; all 930 tests pass
This commit is contained in:
2026-03-13 18:13:03 +01:00
parent 4c138424a5
commit e15ad8fb62
8 changed files with 1885 additions and 64 deletions

View File

@@ -144,25 +144,19 @@ fail2ban ships with a large collection of filter definitions in `filter.d/` (ove
---
### Task 2.2 — Backend: Activate and Edit Filters
### Task 2.2 — Backend: Activate and Edit Filters ✅ DONE
**Goal:** Allow users to assign a filter to a jail and edit filter regex patterns.
**Implemented:**
- `PUT /api/config/filters/{name}` — writes `failregex`, `ignoreregex`, `datepattern`, `journalmatch` changes to `filter.d/{name}.local`. Validates regex before writing. Supports `?reload=true`.
- `POST /api/config/filters` — creates `filter.d/{name}.local` from `FilterCreateRequest`. Returns 409 if file already exists.
- `DELETE /api/config/filters/{name}` — deletes `.local` only; refuses with 409 if filter is conf-only (readonly).
- `POST /api/config/jails/{name}/filter` — assigns a filter to a jail by writing `filter = {name}` to `jail.d/{jail}.local`. Supports `?reload=true`.
- All regex patterns validated via `re.compile()` before writing; invalid patterns return 422.
- New models: `FilterUpdateRequest`, `FilterCreateRequest`, `AssignFilterRequest`.
- Resolved routing conflict: `file_config.py` raw-write routes renamed to `PUT /filters/{name}/raw` and `POST /filters/raw` (consistent with existing `GET /filters/{name}/raw`).
- Full service + router tests added; all 930 tests pass.
**Details:**
- Add a `PUT /api/config/filters/{name}` endpoint that writes changes to a filter's `.local` override file. Accepts updated `failregex`, `ignoreregex`, `datepattern`, and `journalmatch` values. Never write to the `.conf` file directly.
- Add a `POST /api/config/jails/{jail_name}/filter` endpoint that changes which filter a jail uses. This writes `filter = {filter_name}` to the jail's `.local` config. Requires a reload for the change to take effect.
- Add a `POST /api/config/filters` endpoint to create a brand-new filter. Accepts a name and the filter definition fields. Creates a new file at `filter.d/{name}.local`.
- Add a `DELETE /api/config/filters/{name}` endpoint that deletes a custom filter's `.local` file. Refuse to delete files that are `.conf` (shipped defaults) — only user-created `.local` files without a corresponding `.conf` can be fully removed.
- Validate all regex patterns using Python's `re` module before writing them to disk. Return 422 with specific error details if any pattern is invalid.
- After any write operation, optionally trigger a fail2ban reload if the user requests it (query param `?reload=true`).
**Files to create/modify:**
- `app/services/config_file_service.py` (add filter write/create/delete methods)
- `app/routers/config.py` (add endpoints)
- `app/models/config.py` (add `FilterUpdateRequest`, `FilterCreateRequest`)
**References:** [Features.md §6](Features.md), [Backend-Development.md](Backend-Development.md)
**Files modified:** `app/models/config.py`, `app/services/config_file_service.py`, `app/routers/config.py`, `app/routers/file_config.py`, `tests/test_services/test_config_file_service.py`, `tests/test_routers/test_config.py`, `tests/test_routers/test_file_config.py`
---

View File

@@ -370,6 +370,79 @@ class FilterConfigUpdate(BaseModel):
journalmatch: str | None = Field(default=None)
class FilterUpdateRequest(BaseModel):
"""Payload for ``PUT /api/config/filters/{name}``.
Accepts only the user-editable ``[Definition]`` fields. Fields left as
``None`` are not changed; the existing value from the merged conf/local is
preserved.
"""
model_config = ConfigDict(strict=True)
failregex: list[str] | None = Field(
default=None,
description="Updated failure-detection regex patterns. ``None`` = keep existing.",
)
ignoreregex: list[str] | None = Field(
default=None,
description="Updated bypass-ban regex patterns. ``None`` = keep existing.",
)
datepattern: str | None = Field(
default=None,
description="Custom date-parsing pattern. ``None`` = keep existing.",
)
journalmatch: str | None = Field(
default=None,
description="Systemd journal match expression. ``None`` = keep existing.",
)
class FilterCreateRequest(BaseModel):
"""Payload for ``POST /api/config/filters``.
Creates a new user-defined filter at ``filter.d/{name}.local``.
"""
model_config = ConfigDict(strict=True)
name: str = Field(
...,
description="Filter base name (e.g. ``my-custom-filter``). Must not already exist in ``filter.d/``.",
)
failregex: list[str] = Field(
default_factory=list,
description="Failure-detection regex patterns.",
)
ignoreregex: list[str] = Field(
default_factory=list,
description="Regex patterns that bypass ban logic.",
)
prefregex: str | None = Field(
default=None,
description="Prefix regex prepended to every failregex.",
)
datepattern: str | None = Field(
default=None,
description="Custom date-parsing pattern.",
)
journalmatch: str | None = Field(
default=None,
description="Systemd journal match expression.",
)
class AssignFilterRequest(BaseModel):
"""Payload for ``POST /api/config/jails/{jail_name}/filter``."""
model_config = ConfigDict(strict=True)
filter_name: str = Field(
...,
description="Filter base name to assign to the jail (e.g. ``sshd``).",
)
class FilterListResponse(BaseModel):
"""Response for ``GET /api/config/filters``."""

View File

@@ -9,6 +9,7 @@ global settings, test regex patterns, add log paths, and preview log files.
* ``GET /api/config/jails/inactive`` — list all inactive jails
* ``POST /api/config/jails/{name}/activate`` — activate an inactive jail
* ``POST /api/config/jails/{name}/deactivate`` — deactivate an active jail
* ``POST /api/config/jails/{name}/filter`` — assign a filter to a jail
* ``GET /api/config/global`` — global fail2ban settings
* ``PUT /api/config/global`` — update global settings
* ``POST /api/config/reload`` — reload fail2ban
@@ -17,6 +18,9 @@ global settings, test regex patterns, add log paths, and preview log files.
* ``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
* ``PUT /api/config/filters/{name}`` — update a filter's .local override
* ``POST /api/config/filters`` — create a new user-defined filter
* ``DELETE /api/config/filters/{name}`` — delete a filter's .local file
"""
from __future__ import annotations
@@ -29,8 +33,11 @@ from app.dependencies import AuthDep
from app.models.config import (
ActivateJailRequest,
AddLogPathRequest,
AssignFilterRequest,
FilterConfig,
FilterCreateRequest,
FilterListResponse,
FilterUpdateRequest,
GlobalConfigResponse,
GlobalConfigUpdate,
InactiveJailListResponse,
@@ -48,7 +55,11 @@ from app.models.config import (
from app.services import config_file_service, config_service, jail_service
from app.services.config_file_service import (
ConfigWriteError,
FilterAlreadyExistsError,
FilterInvalidRegexError,
FilterNameError,
FilterNotFoundError,
FilterReadonlyError,
JailAlreadyActiveError,
JailAlreadyInactiveError,
JailNameError,
@@ -731,3 +742,229 @@ async def get_filter(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Filter not found: {name!r}",
) from None
# ---------------------------------------------------------------------------
# Filter write endpoints (Task 2.2)
# ---------------------------------------------------------------------------
_FilterNamePath = Annotated[
str,
Path(description="Filter base name, e.g. ``sshd`` or ``sshd.conf``."),
]
def _filter_not_found(name: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Filter not found: {name!r}",
)
@router.put(
"/filters/{name}",
response_model=FilterConfig,
summary="Update a filter's .local override with new regex/pattern values",
)
async def update_filter(
request: Request,
_auth: AuthDep,
name: _FilterNamePath,
body: FilterUpdateRequest,
reload: bool = Query(default=False, description="Reload fail2ban after writing."),
) -> FilterConfig:
"""Update a filter's ``[Definition]`` fields by writing a ``.local`` override.
All regex patterns are validated before writing. The original ``.conf``
file is never modified. Fields left as ``null`` in the request body are
kept at their current values.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Filter base name (with or without ``.conf`` extension).
body: Partial update — ``failregex``, ``ignoreregex``, ``datepattern``,
``journalmatch``.
reload: When ``true``, trigger a fail2ban reload after writing.
Returns:
Updated :class:`~app.models.config.FilterConfig`.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if the filter does not exist.
HTTPException: 422 if any regex pattern fails to compile.
HTTPException: 500 if writing the ``.local`` file fails.
"""
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.update_filter(
config_dir, socket_path, name, body, do_reload=reload
)
except FilterNameError as exc:
raise _bad_request(str(exc)) from exc
except FilterNotFoundError:
raise _filter_not_found(name) from None
except FilterInvalidRegexError as exc:
raise _unprocessable(str(exc)) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write filter override: {exc}",
) from exc
@router.post(
"/filters",
response_model=FilterConfig,
status_code=status.HTTP_201_CREATED,
summary="Create a new user-defined filter",
)
async def create_filter(
request: Request,
_auth: AuthDep,
body: FilterCreateRequest,
reload: bool = Query(default=False, description="Reload fail2ban after creating."),
) -> FilterConfig:
"""Create a new user-defined filter at ``filter.d/{name}.local``.
The filter is created as a ``.local`` file so it can coexist safely with
shipped ``.conf`` files. Returns 409 if a ``.conf`` or ``.local`` for
the requested name already exists.
Args:
request: FastAPI request object.
_auth: Validated session.
body: Filter name and ``[Definition]`` fields.
reload: When ``true``, trigger a fail2ban reload after creating.
Returns:
:class:`~app.models.config.FilterConfig` for the new filter.
Raises:
HTTPException: 400 if the name contains invalid characters.
HTTPException: 409 if the filter already exists.
HTTPException: 422 if any regex pattern is invalid.
HTTPException: 500 if writing fails.
"""
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.create_filter(
config_dir, socket_path, body, do_reload=reload
)
except FilterNameError as exc:
raise _bad_request(str(exc)) from exc
except FilterAlreadyExistsError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Filter {exc.name!r} already exists.",
) from exc
except FilterInvalidRegexError as exc:
raise _unprocessable(str(exc)) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write filter: {exc}",
) from exc
@router.delete(
"/filters/{name}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a user-created filter's .local file",
)
async def delete_filter(
request: Request,
_auth: AuthDep,
name: _FilterNamePath,
) -> None:
"""Delete a user-created filter's ``.local`` override file.
Shipped ``.conf``-only filters cannot be deleted (returns 409). When
both a ``.conf`` and a ``.local`` exist, only the ``.local`` is removed.
When only a ``.local`` exists (user-created filter), the file is deleted
entirely.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Filter base name.
Raises:
HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if the filter does not exist.
HTTPException: 409 if the filter is a shipped default (conf-only).
HTTPException: 500 if deletion fails.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
try:
await config_file_service.delete_filter(config_dir, name)
except FilterNameError as exc:
raise _bad_request(str(exc)) from exc
except FilterNotFoundError:
raise _filter_not_found(name) from None
except FilterReadonlyError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete filter: {exc}",
) from exc
@router.post(
"/jails/{name}/filter",
status_code=status.HTTP_204_NO_CONTENT,
summary="Assign a filter to a jail",
)
async def assign_filter_to_jail(
request: Request,
_auth: AuthDep,
name: _NamePath,
body: AssignFilterRequest,
reload: bool = Query(default=False, description="Reload fail2ban after assigning."),
) -> None:
"""Write ``filter = {filter_name}`` to the jail's ``.local`` config.
Existing keys in the jail's ``.local`` file are preserved. If the file
does not exist it is created.
Args:
request: FastAPI request object.
_auth: Validated session.
name: Jail name.
body: Filter to assign.
reload: When ``true``, trigger a fail2ban reload after writing.
Raises:
HTTPException: 400 if *name* or *filter_name* contain invalid characters.
HTTPException: 404 if the jail or filter does not exist.
HTTPException: 500 if writing fails.
"""
config_dir: str = request.app.state.settings.fail2ban_config_dir
socket_path: str = request.app.state.settings.fail2ban_socket
try:
await config_file_service.assign_filter_to_jail(
config_dir, socket_path, name, body, do_reload=reload
)
except (JailNameError, FilterNameError) as exc:
raise _bad_request(str(exc)) from exc
except JailNotFoundInConfigError:
raise _not_found(name) from None
except FilterNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Filter not found: {exc.name!r}",
) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write jail override: {exc}",
) from exc

View File

@@ -9,8 +9,8 @@ Endpoints:
* ``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/{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
* ``PUT /api/config/filters/{name}/raw`` — update a filter file (raw content)
* ``POST /api/config/filters/raw`` — create a new filter file (raw content)
* ``GET /api/config/filters/{name}/parsed`` — parse a filter file into a structured model
* ``PUT /api/config/filters/{name}/parsed`` — update a filter file from a structured model
* ``GET /api/config/actions`` — list all action files
@@ -23,7 +23,8 @@ Endpoints:
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``.
precedence. Raw-content read/write variants are at ``/filters/{name}/raw``
and ``POST /filters/raw``.
"""
from __future__ import annotations
@@ -347,9 +348,9 @@ async def get_filter_file_raw(
@router.put(
"/filters/{name}",
"/filters/{name}/raw",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update a filter definition file",
summary="Update a filter definition file (raw content)",
)
async def write_filter_file(
request: Request,
@@ -384,10 +385,10 @@ async def write_filter_file(
@router.post(
"/filters",
"/filters/raw",
status_code=status.HTTP_201_CREATED,
response_model=ConfFileContent,
summary="Create a new filter definition file",
summary="Create a new filter definition file (raw content)",
)
async def create_filter_file(
request: Request,

View File

@@ -23,6 +23,7 @@ from __future__ import annotations
import asyncio
import configparser
import contextlib
import io
import os
import re
import tempfile
@@ -33,8 +34,12 @@ import structlog
from app.models.config import (
ActivateJailRequest,
AssignFilterRequest,
FilterConfig,
FilterConfigUpdate,
FilterCreateRequest,
FilterListResponse,
FilterUpdateRequest,
InactiveJail,
InactiveJailListResponse,
JailActivationResponse,
@@ -117,6 +122,54 @@ class ConfigWriteError(Exception):
"""Raised when writing a ``.local`` override file fails."""
class FilterNameError(Exception):
"""Raised when a filter name contains invalid characters."""
class FilterAlreadyExistsError(Exception):
"""Raised when trying to create a filter whose ``.conf`` or ``.local`` already exists."""
def __init__(self, name: str) -> None:
"""Initialise with the filter name that already exists.
Args:
name: The filter name that already exists.
"""
self.name: str = name
super().__init__(f"Filter already exists: {name!r}")
class FilterReadonlyError(Exception):
"""Raised when trying to delete a shipped ``.conf`` filter with no ``.local`` override."""
def __init__(self, name: str) -> None:
"""Initialise with the filter name that cannot be deleted.
Args:
name: The filter name that is read-only (shipped ``.conf`` only).
"""
self.name: str = name
super().__init__(
f"Filter {name!r} is a shipped default (.conf only); "
"only user-created .local files can be deleted."
)
class FilterInvalidRegexError(Exception):
"""Raised when a regex pattern fails to compile."""
def __init__(self, pattern: str, error: str) -> None:
"""Initialise with the invalid pattern and the compile error.
Args:
pattern: The regex string that failed to compile.
error: The ``re.error`` message.
"""
self.pattern: str = pattern
self.error: str = error
super().__init__(f"Invalid regex {pattern!r}: {error}")
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
@@ -143,6 +196,27 @@ def _safe_jail_name(name: str) -> str:
return name
def _safe_filter_name(name: str) -> str:
"""Validate *name* and return it unchanged or raise :class:`FilterNameError`.
Args:
name: Proposed filter name (without extension).
Returns:
The name unchanged if valid.
Raises:
FilterNameError: If *name* contains unsafe characters.
"""
if not _SAFE_FILTER_NAME_RE.match(name):
raise FilterNameError(
f"Filter name {name!r} contains invalid characters. "
"Only alphanumeric characters, hyphens, underscores, and dots are "
"allowed; must start with an alphanumeric character."
)
return name
def _ordered_config_files(config_dir: Path) -> list[Path]:
"""Return all jail config files in fail2ban merge order.
@@ -479,6 +553,144 @@ def _write_local_override_sync(
)
def _validate_regex_patterns(patterns: list[str]) -> None:
"""Validate each pattern in *patterns* using Python's ``re`` module.
Args:
patterns: List of regex strings to validate.
Raises:
FilterInvalidRegexError: If any pattern fails to compile.
"""
for pattern in patterns:
try:
re.compile(pattern)
except re.error as exc:
raise FilterInvalidRegexError(pattern, str(exc)) from exc
def _write_filter_local_sync(filter_d: Path, name: str, content: str) -> None:
"""Write *content* to ``filter.d/{name}.local`` atomically.
The write is atomic: content is written to a temp file first, then
renamed into place. The ``filter.d/`` directory is created if absent.
Args:
filter_d: Path to the ``filter.d`` directory.
name: Validated filter base name (used as filename stem).
content: Full serialized filter content to write.
Raises:
ConfigWriteError: If writing fails.
"""
try:
filter_d.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise ConfigWriteError(
f"Cannot create filter.d directory: {exc}"
) from exc
local_path = filter_d / f"{name}.local"
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=filter_d,
delete=False,
suffix=".tmp",
) as tmp:
tmp.write(content)
tmp_name = tmp.name
os.replace(tmp_name, local_path)
except OSError as exc:
with contextlib.suppress(OSError):
os.unlink(tmp_name) # noqa: F821
raise ConfigWriteError(
f"Failed to write {local_path}: {exc}"
) from exc
log.info("filter_local_written", filter=name, path=str(local_path))
def _set_jail_local_key_sync(
config_dir: Path,
jail_name: str,
key: str,
value: str,
) -> None:
"""Update ``jail.d/{jail_name}.local`` to set a single key in the jail section.
If the ``.local`` file already exists it is read, the key is updated (or
added), and the file is written back atomically without disturbing other
settings. If the file does not exist a new one is created containing
only the BanGUI header comment, the jail section, and the requested key.
Args:
config_dir: The fail2ban configuration root directory.
jail_name: Validated jail name (used as section name and filename stem).
key: Config key to set inside the jail section.
value: Config value to assign.
Raises:
ConfigWriteError: If writing fails.
"""
jail_d = config_dir / "jail.d"
try:
jail_d.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise ConfigWriteError(
f"Cannot create jail.d directory: {exc}"
) from exc
local_path = jail_d / f"{jail_name}.local"
parser = _build_parser()
if local_path.is_file():
try:
parser.read(str(local_path), encoding="utf-8")
except (configparser.Error, OSError) as exc:
log.warning(
"jail_local_read_for_update_error",
jail=jail_name,
error=str(exc),
)
if not parser.has_section(jail_name):
parser.add_section(jail_name)
parser.set(jail_name, key, value)
# Serialize: write a BanGUI header then the parser output.
buf = io.StringIO()
buf.write("# Managed by BanGUI — do not edit manually\n\n")
parser.write(buf)
content = buf.getvalue()
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=jail_d,
delete=False,
suffix=".tmp",
) as tmp:
tmp.write(content)
tmp_name = tmp.name
os.replace(tmp_name, local_path)
except OSError as exc:
with contextlib.suppress(OSError):
os.unlink(tmp_name) # noqa: F821
raise ConfigWriteError(
f"Failed to write {local_path}: {exc}"
) from exc
log.info(
"jail_local_key_set",
jail=jail_name,
key=key,
path=str(local_path),
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@@ -743,32 +955,43 @@ def _build_filter_to_jails_map(
def _parse_filters_sync(
filter_d: Path,
) -> list[tuple[str, str, str, bool]]:
) -> list[tuple[str, str, str, bool, str]]:
"""Synchronously scan ``filter.d/`` and return per-filter tuples.
Each tuple contains:
- ``name`` — filter base name (``"sshd"``).
- ``filename`` — actual filename (``"sshd.conf"``).
- ``filename`` — actual filename (``"sshd.conf"`` or ``"sshd.local"``).
- ``content`` — merged file content (``conf`` overridden by ``local``).
- ``has_local`` — whether a ``.local`` override exists.
- ``has_local`` — whether a ``.local`` override exists alongside a ``.conf``.
- ``source_path`` — absolute path to the primary (``conf``) source file, or
to the ``.local`` file for user-created (local-only) filters.
Also discovers ``.local``-only files (user-created filters with no
corresponding ``.conf``). These are returned with ``has_local = False``
and ``source_path`` pointing to the ``.local`` file itself.
Args:
filter_d: Path to the ``filter.d`` directory.
Returns:
List of ``(name, filename, content, has_local)`` tuples, sorted by name.
List of ``(name, filename, content, has_local, source_path)`` 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]] = []
conf_names: set[str] = set()
results: list[tuple[str, str, str, bool, str]] = []
# ---- .conf-based filters (with optional .local override) ----------------
for conf_path in sorted(filter_d.glob("*.conf")):
if not conf_path.is_file():
continue
name = conf_path.stem
filename = conf_path.name
conf_names.add(name)
local_path = conf_path.with_suffix(".local")
has_local = local_path.is_file()
@@ -794,8 +1017,29 @@ def _parse_filters_sync(
error=str(exc),
)
results.append((name, filename, content, has_local))
results.append((name, filename, content, has_local, str(conf_path)))
# ---- .local-only filters (user-created, no corresponding .conf) ----------
for local_path in sorted(filter_d.glob("*.local")):
if not local_path.is_file():
continue
name = local_path.stem
if name in conf_names:
# Already covered above as a .conf filter with a .local override.
continue
try:
content = local_path.read_text(encoding="utf-8")
except OSError as exc:
log.warning(
"filter_local_read_error",
name=name,
path=str(local_path),
error=str(exc),
)
continue
results.append((name, local_path.name, content, False, str(local_path)))
results.sort(key=lambda t: t[0])
log.debug("filters_scanned", count=len(results), filter_d=str(filter_d))
return results
@@ -832,7 +1076,7 @@ async def list_filters(
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(
raw_filters: list[tuple[str, str, str, bool, str]] = await loop.run_in_executor(
None, _parse_filters_sync, filter_d
)
@@ -846,8 +1090,7 @@ async def list_filters(
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
for name, filename, content, has_local, source_path in raw_filters:
cfg = conffile_parser.parse_filter_file(
content, name=name, filename=filename
)
@@ -867,7 +1110,7 @@ async def list_filters(
journalmatch=cfg.journalmatch,
active=len(used_by) > 0,
used_by_jails=used_by,
source_file=str(conf_path),
source_file=source_path,
has_local_override=has_local,
)
)
@@ -897,35 +1140,46 @@ async def get_filter(
:class:`~app.models.config.FilterConfig` with status fields populated.
Raises:
FilterNotFoundError: If no ``{name}.conf`` file exists in
``filter.d/``.
FilterNotFoundError: If no ``{name}.conf`` or ``{name}.local`` file
exists in ``filter.d/``.
"""
# Normalise — strip extension if provided.
base_name = name[:-5] if name.endswith(".conf") else name
# Normalise — strip extension if provided (.conf=5 chars, .local=6 chars).
if name.endswith(".conf"):
base_name = name[:-5]
elif name.endswith(".local"):
base_name = name[:-6]
else:
base_name = name
filter_d = Path(config_dir) / "filter.d"
conf_path = filter_d / f"{base_name}.conf"
local_path = conf_path.with_suffix(".local")
local_path = filter_d / f"{base_name}.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")
def _read() -> tuple[str, bool, str]:
"""Read filter content and return (content, has_local_override, source_path)."""
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
if conf_path.is_file():
content = conf_path.read_text(encoding="utf-8")
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, str(conf_path)
elif has_local:
# Local-only filter: created by the user, no shipped .conf base.
content = local_path.read_text(encoding="utf-8")
return content, False, str(local_path)
else:
raise FilterNotFoundError(base_name)
content, has_local = await loop.run_in_executor(None, _read)
content, has_local, source_path = await loop.run_in_executor(None, _read)
cfg = conffile_parser.parse_filter_file(
content, name=base_name, filename=f"{base_name}.conf"
@@ -954,6 +1208,299 @@ async def get_filter(
journalmatch=cfg.journalmatch,
active=len(used_by) > 0,
used_by_jails=used_by,
source_file=str(conf_path),
source_file=source_path,
has_local_override=has_local,
)
# ---------------------------------------------------------------------------
# Public API — filter write operations (Task 2.2)
# ---------------------------------------------------------------------------
async def update_filter(
config_dir: str,
socket_path: str,
name: str,
req: FilterUpdateRequest,
do_reload: bool = False,
) -> FilterConfig:
"""Update a filter's ``.local`` override with new regex/pattern values.
Reads the current merged configuration for *name* (``conf`` + any existing
``local``), applies the non-``None`` fields in *req* on top of it, and
writes the resulting definition to ``filter.d/{name}.local``. The
original ``.conf`` file is never modified.
All regex patterns in *req* are validated with Python's ``re`` module
before any write occurs.
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"``).
req: Partial update — only non-``None`` fields are applied.
do_reload: When ``True``, trigger a full fail2ban reload after writing.
Returns:
:class:`~app.models.config.FilterConfig` reflecting the updated state.
Raises:
FilterNameError: If *name* contains invalid characters.
FilterNotFoundError: If no ``{name}.conf`` or ``{name}.local`` exists.
FilterInvalidRegexError: If any supplied regex pattern is invalid.
ConfigWriteError: If writing the ``.local`` file fails.
"""
base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name
_safe_filter_name(base_name)
# Validate regex patterns before touching the filesystem.
patterns: list[str] = []
if req.failregex is not None:
patterns.extend(req.failregex)
if req.ignoreregex is not None:
patterns.extend(req.ignoreregex)
_validate_regex_patterns(patterns)
# Fetch the current merged config (raises FilterNotFoundError if absent).
current = await get_filter(config_dir, socket_path, base_name)
# Build a FilterConfigUpdate from the request fields.
update = FilterConfigUpdate(
failregex=req.failregex,
ignoreregex=req.ignoreregex,
datepattern=req.datepattern,
journalmatch=req.journalmatch,
)
merged = conffile_parser.merge_filter_update(current, update)
content = conffile_parser.serialize_filter_config(merged)
filter_d = Path(config_dir) / "filter.d"
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _write_filter_local_sync, filter_d, base_name, content)
if do_reload:
try:
await jail_service.reload_all(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_filter_update_failed",
filter=base_name,
error=str(exc),
)
log.info("filter_updated", filter=base_name, reload=do_reload)
return await get_filter(config_dir, socket_path, base_name)
async def create_filter(
config_dir: str,
socket_path: str,
req: FilterCreateRequest,
do_reload: bool = False,
) -> FilterConfig:
"""Create a brand-new user-defined filter in ``filter.d/{name}.local``.
No ``.conf`` is written; fail2ban loads ``.local`` files directly. If a
``.conf`` or ``.local`` file already exists for the requested name, a
:class:`FilterAlreadyExistsError` is raised.
All regex patterns are validated with Python's ``re`` module before
writing.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
req: Filter name and definition fields.
do_reload: When ``True``, trigger a full fail2ban reload after writing.
Returns:
:class:`~app.models.config.FilterConfig` for the newly created filter.
Raises:
FilterNameError: If ``req.name`` contains invalid characters.
FilterAlreadyExistsError: If a ``.conf`` or ``.local`` already exists.
FilterInvalidRegexError: If any regex pattern is invalid.
ConfigWriteError: If writing fails.
"""
_safe_filter_name(req.name)
filter_d = Path(config_dir) / "filter.d"
conf_path = filter_d / f"{req.name}.conf"
local_path = filter_d / f"{req.name}.local"
def _check_not_exists() -> None:
if conf_path.is_file() or local_path.is_file():
raise FilterAlreadyExistsError(req.name)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _check_not_exists)
# Validate regex patterns.
patterns: list[str] = list(req.failregex) + list(req.ignoreregex)
_validate_regex_patterns(patterns)
# Build a FilterConfig and serialise it.
cfg = FilterConfig(
name=req.name,
filename=f"{req.name}.local",
failregex=req.failregex,
ignoreregex=req.ignoreregex,
prefregex=req.prefregex,
datepattern=req.datepattern,
journalmatch=req.journalmatch,
)
content = conffile_parser.serialize_filter_config(cfg)
await loop.run_in_executor(None, _write_filter_local_sync, filter_d, req.name, content)
if do_reload:
try:
await jail_service.reload_all(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_filter_create_failed",
filter=req.name,
error=str(exc),
)
log.info("filter_created", filter=req.name, reload=do_reload)
# Re-fetch to get the canonical FilterConfig (source_file, active, etc.).
return await get_filter(config_dir, socket_path, req.name)
async def delete_filter(
config_dir: str,
name: str,
) -> None:
"""Delete a user-created filter's ``.local`` file.
Deletion rules:
- If only a ``.conf`` file exists (shipped default, no user override) →
:class:`FilterReadonlyError`.
- If a ``.local`` file exists (whether or not a ``.conf`` also exists) →
the ``.local`` file is deleted. The shipped ``.conf`` is never touched.
- If neither file exists → :class:`FilterNotFoundError`.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
name: Filter base name (e.g. ``"sshd"``).
Raises:
FilterNameError: If *name* contains invalid characters.
FilterNotFoundError: If no filter file is found for *name*.
FilterReadonlyError: If only a shipped ``.conf`` exists (no ``.local``).
ConfigWriteError: If deletion of the ``.local`` file fails.
"""
base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name
_safe_filter_name(base_name)
filter_d = Path(config_dir) / "filter.d"
conf_path = filter_d / f"{base_name}.conf"
local_path = filter_d / f"{base_name}.local"
loop = asyncio.get_event_loop()
def _delete() -> None:
has_conf = conf_path.is_file()
has_local = local_path.is_file()
if not has_conf and not has_local:
raise FilterNotFoundError(base_name)
if has_conf and not has_local:
# Shipped default — nothing user-writable to remove.
raise FilterReadonlyError(base_name)
try:
local_path.unlink()
except OSError as exc:
raise ConfigWriteError(
f"Failed to delete {local_path}: {exc}"
) from exc
log.info("filter_local_deleted", filter=base_name, path=str(local_path))
await loop.run_in_executor(None, _delete)
async def assign_filter_to_jail(
config_dir: str,
socket_path: str,
jail_name: str,
req: AssignFilterRequest,
do_reload: bool = False,
) -> None:
"""Assign a filter to a jail by updating the jail's ``.local`` file.
Writes ``filter = {req.filter_name}`` into the ``[{jail_name}]`` section
of ``jail.d/{jail_name}.local``. If the ``.local`` file already contains
other settings for this jail they are preserved.
Args:
config_dir: Absolute path to the fail2ban configuration directory.
socket_path: Path to the fail2ban Unix domain socket.
jail_name: Name of the jail to update.
req: Request containing the filter name to assign.
do_reload: When ``True``, trigger a full fail2ban reload after writing.
Raises:
JailNameError: If *jail_name* contains invalid characters.
FilterNameError: If ``req.filter_name`` contains invalid characters.
JailNotFoundInConfigError: If *jail_name* is not defined in any config
file.
FilterNotFoundError: If ``req.filter_name`` does not exist in
``filter.d/``.
ConfigWriteError: If writing fails.
"""
_safe_jail_name(jail_name)
_safe_filter_name(req.filter_name)
loop = asyncio.get_event_loop()
# Verify the jail exists in config.
all_jails, _src = await loop.run_in_executor(
None, _parse_jails_sync, Path(config_dir)
)
if jail_name not in all_jails:
raise JailNotFoundInConfigError(jail_name)
# Verify the filter exists (conf or local).
filter_d = Path(config_dir) / "filter.d"
def _check_filter() -> None:
conf_exists = (filter_d / f"{req.filter_name}.conf").is_file()
local_exists = (filter_d / f"{req.filter_name}.local").is_file()
if not conf_exists and not local_exists:
raise FilterNotFoundError(req.filter_name)
await loop.run_in_executor(None, _check_filter)
await loop.run_in_executor(
None,
_set_jail_local_key_sync,
Path(config_dir),
jail_name,
"filter",
req.filter_name,
)
if do_reload:
try:
await jail_service.reload_all(socket_path)
except Exception as exc: # noqa: BLE001
log.warning(
"reload_after_assign_filter_failed",
jail=jail_name,
filter=req.filter_name,
error=str(exc),
)
log.info(
"filter_assigned_to_jail",
jail=jail_name,
filter=req.filter_name,
reload=do_reload,
)

View File

@@ -955,3 +955,337 @@ class TestGetFilter:
base_url="http://test",
).get("/api/config/filters/sshd")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# PUT /api/config/filters/{name} (Task 2.2)
# ---------------------------------------------------------------------------
class TestUpdateFilter:
"""Tests for ``PUT /api/config/filters/{name}``."""
async def test_200_returns_updated_filter(self, config_client: AsyncClient) -> None:
"""PUT /api/config/filters/sshd returns 200 with updated FilterConfig."""
with patch(
"app.routers.config.config_file_service.update_filter",
AsyncMock(return_value=_make_filter_config("sshd")),
):
resp = await config_client.put(
"/api/config/filters/sshd",
json={"failregex": [r"^fail from <HOST>"]},
)
assert resp.status_code == 200
assert resp.json()["name"] == "sshd"
async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None:
"""PUT /api/config/filters/missing returns 404."""
from app.services.config_file_service import FilterNotFoundError
with patch(
"app.routers.config.config_file_service.update_filter",
AsyncMock(side_effect=FilterNotFoundError("missing")),
):
resp = await config_client.put(
"/api/config/filters/missing",
json={},
)
assert resp.status_code == 404
async def test_422_for_invalid_regex(self, config_client: AsyncClient) -> None:
"""PUT /api/config/filters/sshd returns 422 for bad regex."""
from app.services.config_file_service import FilterInvalidRegexError
with patch(
"app.routers.config.config_file_service.update_filter",
AsyncMock(side_effect=FilterInvalidRegexError("[bad", "unterminated")),
):
resp = await config_client.put(
"/api/config/filters/sshd",
json={"failregex": ["[bad"]},
)
assert resp.status_code == 422
async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None:
"""PUT /api/config/filters/... with bad name returns 400."""
from app.services.config_file_service import FilterNameError
with patch(
"app.routers.config.config_file_service.update_filter",
AsyncMock(side_effect=FilterNameError("bad")),
):
resp = await config_client.put(
"/api/config/filters/bad",
json={},
)
assert resp.status_code == 400
async def test_reload_query_param_passed(self, config_client: AsyncClient) -> None:
"""PUT /api/config/filters/sshd?reload=true passes do_reload=True."""
with patch(
"app.routers.config.config_file_service.update_filter",
AsyncMock(return_value=_make_filter_config("sshd")),
) as mock_update:
resp = await config_client.put(
"/api/config/filters/sshd?reload=true",
json={},
)
assert resp.status_code == 200
assert mock_update.call_args.kwargs.get("do_reload") is True
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""PUT /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",
).put("/api/config/filters/sshd", json={})
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# POST /api/config/filters (Task 2.2)
# ---------------------------------------------------------------------------
class TestCreateFilter:
"""Tests for ``POST /api/config/filters``."""
async def test_201_creates_filter(self, config_client: AsyncClient) -> None:
"""POST /api/config/filters returns 201 with FilterConfig."""
with patch(
"app.routers.config.config_file_service.create_filter",
AsyncMock(return_value=_make_filter_config("my-custom")),
):
resp = await config_client.post(
"/api/config/filters",
json={"name": "my-custom", "failregex": [r"^fail from <HOST>"]},
)
assert resp.status_code == 201
assert resp.json()["name"] == "my-custom"
async def test_409_when_already_exists(self, config_client: AsyncClient) -> None:
"""POST /api/config/filters returns 409 if filter exists."""
from app.services.config_file_service import FilterAlreadyExistsError
with patch(
"app.routers.config.config_file_service.create_filter",
AsyncMock(side_effect=FilterAlreadyExistsError("sshd")),
):
resp = await config_client.post(
"/api/config/filters",
json={"name": "sshd"},
)
assert resp.status_code == 409
async def test_422_for_invalid_regex(self, config_client: AsyncClient) -> None:
"""POST /api/config/filters returns 422 for bad regex."""
from app.services.config_file_service import FilterInvalidRegexError
with patch(
"app.routers.config.config_file_service.create_filter",
AsyncMock(side_effect=FilterInvalidRegexError("[bad", "unterminated")),
):
resp = await config_client.post(
"/api/config/filters",
json={"name": "test", "failregex": ["[bad"]},
)
assert resp.status_code == 422
async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/filters returns 400 for invalid filter name."""
from app.services.config_file_service import FilterNameError
with patch(
"app.routers.config.config_file_service.create_filter",
AsyncMock(side_effect=FilterNameError("bad")),
):
resp = await config_client.post(
"/api/config/filters",
json={"name": "bad"},
)
assert resp.status_code == 400
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""POST /api/config/filters returns 401 without session."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).post("/api/config/filters", json={"name": "test"})
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# DELETE /api/config/filters/{name} (Task 2.2)
# ---------------------------------------------------------------------------
class TestDeleteFilter:
"""Tests for ``DELETE /api/config/filters/{name}``."""
async def test_204_deletes_filter(self, config_client: AsyncClient) -> None:
"""DELETE /api/config/filters/my-custom returns 204."""
with patch(
"app.routers.config.config_file_service.delete_filter",
AsyncMock(return_value=None),
):
resp = await config_client.delete("/api/config/filters/my-custom")
assert resp.status_code == 204
async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None:
"""DELETE /api/config/filters/missing returns 404."""
from app.services.config_file_service import FilterNotFoundError
with patch(
"app.routers.config.config_file_service.delete_filter",
AsyncMock(side_effect=FilterNotFoundError("missing")),
):
resp = await config_client.delete("/api/config/filters/missing")
assert resp.status_code == 404
async def test_409_for_readonly_filter(self, config_client: AsyncClient) -> None:
"""DELETE /api/config/filters/sshd returns 409 for shipped conf-only filter."""
from app.services.config_file_service import FilterReadonlyError
with patch(
"app.routers.config.config_file_service.delete_filter",
AsyncMock(side_effect=FilterReadonlyError("sshd")),
):
resp = await config_client.delete("/api/config/filters/sshd")
assert resp.status_code == 409
async def test_400_for_invalid_name(self, config_client: AsyncClient) -> None:
"""DELETE /api/config/filters/... with bad name returns 400."""
from app.services.config_file_service import FilterNameError
with patch(
"app.routers.config.config_file_service.delete_filter",
AsyncMock(side_effect=FilterNameError("bad")),
):
resp = await config_client.delete("/api/config/filters/bad")
assert resp.status_code == 400
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""DELETE /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",
).delete("/api/config/filters/sshd")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# POST /api/config/jails/{name}/filter (Task 2.2)
# ---------------------------------------------------------------------------
class TestAssignFilterToJail:
"""Tests for ``POST /api/config/jails/{name}/filter``."""
async def test_204_assigns_filter(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/filter returns 204 on success."""
with patch(
"app.routers.config.config_file_service.assign_filter_to_jail",
AsyncMock(return_value=None),
):
resp = await config_client.post(
"/api/config/jails/sshd/filter",
json={"filter_name": "myfilter"},
)
assert resp.status_code == 204
async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/missing/filter returns 404."""
from app.services.config_file_service import JailNotFoundInConfigError
with patch(
"app.routers.config.config_file_service.assign_filter_to_jail",
AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
):
resp = await config_client.post(
"/api/config/jails/missing/filter",
json={"filter_name": "sshd"},
)
assert resp.status_code == 404
async def test_404_for_unknown_filter(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/filter returns 404 when filter not found."""
from app.services.config_file_service import FilterNotFoundError
with patch(
"app.routers.config.config_file_service.assign_filter_to_jail",
AsyncMock(side_effect=FilterNotFoundError("missing-filter")),
):
resp = await config_client.post(
"/api/config/jails/sshd/filter",
json={"filter_name": "missing-filter"},
)
assert resp.status_code == 404
async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/.../filter with bad jail name returns 400."""
from app.services.config_file_service import JailNameError
with patch(
"app.routers.config.config_file_service.assign_filter_to_jail",
AsyncMock(side_effect=JailNameError("bad")),
):
resp = await config_client.post(
"/api/config/jails/sshd/filter",
json={"filter_name": "valid"},
)
assert resp.status_code == 400
async def test_400_for_invalid_filter_name(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/filter with bad filter name returns 400."""
from app.services.config_file_service import FilterNameError
with patch(
"app.routers.config.config_file_service.assign_filter_to_jail",
AsyncMock(side_effect=FilterNameError("bad")),
):
resp = await config_client.post(
"/api/config/jails/sshd/filter",
json={"filter_name": "../evil"},
)
assert resp.status_code == 400
async def test_reload_query_param_passed(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/filter?reload=true passes do_reload=True."""
with patch(
"app.routers.config.config_file_service.assign_filter_to_jail",
AsyncMock(return_value=None),
) as mock_assign:
resp = await config_client.post(
"/api/config/jails/sshd/filter?reload=true",
json={"filter_name": "sshd"},
)
assert resp.status_code == 204
assert mock_assign.call_args.kwargs.get("do_reload") is True
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
"""POST /api/config/jails/sshd/filter returns 401 without session."""
resp = await AsyncClient(
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
base_url="http://test",
).post("/api/config/jails/sshd/filter", json={"filter_name": "sshd"})
assert resp.status_code == 401

View File

@@ -262,7 +262,7 @@ class TestUpdateFilterFile:
AsyncMock(return_value=None),
):
resp = await file_config_client.put(
"/api/config/filters/nginx",
"/api/config/filters/nginx/raw",
json={"content": "[Definition]\nfailregex = test\n"},
)
@@ -274,7 +274,7 @@ class TestUpdateFilterFile:
AsyncMock(side_effect=ConfigFileWriteError("disk full")),
):
resp = await file_config_client.put(
"/api/config/filters/nginx",
"/api/config/filters/nginx/raw",
json={"content": "x"},
)
@@ -293,7 +293,7 @@ class TestCreateFilterFile:
AsyncMock(return_value="myfilter.conf"),
):
resp = await file_config_client.post(
"/api/config/filters",
"/api/config/filters/raw",
json={"name": "myfilter", "content": "[Definition]\n"},
)
@@ -306,7 +306,7 @@ class TestCreateFilterFile:
AsyncMock(side_effect=ConfigFileExistsError("myfilter.conf")),
):
resp = await file_config_client.post(
"/api/config/filters",
"/api/config/filters/raw",
json={"name": "myfilter", "content": "[Definition]\n"},
)
@@ -318,7 +318,7 @@ class TestCreateFilterFile:
AsyncMock(side_effect=ConfigFileNameError("bad/../name")),
):
resp = await file_config_client.post(
"/api/config/filters",
"/api/config/filters/raw",
json={"name": "../escape", "content": "[Definition]\n"},
)

View File

@@ -660,11 +660,12 @@ class TestParseFiltersSync:
result = _parse_filters_sync(filter_d)
assert len(result) == 1
name, filename, content, has_local = result[0]
name, filename, content, has_local, source_path = result[0]
assert name == "nginx"
assert filename == "nginx.conf"
assert "failregex" in content
assert has_local is False
assert source_path.endswith("nginx.conf")
def test_local_override_detected(self, tmp_path: Path) -> None:
from app.services.config_file_service import _parse_filters_sync
@@ -675,7 +676,7 @@ class TestParseFiltersSync:
result = _parse_filters_sync(filter_d)
_, _, _, has_local = result[0]
_, _, _, has_local, _ = result[0]
assert has_local is True
def test_local_content_appended_to_content(self, tmp_path: Path) -> None:
@@ -687,7 +688,7 @@ class TestParseFiltersSync:
result = _parse_filters_sync(filter_d)
_, _, content, _ = result[0]
_, _, content, _, _ = result[0]
assert "local tweak" in content
def test_sorted_alphabetically(self, tmp_path: Path) -> None:
@@ -852,3 +853,637 @@ class TestGetFilter:
result = await get_filter(str(tmp_path), "/fake.sock", "sshd")
assert result.has_local_override is True
# ---------------------------------------------------------------------------
# _parse_filters_sync — .local-only filters (Task 2.2)
# ---------------------------------------------------------------------------
class TestParseFiltersSyncLocalOnly:
"""Verify that .local-only user-created filters appear in results."""
def test_local_only_included(self, tmp_path: Path) -> None:
from app.services.config_file_service import _parse_filters_sync
filter_d = tmp_path / "filter.d"
_write(filter_d / "custom.local", "[Definition]\nfailregex = ^fail\n")
result = _parse_filters_sync(filter_d)
assert len(result) == 1
name, filename, content, has_local, source_path = result[0]
assert name == "custom"
assert filename == "custom.local"
assert has_local is False # .local-only: no conf to override
assert source_path.endswith("custom.local")
def test_local_only_not_duplicated_when_conf_exists(self, tmp_path: Path) -> None:
from app.services.config_file_service import _parse_filters_sync
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF)
_write(filter_d / "sshd.local", "[Definition]\n")
result = _parse_filters_sync(filter_d)
# sshd should appear exactly once (conf + local, not as separate entry)
names = [r[0] for r in result]
assert names.count("sshd") == 1
_, _, _, has_local, _ = result[0]
assert has_local is True # conf + local → has_local_override
def test_local_only_sorted_with_conf_filters(self, tmp_path: Path) -> None:
from app.services.config_file_service import _parse_filters_sync
filter_d = tmp_path / "filter.d"
_write(filter_d / "zzz.conf", _FILTER_CONF)
_write(filter_d / "aaa.local", "[Definition]\nfailregex = x\n")
result = _parse_filters_sync(filter_d)
names = [r[0] for r in result]
assert names == ["aaa", "zzz"]
# ---------------------------------------------------------------------------
# get_filter — .local-only filter (Task 2.2)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestGetFilterLocalOnly:
"""Verify that get_filter handles .local-only user-created filters."""
async def test_returns_local_only_filter(self, tmp_path: Path) -> None:
from app.services.config_file_service import get_filter
filter_d = tmp_path / "filter.d"
_write(
filter_d / "custom.local",
"[Definition]\nfailregex = ^fail from <HOST>\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", "custom")
assert result.name == "custom"
assert result.has_local_override is False
assert result.source_file.endswith("custom.local")
assert len(result.failregex) == 1
async def test_raises_when_neither_conf_nor_local(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_accepts_local_extension(self, tmp_path: Path) -> None:
from app.services.config_file_service import get_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "custom.local", "[Definition]\nfailregex = x\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", "custom.local")
assert result.name == "custom"
# ---------------------------------------------------------------------------
# _validate_regex_patterns (Task 2.2)
# ---------------------------------------------------------------------------
class TestValidateRegexPatterns:
def test_valid_patterns_pass(self) -> None:
from app.services.config_file_service import _validate_regex_patterns
_validate_regex_patterns([r"^fail from \S+", r"\d+\.\d+"])
def test_empty_list_passes(self) -> None:
from app.services.config_file_service import _validate_regex_patterns
_validate_regex_patterns([])
def test_invalid_pattern_raises(self) -> None:
from app.services.config_file_service import (
FilterInvalidRegexError,
_validate_regex_patterns,
)
with pytest.raises(FilterInvalidRegexError) as exc_info:
_validate_regex_patterns([r"[unclosed"])
assert "[unclosed" in exc_info.value.pattern
def test_mixed_valid_invalid_raises_on_first_invalid(self) -> None:
from app.services.config_file_service import (
FilterInvalidRegexError,
_validate_regex_patterns,
)
with pytest.raises(FilterInvalidRegexError) as exc_info:
_validate_regex_patterns([r"\d+", r"[bad", r"\w+"])
assert "[bad" in exc_info.value.pattern
# ---------------------------------------------------------------------------
# _write_filter_local_sync (Task 2.2)
# ---------------------------------------------------------------------------
class TestWriteFilterLocalSync:
def test_writes_file(self, tmp_path: Path) -> None:
from app.services.config_file_service import _write_filter_local_sync
filter_d = tmp_path / "filter.d"
filter_d.mkdir()
_write_filter_local_sync(filter_d, "myfilter", "[Definition]\n")
local = filter_d / "myfilter.local"
assert local.is_file()
assert "[Definition]" in local.read_text()
def test_creates_filter_d_if_missing(self, tmp_path: Path) -> None:
from app.services.config_file_service import _write_filter_local_sync
filter_d = tmp_path / "filter.d"
_write_filter_local_sync(filter_d, "test", "[Definition]\n")
assert (filter_d / "test.local").is_file()
def test_overwrites_existing_file(self, tmp_path: Path) -> None:
from app.services.config_file_service import _write_filter_local_sync
filter_d = tmp_path / "filter.d"
filter_d.mkdir()
(filter_d / "myfilter.local").write_text("old content")
_write_filter_local_sync(filter_d, "myfilter", "[Definition]\nnew=1\n")
assert "new=1" in (filter_d / "myfilter.local").read_text()
assert "old content" not in (filter_d / "myfilter.local").read_text()
# ---------------------------------------------------------------------------
# _set_jail_local_key_sync (Task 2.2)
# ---------------------------------------------------------------------------
class TestSetJailLocalKeySync:
def test_creates_new_local_file(self, tmp_path: Path) -> None:
from app.services.config_file_service import _set_jail_local_key_sync
_set_jail_local_key_sync(tmp_path, "sshd", "filter", "myfilter")
local = tmp_path / "jail.d" / "sshd.local"
assert local.is_file()
content = local.read_text()
assert "filter" in content
assert "myfilter" in content
def test_updates_existing_local_file(self, tmp_path: Path) -> None:
from app.services.config_file_service import _set_jail_local_key_sync
jail_d = tmp_path / "jail.d"
jail_d.mkdir()
(jail_d / "sshd.local").write_text(
"[sshd]\nenabled = true\n"
)
_set_jail_local_key_sync(tmp_path, "sshd", "filter", "newfilter")
content = (jail_d / "sshd.local").read_text()
assert "newfilter" in content
# Existing key is preserved
assert "enabled" in content
def test_overwrites_existing_key(self, tmp_path: Path) -> None:
from app.services.config_file_service import _set_jail_local_key_sync
jail_d = tmp_path / "jail.d"
jail_d.mkdir()
(jail_d / "sshd.local").write_text("[sshd]\nfilter = old\n")
_set_jail_local_key_sync(tmp_path, "sshd", "filter", "newfilter")
content = (jail_d / "sshd.local").read_text()
assert "newfilter" in content
# ---------------------------------------------------------------------------
# update_filter (Task 2.2)
# ---------------------------------------------------------------------------
_FILTER_CONF_WITH_REGEX = """\
[Definition]
failregex = ^fail from <HOST>
^error from <HOST>
ignoreregex =
"""
@pytest.mark.asyncio
class TestUpdateFilter:
async def test_writes_local_override(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import update_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await update_filter(
str(tmp_path),
"/fake.sock",
"sshd",
FilterUpdateRequest(failregex=[r"^new pattern <HOST>"]),
)
local = filter_d / "sshd.local"
assert local.is_file()
assert result.name == "sshd"
assert any("new pattern" in p for p in result.failregex)
async def test_accepts_conf_extension(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import update_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await update_filter(
str(tmp_path),
"/fake.sock",
"sshd.conf",
FilterUpdateRequest(datepattern="%Y-%m-%d"),
)
assert result.name == "sshd"
async def test_raises_filter_not_found(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import FilterNotFoundError, update_filter
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), pytest.raises(FilterNotFoundError):
await update_filter(
str(tmp_path),
"/fake.sock",
"missing",
FilterUpdateRequest(),
)
async def test_raises_on_invalid_regex(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import (
FilterInvalidRegexError,
update_filter,
)
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF_WITH_REGEX)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), pytest.raises(FilterInvalidRegexError):
await update_filter(
str(tmp_path),
"/fake.sock",
"sshd",
FilterUpdateRequest(failregex=[r"[unclosed"]),
)
async def test_raises_filter_name_error_for_invalid_name(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import FilterNameError, update_filter
with pytest.raises(FilterNameError):
await update_filter(
str(tmp_path),
"/fake.sock",
"../etc/passwd",
FilterUpdateRequest(),
)
async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None:
from app.models.config import FilterUpdateRequest
from app.services.config_file_service import update_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()),
), patch(
"app.services.config_file_service.jail_service.reload_all",
new=AsyncMock(),
) as mock_reload:
await update_filter(
str(tmp_path),
"/fake.sock",
"sshd",
FilterUpdateRequest(journalmatch="_SYSTEMD_UNIT=sshd.service"),
do_reload=True,
)
mock_reload.assert_awaited_once()
# ---------------------------------------------------------------------------
# create_filter (Task 2.2)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestCreateFilter:
async def test_creates_local_file(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import create_filter
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
result = await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(
name="my-custom",
failregex=[r"^fail from <HOST>"],
),
)
local = tmp_path / "filter.d" / "my-custom.local"
assert local.is_file()
assert result.name == "my-custom"
assert result.source_file.endswith("my-custom.local")
async def test_raises_already_exists_when_conf_exists(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import FilterAlreadyExistsError, create_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()),
), pytest.raises(FilterAlreadyExistsError):
await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(name="sshd"),
)
async def test_raises_already_exists_when_local_exists(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import FilterAlreadyExistsError, create_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "custom.local", "[Definition]\n")
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), pytest.raises(FilterAlreadyExistsError):
await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(name="custom"),
)
async def test_raises_invalid_regex(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import FilterInvalidRegexError, create_filter
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), pytest.raises(FilterInvalidRegexError):
await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(name="bad", failregex=[r"[unclosed"]),
)
async def test_raises_filter_name_error_for_invalid_name(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import FilterNameError, create_filter
with pytest.raises(FilterNameError):
await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(name="../etc/evil"),
)
async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None:
from app.models.config import FilterCreateRequest
from app.services.config_file_service import create_filter
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
), patch(
"app.services.config_file_service.jail_service.reload_all",
new=AsyncMock(),
) as mock_reload:
await create_filter(
str(tmp_path),
"/fake.sock",
FilterCreateRequest(name="newfilter"),
do_reload=True,
)
mock_reload.assert_awaited_once()
# ---------------------------------------------------------------------------
# delete_filter (Task 2.2)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestDeleteFilter:
async def test_deletes_local_file_when_conf_and_local_exist(
self, tmp_path: Path
) -> None:
from app.services.config_file_service import delete_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF)
_write(filter_d / "sshd.local", "[Definition]\n")
await delete_filter(str(tmp_path), "sshd")
assert not (filter_d / "sshd.local").exists()
assert (filter_d / "sshd.conf").exists()
async def test_deletes_local_only_filter(self, tmp_path: Path) -> None:
from app.services.config_file_service import delete_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "custom.local", "[Definition]\n")
await delete_filter(str(tmp_path), "custom")
assert not (filter_d / "custom.local").exists()
async def test_raises_readonly_for_conf_only(self, tmp_path: Path) -> None:
from app.services.config_file_service import FilterReadonlyError, delete_filter
filter_d = tmp_path / "filter.d"
_write(filter_d / "sshd.conf", _FILTER_CONF)
with pytest.raises(FilterReadonlyError):
await delete_filter(str(tmp_path), "sshd")
async def test_raises_not_found_for_missing_filter(self, tmp_path: Path) -> None:
from app.services.config_file_service import FilterNotFoundError, delete_filter
with pytest.raises(FilterNotFoundError):
await delete_filter(str(tmp_path), "nonexistent")
async def test_accepts_filter_name_error_for_invalid_name(
self, tmp_path: Path
) -> None:
from app.services.config_file_service import FilterNameError, delete_filter
with pytest.raises(FilterNameError):
await delete_filter(str(tmp_path), "../evil")
# ---------------------------------------------------------------------------
# assign_filter_to_jail (Task 2.2)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestAssignFilterToJail:
async def test_writes_filter_key_to_jail_local(self, tmp_path: Path) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import assign_filter_to_jail
# Setup: jail.conf with sshd jail, filter.conf for "myfilter"
_write(tmp_path / "jail.conf", JAIL_CONF)
_write(tmp_path / "filter.d" / "myfilter.conf", _FILTER_CONF)
with patch(
"app.services.config_file_service._get_active_jail_names",
new=AsyncMock(return_value=set()),
):
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"sshd",
AssignFilterRequest(filter_name="myfilter"),
)
local = tmp_path / "jail.d" / "sshd.local"
assert local.is_file()
content = local.read_text()
assert "myfilter" in content
async def test_raises_jail_not_found(self, tmp_path: Path) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import (
JailNotFoundInConfigError,
assign_filter_to_jail,
)
_write(tmp_path / "filter.d" / "sshd.conf", _FILTER_CONF)
with pytest.raises(JailNotFoundInConfigError):
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"nonexistent-jail",
AssignFilterRequest(filter_name="sshd"),
)
async def test_raises_filter_not_found(self, tmp_path: Path) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import FilterNotFoundError, assign_filter_to_jail
_write(tmp_path / "jail.conf", JAIL_CONF)
with pytest.raises(FilterNotFoundError):
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"sshd",
AssignFilterRequest(filter_name="nonexistent-filter"),
)
async def test_raises_jail_name_error_for_invalid_name(self, tmp_path: Path) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import JailNameError, assign_filter_to_jail
with pytest.raises(JailNameError):
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"../etc/evil",
AssignFilterRequest(filter_name="sshd"),
)
async def test_raises_filter_name_error_for_invalid_filter(
self, tmp_path: Path
) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import FilterNameError, assign_filter_to_jail
with pytest.raises(FilterNameError):
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"sshd",
AssignFilterRequest(filter_name="../etc/evil"),
)
async def test_triggers_reload_when_requested(self, tmp_path: Path) -> None:
from app.models.config import AssignFilterRequest
from app.services.config_file_service import assign_filter_to_jail
_write(tmp_path / "jail.conf", JAIL_CONF)
_write(tmp_path / "filter.d" / "myfilter.conf", _FILTER_CONF)
with patch(
"app.services.config_file_service.jail_service.reload_all",
new=AsyncMock(),
) as mock_reload:
await assign_filter_to_jail(
str(tmp_path),
"/fake.sock",
"sshd",
AssignFilterRequest(filter_name="myfilter"),
do_reload=True,
)
mock_reload.assert_awaited_once()