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

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

View File

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

View File

@@ -9,8 +9,8 @@ Endpoints:
* ``PUT /api/config/jail-files/{filename}`` — overwrite a jail config file
* ``PUT /api/config/jail-files/{filename}/enabled`` — enable/disable a jail config
* ``GET /api/config/filters/{name}/raw`` — get one filter file raw content
* ``PUT /api/config/filters/{name}`` — update a filter file
* ``POST /api/config/filters`` — create a new filter file
* ``PUT /api/config/filters/{name}/raw`` — update a filter file (raw content)
* ``POST /api/config/filters/raw`` — create a new filter file (raw content)
* ``GET /api/config/filters/{name}/parsed`` — parse a filter file into a structured model
* ``PUT /api/config/filters/{name}/parsed`` — update a filter file from a structured model
* ``GET /api/config/actions`` — list all action files
@@ -23,7 +23,8 @@ Endpoints:
Note: ``GET /api/config/filters`` (enriched list) and
``GET /api/config/filters/{name}`` (full parsed detail) are handled by the
config router (``config.py``), which is registered first and therefore takes
precedence. The raw-content variant is at ``/filters/{name}/raw``.
precedence. Raw-content read/write variants are at ``/filters/{name}/raw``
and ``POST /filters/raw``.
"""
from __future__ import annotations
@@ -347,9 +348,9 @@ async def get_filter_file_raw(
@router.put(
"/filters/{name}",
"/filters/{name}/raw",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update a filter definition file",
summary="Update a filter definition file (raw content)",
)
async def write_filter_file(
request: Request,
@@ -384,10 +385,10 @@ async def write_filter_file(
@router.post(
"/filters",
"/filters/raw",
status_code=status.HTTP_201_CREATED,
response_model=ConfFileContent,
summary="Create a new filter definition file",
summary="Create a new filter definition file (raw content)",
)
async def create_filter_file(
request: Request,