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