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:
@@ -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``."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user