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:
@@ -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:**
|
**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`
|
||||||
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -370,6 +370,79 @@ class FilterConfigUpdate(BaseModel):
|
|||||||
journalmatch: str | None = Field(default=None)
|
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):
|
class FilterListResponse(BaseModel):
|
||||||
"""Response for ``GET /api/config/filters``."""
|
"""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
|
* ``GET /api/config/jails/inactive`` — list all inactive jails
|
||||||
* ``POST /api/config/jails/{name}/activate`` — activate an inactive jail
|
* ``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}/deactivate`` — deactivate an active jail
|
||||||
|
* ``POST /api/config/jails/{name}/filter`` — assign a filter to a jail
|
||||||
* ``GET /api/config/global`` — global fail2ban settings
|
* ``GET /api/config/global`` — global fail2ban settings
|
||||||
* ``PUT /api/config/global`` — update global settings
|
* ``PUT /api/config/global`` — update global settings
|
||||||
* ``POST /api/config/reload`` — reload fail2ban
|
* ``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
|
* ``POST /api/config/preview-log`` — preview log matches
|
||||||
* ``GET /api/config/filters`` — list all filters with active/inactive status
|
* ``GET /api/config/filters`` — list all filters with active/inactive status
|
||||||
* ``GET /api/config/filters/{name}`` — full parsed detail for one filter
|
* ``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
|
from __future__ import annotations
|
||||||
@@ -29,8 +33,11 @@ from app.dependencies import AuthDep
|
|||||||
from app.models.config import (
|
from app.models.config import (
|
||||||
ActivateJailRequest,
|
ActivateJailRequest,
|
||||||
AddLogPathRequest,
|
AddLogPathRequest,
|
||||||
|
AssignFilterRequest,
|
||||||
FilterConfig,
|
FilterConfig,
|
||||||
|
FilterCreateRequest,
|
||||||
FilterListResponse,
|
FilterListResponse,
|
||||||
|
FilterUpdateRequest,
|
||||||
GlobalConfigResponse,
|
GlobalConfigResponse,
|
||||||
GlobalConfigUpdate,
|
GlobalConfigUpdate,
|
||||||
InactiveJailListResponse,
|
InactiveJailListResponse,
|
||||||
@@ -48,7 +55,11 @@ from app.models.config import (
|
|||||||
from app.services import config_file_service, config_service, jail_service
|
from app.services import config_file_service, config_service, jail_service
|
||||||
from app.services.config_file_service import (
|
from app.services.config_file_service import (
|
||||||
ConfigWriteError,
|
ConfigWriteError,
|
||||||
|
FilterAlreadyExistsError,
|
||||||
|
FilterInvalidRegexError,
|
||||||
|
FilterNameError,
|
||||||
FilterNotFoundError,
|
FilterNotFoundError,
|
||||||
|
FilterReadonlyError,
|
||||||
JailAlreadyActiveError,
|
JailAlreadyActiveError,
|
||||||
JailAlreadyInactiveError,
|
JailAlreadyInactiveError,
|
||||||
JailNameError,
|
JailNameError,
|
||||||
@@ -731,3 +742,229 @@ async def get_filter(
|
|||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"Filter not found: {name!r}",
|
detail=f"Filter not found: {name!r}",
|
||||||
) from None
|
) 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}`` — overwrite a jail config file
|
||||||
* ``PUT /api/config/jail-files/{filename}/enabled`` — enable/disable a jail config
|
* ``PUT /api/config/jail-files/{filename}/enabled`` — enable/disable a jail config
|
||||||
* ``GET /api/config/filters/{name}/raw`` — get one filter file raw content
|
* ``GET /api/config/filters/{name}/raw`` — get one filter file raw content
|
||||||
* ``PUT /api/config/filters/{name}`` — update a filter file
|
* ``PUT /api/config/filters/{name}/raw`` — update a filter file (raw content)
|
||||||
* ``POST /api/config/filters`` — create a new filter file
|
* ``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
|
* ``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
|
* ``PUT /api/config/filters/{name}/parsed`` — update a filter file from a structured model
|
||||||
* ``GET /api/config/actions`` — list all action files
|
* ``GET /api/config/actions`` — list all action files
|
||||||
@@ -23,7 +23,8 @@ Endpoints:
|
|||||||
Note: ``GET /api/config/filters`` (enriched list) and
|
Note: ``GET /api/config/filters`` (enriched list) and
|
||||||
``GET /api/config/filters/{name}`` (full parsed detail) are handled by the
|
``GET /api/config/filters/{name}`` (full parsed detail) are handled by the
|
||||||
config router (``config.py``), which is registered first and therefore takes
|
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
|
from __future__ import annotations
|
||||||
@@ -347,9 +348,9 @@ async def get_filter_file_raw(
|
|||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/filters/{name}",
|
"/filters/{name}/raw",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
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(
|
async def write_filter_file(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -384,10 +385,10 @@ async def write_filter_file(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/filters",
|
"/filters/raw",
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
response_model=ConfFileContent,
|
response_model=ConfFileContent,
|
||||||
summary="Create a new filter definition file",
|
summary="Create a new filter definition file (raw content)",
|
||||||
)
|
)
|
||||||
async def create_filter_file(
|
async def create_filter_file(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import configparser
|
import configparser
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -33,8 +34,12 @@ import structlog
|
|||||||
|
|
||||||
from app.models.config import (
|
from app.models.config import (
|
||||||
ActivateJailRequest,
|
ActivateJailRequest,
|
||||||
|
AssignFilterRequest,
|
||||||
FilterConfig,
|
FilterConfig,
|
||||||
|
FilterConfigUpdate,
|
||||||
|
FilterCreateRequest,
|
||||||
FilterListResponse,
|
FilterListResponse,
|
||||||
|
FilterUpdateRequest,
|
||||||
InactiveJail,
|
InactiveJail,
|
||||||
InactiveJailListResponse,
|
InactiveJailListResponse,
|
||||||
JailActivationResponse,
|
JailActivationResponse,
|
||||||
@@ -117,6 +122,54 @@ class ConfigWriteError(Exception):
|
|||||||
"""Raised when writing a ``.local`` override file fails."""
|
"""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
|
# Internal helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -143,6 +196,27 @@ def _safe_jail_name(name: str) -> str:
|
|||||||
return name
|
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]:
|
def _ordered_config_files(config_dir: Path) -> list[Path]:
|
||||||
"""Return all jail config files in fail2ban merge order.
|
"""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
|
# Public API
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -743,32 +955,43 @@ def _build_filter_to_jails_map(
|
|||||||
|
|
||||||
def _parse_filters_sync(
|
def _parse_filters_sync(
|
||||||
filter_d: Path,
|
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.
|
"""Synchronously scan ``filter.d/`` and return per-filter tuples.
|
||||||
|
|
||||||
Each tuple contains:
|
Each tuple contains:
|
||||||
|
|
||||||
- ``name`` — filter base name (``"sshd"``).
|
- ``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``).
|
- ``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:
|
Args:
|
||||||
filter_d: Path to the ``filter.d`` directory.
|
filter_d: Path to the ``filter.d`` directory.
|
||||||
|
|
||||||
Returns:
|
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():
|
if not filter_d.is_dir():
|
||||||
log.warning("filter_d_not_found", path=str(filter_d))
|
log.warning("filter_d_not_found", path=str(filter_d))
|
||||||
return []
|
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")):
|
for conf_path in sorted(filter_d.glob("*.conf")):
|
||||||
if not conf_path.is_file():
|
if not conf_path.is_file():
|
||||||
continue
|
continue
|
||||||
name = conf_path.stem
|
name = conf_path.stem
|
||||||
filename = conf_path.name
|
filename = conf_path.name
|
||||||
|
conf_names.add(name)
|
||||||
local_path = conf_path.with_suffix(".local")
|
local_path = conf_path.with_suffix(".local")
|
||||||
has_local = local_path.is_file()
|
has_local = local_path.is_file()
|
||||||
|
|
||||||
@@ -794,8 +1017,29 @@ def _parse_filters_sync(
|
|||||||
error=str(exc),
|
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))
|
log.debug("filters_scanned", count=len(results), filter_d=str(filter_d))
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@@ -832,7 +1076,7 @@ async def list_filters(
|
|||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
# Run the synchronous scan in a thread-pool executor.
|
# 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
|
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)
|
filter_to_jails = _build_filter_to_jails_map(all_jails, active_names)
|
||||||
|
|
||||||
filters: list[FilterConfig] = []
|
filters: list[FilterConfig] = []
|
||||||
for name, filename, content, has_local in raw_filters:
|
for name, filename, content, has_local, source_path in raw_filters:
|
||||||
conf_path = filter_d / filename
|
|
||||||
cfg = conffile_parser.parse_filter_file(
|
cfg = conffile_parser.parse_filter_file(
|
||||||
content, name=name, filename=filename
|
content, name=name, filename=filename
|
||||||
)
|
)
|
||||||
@@ -867,7 +1110,7 @@ async def list_filters(
|
|||||||
journalmatch=cfg.journalmatch,
|
journalmatch=cfg.journalmatch,
|
||||||
active=len(used_by) > 0,
|
active=len(used_by) > 0,
|
||||||
used_by_jails=used_by,
|
used_by_jails=used_by,
|
||||||
source_file=str(conf_path),
|
source_file=source_path,
|
||||||
has_local_override=has_local,
|
has_local_override=has_local,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -897,35 +1140,46 @@ async def get_filter(
|
|||||||
:class:`~app.models.config.FilterConfig` with status fields populated.
|
:class:`~app.models.config.FilterConfig` with status fields populated.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
FilterNotFoundError: If no ``{name}.conf`` file exists in
|
FilterNotFoundError: If no ``{name}.conf`` or ``{name}.local`` file
|
||||||
``filter.d/``.
|
exists in ``filter.d/``.
|
||||||
"""
|
"""
|
||||||
# Normalise — strip extension if provided.
|
# Normalise — strip extension if provided (.conf=5 chars, .local=6 chars).
|
||||||
base_name = name[:-5] if name.endswith(".conf") else name
|
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"
|
filter_d = Path(config_dir) / "filter.d"
|
||||||
conf_path = filter_d / f"{base_name}.conf"
|
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()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
def _read() -> tuple[str, bool]:
|
def _read() -> tuple[str, bool, str]:
|
||||||
if not conf_path.is_file():
|
"""Read filter content and return (content, has_local_override, source_path)."""
|
||||||
raise FilterNotFoundError(base_name)
|
|
||||||
content = conf_path.read_text(encoding="utf-8")
|
|
||||||
has_local = local_path.is_file()
|
has_local = local_path.is_file()
|
||||||
if has_local:
|
if conf_path.is_file():
|
||||||
try:
|
content = conf_path.read_text(encoding="utf-8")
|
||||||
content += "\n" + local_path.read_text(encoding="utf-8")
|
if has_local:
|
||||||
except OSError as exc:
|
try:
|
||||||
log.warning(
|
content += "\n" + local_path.read_text(encoding="utf-8")
|
||||||
"filter_local_read_error",
|
except OSError as exc:
|
||||||
name=base_name,
|
log.warning(
|
||||||
path=str(local_path),
|
"filter_local_read_error",
|
||||||
error=str(exc),
|
name=base_name,
|
||||||
)
|
path=str(local_path),
|
||||||
return content, has_local
|
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(
|
cfg = conffile_parser.parse_filter_file(
|
||||||
content, name=base_name, filename=f"{base_name}.conf"
|
content, name=base_name, filename=f"{base_name}.conf"
|
||||||
@@ -954,6 +1208,299 @@ async def get_filter(
|
|||||||
journalmatch=cfg.journalmatch,
|
journalmatch=cfg.journalmatch,
|
||||||
active=len(used_by) > 0,
|
active=len(used_by) > 0,
|
||||||
used_by_jails=used_by,
|
used_by_jails=used_by,
|
||||||
source_file=str(conf_path),
|
source_file=source_path,
|
||||||
has_local_override=has_local,
|
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",
|
base_url="http://test",
|
||||||
).get("/api/config/filters/sshd")
|
).get("/api/config/filters/sshd")
|
||||||
assert resp.status_code == 401
|
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),
|
AsyncMock(return_value=None),
|
||||||
):
|
):
|
||||||
resp = await file_config_client.put(
|
resp = await file_config_client.put(
|
||||||
"/api/config/filters/nginx",
|
"/api/config/filters/nginx/raw",
|
||||||
json={"content": "[Definition]\nfailregex = test\n"},
|
json={"content": "[Definition]\nfailregex = test\n"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -274,7 +274,7 @@ class TestUpdateFilterFile:
|
|||||||
AsyncMock(side_effect=ConfigFileWriteError("disk full")),
|
AsyncMock(side_effect=ConfigFileWriteError("disk full")),
|
||||||
):
|
):
|
||||||
resp = await file_config_client.put(
|
resp = await file_config_client.put(
|
||||||
"/api/config/filters/nginx",
|
"/api/config/filters/nginx/raw",
|
||||||
json={"content": "x"},
|
json={"content": "x"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ class TestCreateFilterFile:
|
|||||||
AsyncMock(return_value="myfilter.conf"),
|
AsyncMock(return_value="myfilter.conf"),
|
||||||
):
|
):
|
||||||
resp = await file_config_client.post(
|
resp = await file_config_client.post(
|
||||||
"/api/config/filters",
|
"/api/config/filters/raw",
|
||||||
json={"name": "myfilter", "content": "[Definition]\n"},
|
json={"name": "myfilter", "content": "[Definition]\n"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -306,7 +306,7 @@ class TestCreateFilterFile:
|
|||||||
AsyncMock(side_effect=ConfigFileExistsError("myfilter.conf")),
|
AsyncMock(side_effect=ConfigFileExistsError("myfilter.conf")),
|
||||||
):
|
):
|
||||||
resp = await file_config_client.post(
|
resp = await file_config_client.post(
|
||||||
"/api/config/filters",
|
"/api/config/filters/raw",
|
||||||
json={"name": "myfilter", "content": "[Definition]\n"},
|
json={"name": "myfilter", "content": "[Definition]\n"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -318,7 +318,7 @@ class TestCreateFilterFile:
|
|||||||
AsyncMock(side_effect=ConfigFileNameError("bad/../name")),
|
AsyncMock(side_effect=ConfigFileNameError("bad/../name")),
|
||||||
):
|
):
|
||||||
resp = await file_config_client.post(
|
resp = await file_config_client.post(
|
||||||
"/api/config/filters",
|
"/api/config/filters/raw",
|
||||||
json={"name": "../escape", "content": "[Definition]\n"},
|
json={"name": "../escape", "content": "[Definition]\n"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -660,11 +660,12 @@ class TestParseFiltersSync:
|
|||||||
result = _parse_filters_sync(filter_d)
|
result = _parse_filters_sync(filter_d)
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
name, filename, content, has_local = result[0]
|
name, filename, content, has_local, source_path = result[0]
|
||||||
assert name == "nginx"
|
assert name == "nginx"
|
||||||
assert filename == "nginx.conf"
|
assert filename == "nginx.conf"
|
||||||
assert "failregex" in content
|
assert "failregex" in content
|
||||||
assert has_local is False
|
assert has_local is False
|
||||||
|
assert source_path.endswith("nginx.conf")
|
||||||
|
|
||||||
def test_local_override_detected(self, tmp_path: Path) -> None:
|
def test_local_override_detected(self, tmp_path: Path) -> None:
|
||||||
from app.services.config_file_service import _parse_filters_sync
|
from app.services.config_file_service import _parse_filters_sync
|
||||||
@@ -675,7 +676,7 @@ class TestParseFiltersSync:
|
|||||||
|
|
||||||
result = _parse_filters_sync(filter_d)
|
result = _parse_filters_sync(filter_d)
|
||||||
|
|
||||||
_, _, _, has_local = result[0]
|
_, _, _, has_local, _ = result[0]
|
||||||
assert has_local is True
|
assert has_local is True
|
||||||
|
|
||||||
def test_local_content_appended_to_content(self, tmp_path: Path) -> None:
|
def test_local_content_appended_to_content(self, tmp_path: Path) -> None:
|
||||||
@@ -687,7 +688,7 @@ class TestParseFiltersSync:
|
|||||||
|
|
||||||
result = _parse_filters_sync(filter_d)
|
result = _parse_filters_sync(filter_d)
|
||||||
|
|
||||||
_, _, content, _ = result[0]
|
_, _, content, _, _ = result[0]
|
||||||
assert "local tweak" in content
|
assert "local tweak" in content
|
||||||
|
|
||||||
def test_sorted_alphabetically(self, tmp_path: Path) -> None:
|
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")
|
result = await get_filter(str(tmp_path), "/fake.sock", "sshd")
|
||||||
|
|
||||||
assert result.has_local_override is True
|
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