Split monolithic config router into focused subrouters
This commit is contained in:
@@ -107,6 +107,7 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
|
|||||||
- Issue: `app/routers/config.py` and `app/routers/file_config.py` each contain many unrelated endpoints with mixed concerns, making the HTTP layer large, hard to navigate, and harder to evolve safely.
|
- Issue: `app/routers/config.py` and `app/routers/file_config.py` each contain many unrelated endpoints with mixed concerns, making the HTTP layer large, hard to navigate, and harder to evolve safely.
|
||||||
- Propose: Refactor configuration endpoints into smaller routers or feature-specific modules such as `jail_config`, `filter_config`, `action_config`, and `raw_config`. Keep each router focused on one vertical slice of functionality and delegate business logic to a single service.
|
- Propose: Refactor configuration endpoints into smaller routers or feature-specific modules such as `jail_config`, `filter_config`, `action_config`, and `raw_config`. Keep each router focused on one vertical slice of functionality and delegate business logic to a single service.
|
||||||
- Test: Verify the refactor preserves endpoint behavior and that router tests are split along the new modules, ensuring each HTTP layer unit test covers a narrow, well-defined feature.
|
- Test: Verify the refactor preserves endpoint behavior and that router tests are split along the new modules, ensuring each HTTP layer unit test covers a narrow, well-defined feature.
|
||||||
|
- Status: completed
|
||||||
|
|
||||||
15. Centralize fail2ban metadata resolution and avoid repeated socket discovery
|
15. Centralize fail2ban metadata resolution and avoid repeated socket discovery
|
||||||
- Goal: Reduce coupling between history APIs and fail2ban socket metadata discovery, improving performance and reliability.
|
- Goal: Reduce coupling between history APIs and fail2ban socket metadata discovery, improving performance and reliability.
|
||||||
|
|||||||
373
backend/app/routers/action_config.py
Normal file
373
backend/app/routers/action_config.py
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Path, Query, status
|
||||||
|
|
||||||
|
from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep
|
||||||
|
from app.exceptions import ConfigWriteError
|
||||||
|
from app.models.config import (
|
||||||
|
ActionConfig,
|
||||||
|
ActionCreateRequest,
|
||||||
|
ActionListResponse,
|
||||||
|
ActionUpdateRequest,
|
||||||
|
AssignActionRequest,
|
||||||
|
)
|
||||||
|
from app.services import action_config_service
|
||||||
|
from app.services.action_config_service import (
|
||||||
|
ActionAlreadyExistsError,
|
||||||
|
ActionNameError,
|
||||||
|
ActionNotFoundError,
|
||||||
|
ActionReadonlyError,
|
||||||
|
)
|
||||||
|
from app.services.jail_config_service import JailNameError, JailNotFoundInConfigError
|
||||||
|
|
||||||
|
router: APIRouter = APIRouter()
|
||||||
|
|
||||||
|
_ActionNamePath = Annotated[
|
||||||
|
str,
|
||||||
|
Path(description='Action base name, e.g. ``iptables`` or ``iptables.conf``.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _action_not_found(name: str) -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Action not found: {name!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/actions",
|
||||||
|
response_model=ActionListResponse,
|
||||||
|
summary="List all available actions with active/inactive status",
|
||||||
|
)
|
||||||
|
async def list_actions(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
) -> ActionListResponse:
|
||||||
|
"""Return all actions discovered in ``action.d/`` with active/inactive status.
|
||||||
|
|
||||||
|
Scans ``{config_dir}/action.d/`` for ``.conf`` files, merges any
|
||||||
|
corresponding ``.local`` overrides, and cross-references each action's
|
||||||
|
name against the ``action`` fields of currently running jails to determine
|
||||||
|
whether it is active.
|
||||||
|
|
||||||
|
Active actions (those used by at least one running jail) are sorted to the
|
||||||
|
top of the list; inactive actions follow. Both groups are sorted
|
||||||
|
alphabetically within themselves.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session — enforces authentication.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.ActionListResponse` with all discovered
|
||||||
|
actions.
|
||||||
|
"""
|
||||||
|
result = await action_config_service.list_actions(config_dir, socket_path)
|
||||||
|
result.actions.sort(key=lambda a: (not a.active, a.name.lower()))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/actions/{name}",
|
||||||
|
response_model=ActionConfig,
|
||||||
|
summary="Return full parsed detail for a single action",
|
||||||
|
)
|
||||||
|
async def get_action(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
name: _ActionNamePath,
|
||||||
|
) -> ActionConfig:
|
||||||
|
"""Return the full parsed configuration and active/inactive status for one action.
|
||||||
|
|
||||||
|
Reads ``{config_dir}/action.d/{name}.conf``, merges any corresponding
|
||||||
|
``.local`` override, and annotates the result with ``active``,
|
||||||
|
``used_by_jails``, ``source_file``, and ``has_local_override``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session — enforces authentication.
|
||||||
|
name: Action base name (with or without ``.conf`` extension).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.ActionConfig`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if the action is not found in ``action.d/``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await action_config_service.get_action(config_dir, socket_path, name)
|
||||||
|
except ActionNotFoundError:
|
||||||
|
raise _action_not_found(name) from None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Action write endpoints (Task 3.2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/actions/{name}",
|
||||||
|
response_model=ActionConfig,
|
||||||
|
summary="Update an action's .local override with new lifecycle command values",
|
||||||
|
)
|
||||||
|
async def update_action(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
name: _ActionNamePath,
|
||||||
|
body: ActionUpdateRequest,
|
||||||
|
reload: bool = Query(default=False, description="Reload fail2ban after writing."),
|
||||||
|
) -> ActionConfig:
|
||||||
|
"""Update an action's ``[Definition]`` fields by writing a ``.local`` override.
|
||||||
|
|
||||||
|
Only non-``null`` fields in the request body are written. The original
|
||||||
|
``.conf`` file is never modified.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session.
|
||||||
|
name: Action base name (with or without ``.conf`` extension).
|
||||||
|
body: Partial update — lifecycle commands and ``[Init]`` parameters.
|
||||||
|
reload: When ``true``, trigger a fail2ban reload after writing.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated :class:`~app.models.config.ActionConfig`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if *name* contains invalid characters.
|
||||||
|
HTTPException: 404 if the action does not exist.
|
||||||
|
HTTPException: 500 if writing the ``.local`` file fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await action_config_service.update_action(config_dir, socket_path, name, body, do_reload=reload)
|
||||||
|
except ActionNameError as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
except ActionNotFoundError:
|
||||||
|
raise _action_not_found(name) from None
|
||||||
|
except ConfigWriteError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to write action override: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/actions",
|
||||||
|
response_model=ActionConfig,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Create a new user-defined action",
|
||||||
|
)
|
||||||
|
async def create_action(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
body: ActionCreateRequest,
|
||||||
|
reload: bool = Query(default=False, description="Reload fail2ban after creating."),
|
||||||
|
) -> ActionConfig:
|
||||||
|
"""Create a new user-defined action at ``action.d/{name}.local``.
|
||||||
|
|
||||||
|
Returns 409 if a ``.conf`` or ``.local`` for the requested name already
|
||||||
|
exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session.
|
||||||
|
body: Action name and ``[Definition]`` lifecycle fields.
|
||||||
|
reload: When ``true``, trigger a fail2ban reload after creating.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.ActionConfig` for the new action.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if the name contains invalid characters.
|
||||||
|
HTTPException: 409 if the action already exists.
|
||||||
|
HTTPException: 500 if writing fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await action_config_service.create_action(config_dir, socket_path, body, do_reload=reload)
|
||||||
|
except ActionNameError as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
except ActionAlreadyExistsError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Action {exc.name!r} already exists.",
|
||||||
|
) from exc
|
||||||
|
except ConfigWriteError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to write action: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/actions/{name}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Delete a user-created action's .local file",
|
||||||
|
)
|
||||||
|
async def delete_action(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
name: _ActionNamePath,
|
||||||
|
) -> None:
|
||||||
|
"""Delete a user-created action's ``.local`` override file.
|
||||||
|
|
||||||
|
Shipped ``.conf``-only actions cannot be deleted (returns 409). When
|
||||||
|
both a ``.conf`` and a ``.local`` exist, only the ``.local`` is removed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session.
|
||||||
|
name: Action base name.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if *name* contains invalid characters.
|
||||||
|
HTTPException: 404 if the action does not exist.
|
||||||
|
HTTPException: 409 if the action is a shipped default (conf-only).
|
||||||
|
HTTPException: 500 if deletion fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await action_config_service.delete_action(config_dir, name)
|
||||||
|
except ActionNameError as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
except ActionNotFoundError:
|
||||||
|
raise _action_not_found(name) from None
|
||||||
|
except ActionReadonlyError 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 action: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/jails/{name}/action",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Add an action to a jail",
|
||||||
|
)
|
||||||
|
async def assign_action_to_jail(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
name: _NamePath,
|
||||||
|
body: AssignActionRequest,
|
||||||
|
reload: bool = Query(default=False, description="Reload fail2ban after assigning."),
|
||||||
|
) -> None:
|
||||||
|
"""Append an action entry 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. The action is not duplicated if it is
|
||||||
|
already present.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session.
|
||||||
|
name: Jail name.
|
||||||
|
body: Action to add plus optional per-jail parameters.
|
||||||
|
reload: When ``true``, trigger a fail2ban reload after writing.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if *name* or *action_name* contain invalid characters.
|
||||||
|
HTTPException: 404 if the jail or action does not exist.
|
||||||
|
HTTPException: 500 if writing fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await action_config_service.assign_action_to_jail(config_dir, socket_path, name, body, do_reload=reload)
|
||||||
|
except (JailNameError, ActionNameError) as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
except JailNotFoundInConfigError:
|
||||||
|
raise _not_found(name) from None
|
||||||
|
except ActionNotFoundError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Action 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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/jails/{name}/action/{action_name}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Remove an action from a jail",
|
||||||
|
)
|
||||||
|
async def remove_action_from_jail(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
name: _NamePath,
|
||||||
|
action_name: Annotated[str, Path(description="Action base name to remove.")],
|
||||||
|
reload: bool = Query(default=False, description="Reload fail2ban after removing."),
|
||||||
|
) -> None:
|
||||||
|
"""Remove an action from the jail's ``.local`` config.
|
||||||
|
|
||||||
|
If the jail has no ``.local`` file or the action is not listed there,
|
||||||
|
the call is silently idempotent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session.
|
||||||
|
name: Jail name.
|
||||||
|
action_name: Base name of the action to remove.
|
||||||
|
reload: When ``true``, trigger a fail2ban reload after writing.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if *name* or *action_name* contain invalid characters.
|
||||||
|
HTTPException: 404 if the jail is not found in config files.
|
||||||
|
HTTPException: 500 if writing fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await action_config_service.remove_action_from_jail(
|
||||||
|
config_dir,
|
||||||
|
socket_path,
|
||||||
|
name,
|
||||||
|
action_name,
|
||||||
|
do_reload=reload,
|
||||||
|
)
|
||||||
|
except (JailNameError, ActionNameError) as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
except JailNotFoundInConfigError:
|
||||||
|
raise _not_found(name) from None
|
||||||
|
except ConfigWriteError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to write jail override: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fail2ban log viewer endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
429
backend/app/routers/config_misc.py
Normal file
429
backend/app/routers/config_misc.py
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||||
|
|
||||||
|
from app.dependencies import AuthDep, DbDep, Fail2BanSocketDep, Fail2BanStartCommandDep
|
||||||
|
from app.models.config import (
|
||||||
|
Fail2BanLogResponse,
|
||||||
|
GlobalConfigResponse,
|
||||||
|
GlobalConfigUpdate,
|
||||||
|
LogPreviewRequest,
|
||||||
|
LogPreviewResponse,
|
||||||
|
MapColorThresholdsResponse,
|
||||||
|
MapColorThresholdsUpdate,
|
||||||
|
RegexTestRequest,
|
||||||
|
RegexTestResponse,
|
||||||
|
ServiceStatusResponse,
|
||||||
|
)
|
||||||
|
from app.services import config_file_service, config_service, jail_service, log_service, setup_service
|
||||||
|
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||||
|
|
||||||
|
router: APIRouter = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _bad_gateway(exc: Exception) -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Cannot reach fail2ban: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/global",
|
||||||
|
response_model=GlobalConfigResponse,
|
||||||
|
summary="Return global fail2ban settings",
|
||||||
|
)
|
||||||
|
async def get_global_config(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
) -> GlobalConfigResponse:
|
||||||
|
"""Return global fail2ban settings (log level, log target, database config).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Incoming request.
|
||||||
|
_auth: Validated session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.GlobalConfigResponse`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 502 when fail2ban is unreachable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await config_service.get_global_config(socket_path)
|
||||||
|
except Fail2BanConnectionError as exc:
|
||||||
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/global",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Update global fail2ban settings",
|
||||||
|
)
|
||||||
|
async def update_global_config(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
body: GlobalConfigUpdate,
|
||||||
|
) -> None:
|
||||||
|
"""Update global fail2ban settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Incoming request.
|
||||||
|
_auth: Validated session.
|
||||||
|
body: Partial update — only non-None fields are written.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 when a set command is rejected.
|
||||||
|
HTTPException: 502 when fail2ban is unreachable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await config_service.update_global_config(socket_path, body)
|
||||||
|
except ConfigOperationError as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
except Fail2BanConnectionError as exc:
|
||||||
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reload endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/reload",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Reload fail2ban to apply configuration changes",
|
||||||
|
)
|
||||||
|
async def reload_fail2ban(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
) -> None:
|
||||||
|
"""Trigger a full fail2ban reload.
|
||||||
|
|
||||||
|
All jails are stopped and restarted with the current configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Incoming request.
|
||||||
|
_auth: Validated session.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 409 when fail2ban reports the reload failed.
|
||||||
|
HTTPException: 502 when fail2ban is unreachable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await jail_service.reload_all(socket_path)
|
||||||
|
except JailOperationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"fail2ban reload failed: {exc}",
|
||||||
|
) from exc
|
||||||
|
except Fail2BanConnectionError as exc:
|
||||||
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# Restart endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/restart",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Restart the fail2ban service",
|
||||||
|
)
|
||||||
|
async def restart_fail2ban(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
start_cmd: Fail2BanStartCommandDep,
|
||||||
|
) -> None:
|
||||||
|
"""Trigger a full fail2ban service restart.
|
||||||
|
|
||||||
|
Stops the fail2ban daemon via the Unix domain socket, then starts it
|
||||||
|
again using the configured ``fail2ban_start_command``. After starting,
|
||||||
|
probes the socket for up to 10 seconds to confirm the daemon came back
|
||||||
|
online.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Incoming request.
|
||||||
|
_auth: Validated session.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 409 when fail2ban reports the stop command failed.
|
||||||
|
HTTPException: 502 when fail2ban is unreachable for the stop command.
|
||||||
|
HTTPException: 503 when fail2ban does not come back online within
|
||||||
|
10 seconds after being started. Check the fail2ban log for
|
||||||
|
initialisation errors. Use
|
||||||
|
``POST /api/config/jails/{name}/rollback`` if a specific jail
|
||||||
|
is suspect.
|
||||||
|
"""
|
||||||
|
start_cmd_parts: list[str] = start_cmd.split()
|
||||||
|
|
||||||
|
# Step 1: stop the daemon via socket.
|
||||||
|
try:
|
||||||
|
await jail_service.restart(socket_path)
|
||||||
|
except JailOperationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"fail2ban stop command failed: {exc}",
|
||||||
|
) from exc
|
||||||
|
except Fail2BanConnectionError as exc:
|
||||||
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
|
# Step 2: start the daemon via subprocess.
|
||||||
|
await config_file_service.start_daemon(start_cmd_parts)
|
||||||
|
|
||||||
|
# Step 3: probe the socket until fail2ban is responsive or the budget expires.
|
||||||
|
fail2ban_running: bool = await config_file_service.wait_for_fail2ban(socket_path, max_wait_seconds=10.0)
|
||||||
|
if not fail2ban_running:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=(
|
||||||
|
"fail2ban was stopped but did not come back online within 10 seconds. "
|
||||||
|
"Check the fail2ban log for initialisation errors. "
|
||||||
|
"Use POST /api/config/jails/{name}/rollback if a specific jail is suspect."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
log.info("fail2ban_restarted")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Regex tester (stateless)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/regex-test",
|
||||||
|
response_model=RegexTestResponse,
|
||||||
|
summary="Test a fail regex pattern against a sample log line",
|
||||||
|
)
|
||||||
|
async def regex_test(
|
||||||
|
_auth: AuthDep,
|
||||||
|
body: RegexTestRequest,
|
||||||
|
) -> RegexTestResponse:
|
||||||
|
"""Test whether a regex pattern matches a given log line.
|
||||||
|
|
||||||
|
This endpoint is entirely in-process — no fail2ban socket call is made.
|
||||||
|
Returns the match result and any captured groups.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
_auth: Validated session.
|
||||||
|
body: Sample log line and regex pattern.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.RegexTestResponse` with match result and groups.
|
||||||
|
"""
|
||||||
|
return log_service.test_regex(body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Log path management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/preview-log",
|
||||||
|
response_model=LogPreviewResponse,
|
||||||
|
summary="Preview log file lines against a regex pattern",
|
||||||
|
)
|
||||||
|
async def preview_log(
|
||||||
|
_auth: AuthDep,
|
||||||
|
body: LogPreviewRequest,
|
||||||
|
) -> LogPreviewResponse:
|
||||||
|
"""Read the last N lines of a log file and test a regex against each one.
|
||||||
|
|
||||||
|
Returns each line with a flag indicating whether the regex matched, and
|
||||||
|
the captured groups for matching lines. The log file is read from the
|
||||||
|
server's local filesystem.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
_auth: Validated session.
|
||||||
|
body: Log file path, regex pattern, and number of lines to read.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.LogPreviewResponse` with per-line results.
|
||||||
|
"""
|
||||||
|
return await log_service.preview_log(body)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Map color thresholds
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/map-color-thresholds",
|
||||||
|
response_model=MapColorThresholdsResponse,
|
||||||
|
summary="Get map color threshold configuration",
|
||||||
|
)
|
||||||
|
async def get_map_color_thresholds(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
db: DbDep,
|
||||||
|
) -> MapColorThresholdsResponse:
|
||||||
|
"""Return the configured map color thresholds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.MapColorThresholdsResponse` with
|
||||||
|
current thresholds.
|
||||||
|
"""
|
||||||
|
from app.services import setup_service
|
||||||
|
|
||||||
|
high, medium, low = await setup_service.get_map_color_thresholds(db)
|
||||||
|
return MapColorThresholdsResponse(
|
||||||
|
threshold_high=high,
|
||||||
|
threshold_medium=medium,
|
||||||
|
threshold_low=low,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/map-color-thresholds",
|
||||||
|
response_model=MapColorThresholdsResponse,
|
||||||
|
summary="Update map color threshold configuration",
|
||||||
|
)
|
||||||
|
async def update_map_color_thresholds(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
db: DbDep,
|
||||||
|
body: MapColorThresholdsUpdate,
|
||||||
|
) -> MapColorThresholdsResponse:
|
||||||
|
"""Update the map color threshold configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session.
|
||||||
|
body: New threshold values.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.MapColorThresholdsResponse` with
|
||||||
|
updated thresholds.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if validation fails (thresholds not
|
||||||
|
properly ordered).
|
||||||
|
"""
|
||||||
|
from app.services import setup_service
|
||||||
|
|
||||||
|
try:
|
||||||
|
await setup_service.set_map_color_thresholds(
|
||||||
|
db,
|
||||||
|
threshold_high=body.threshold_high,
|
||||||
|
threshold_medium=body.threshold_medium,
|
||||||
|
threshold_low=body.threshold_low,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
|
||||||
|
return MapColorThresholdsResponse(
|
||||||
|
threshold_high=body.threshold_high,
|
||||||
|
threshold_medium=body.threshold_medium,
|
||||||
|
threshold_low=body.threshold_low,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/fail2ban-log",
|
||||||
|
response_model=Fail2BanLogResponse,
|
||||||
|
summary="Read the tail of the fail2ban daemon log file",
|
||||||
|
)
|
||||||
|
async def get_fail2ban_log(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
lines: Annotated[int, Query(ge=1, le=2000, description="Number of lines to return from the tail.")] = 200,
|
||||||
|
filter: Annotated[ # noqa: A002
|
||||||
|
str | None,
|
||||||
|
Query(description="Plain-text substring filter; only matching lines are returned."),
|
||||||
|
] = None,
|
||||||
|
) -> Fail2BanLogResponse:
|
||||||
|
"""Return the tail of the fail2ban daemon log file.
|
||||||
|
|
||||||
|
Queries the fail2ban socket for the current log target and log level,
|
||||||
|
reads the last *lines* entries from the file, and optionally filters
|
||||||
|
them by *filter*. Only file-based log targets are supported.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Incoming request.
|
||||||
|
_auth: Validated session — enforces authentication.
|
||||||
|
lines: Number of tail lines to return (1–2000, default 200).
|
||||||
|
filter: Optional plain-text substring — only matching lines returned.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.Fail2BanLogResponse`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 when the log target is not a file or path is outside
|
||||||
|
the allowed directory.
|
||||||
|
HTTPException: 502 when fail2ban is unreachable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await config_service.read_fail2ban_log(socket_path, lines, filter)
|
||||||
|
except config_service.ConfigOperationError as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
except Fail2BanConnectionError as exc:
|
||||||
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/service-status",
|
||||||
|
response_model=ServiceStatusResponse,
|
||||||
|
summary="Return fail2ban service health status with log configuration",
|
||||||
|
)
|
||||||
|
async def get_service_status(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
) -> ServiceStatusResponse:
|
||||||
|
"""Return fail2ban service health and current log configuration.
|
||||||
|
|
||||||
|
Probes the fail2ban daemon to determine online/offline state, then
|
||||||
|
augments the result with the current log level and log target values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Incoming request.
|
||||||
|
_auth: Validated session — enforces authentication.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.ServiceStatusResponse`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 502 when fail2ban is unreachable (the service itself
|
||||||
|
handles this gracefully and returns ``online=False``).
|
||||||
|
"""
|
||||||
|
from app.services import health_service
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await config_service.get_service_status(
|
||||||
|
socket_path,
|
||||||
|
probe_fn=health_service.probe,
|
||||||
|
)
|
||||||
|
except Fail2BanConnectionError as exc:
|
||||||
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
367
backend/app/routers/filter_config.py
Normal file
367
backend/app/routers/filter_config.py
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Path, Query, status
|
||||||
|
|
||||||
|
from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep
|
||||||
|
from app.services.config_file_service import ConfigWriteError
|
||||||
|
from app.models.config import (
|
||||||
|
AssignFilterRequest,
|
||||||
|
FilterConfig,
|
||||||
|
FilterConfigUpdate,
|
||||||
|
FilterCreateRequest,
|
||||||
|
FilterListResponse,
|
||||||
|
)
|
||||||
|
from app.services import filter_config_service
|
||||||
|
from app.services.filter_config_service import (
|
||||||
|
FilterAlreadyExistsError,
|
||||||
|
FilterInvalidRegexError,
|
||||||
|
FilterNameError,
|
||||||
|
FilterNotFoundError,
|
||||||
|
FilterReadonlyError,
|
||||||
|
)
|
||||||
|
from app.services.jail_config_service import JailNameError, JailNotFoundInConfigError
|
||||||
|
|
||||||
|
router: APIRouter = APIRouter()
|
||||||
|
|
||||||
|
_NamePath = Annotated[
|
||||||
|
str,
|
||||||
|
Path(description='Jail name as configured in fail2ban.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
_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.get(
|
||||||
|
"/filters",
|
||||||
|
response_model=FilterListResponse,
|
||||||
|
summary="List all available filters with active/inactive status",
|
||||||
|
)
|
||||||
|
async def list_filters(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
) -> FilterListResponse:
|
||||||
|
"""Return all filters discovered in ``filter.d/`` with active/inactive status.
|
||||||
|
|
||||||
|
Scans ``{config_dir}/filter.d/`` for ``.conf`` files, merges any
|
||||||
|
corresponding ``.local`` overrides, and cross-references each filter's
|
||||||
|
name against the ``filter`` fields of currently running jails to determine
|
||||||
|
whether it is active.
|
||||||
|
|
||||||
|
Active filters (those used by at least one running jail) are sorted to the
|
||||||
|
top of the list; inactive filters follow. Both groups are sorted
|
||||||
|
alphabetically within themselves.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session — enforces authentication.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.FilterListResponse` with all discovered
|
||||||
|
filters.
|
||||||
|
"""
|
||||||
|
result = await filter_config_service.list_filters(config_dir, socket_path)
|
||||||
|
# Sort: active first (by name), then inactive (by name).
|
||||||
|
result.filters.sort(key=lambda f: (not f.active, f.name.lower()))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/filters/{name}",
|
||||||
|
response_model=FilterConfig,
|
||||||
|
summary="Return full parsed detail for a single filter",
|
||||||
|
)
|
||||||
|
async def get_filter(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
name: Annotated[str, Path(description="Filter base name, e.g. ``sshd`` or ``sshd.conf``.")],
|
||||||
|
) -> FilterConfig:
|
||||||
|
"""Return the full parsed configuration and active/inactive status for one filter.
|
||||||
|
|
||||||
|
Reads ``{config_dir}/filter.d/{name}.conf``, merges any corresponding
|
||||||
|
``.local`` override, and annotates the result with ``active``,
|
||||||
|
``used_by_jails``, ``source_file``, and ``has_local_override``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session — enforces authentication.
|
||||||
|
name: Filter base name (with or without ``.conf`` extension).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.FilterConfig`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if the filter is not found in ``filter.d/``.
|
||||||
|
HTTPException: 502 if fail2ban is unreachable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await filter_config_service.get_filter(config_dir, socket_path, name)
|
||||||
|
except FilterNotFoundError:
|
||||||
|
raise HTTPException(
|
||||||
|
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,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await filter_config_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,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await filter_config_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,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await filter_config_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,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await filter_config_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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Action discovery endpoints (Task 3.1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_ActionNamePath = Annotated[
|
||||||
|
str,
|
||||||
|
Path(description="Action base name, e.g. ``iptables`` or ``iptables.conf``."),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _action_not_found(name: str) -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Action not found: {name!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
589
backend/app/routers/jail_config.py
Normal file
589
backend/app/routers/jail_config.py
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Path, Query, Request, status
|
||||||
|
|
||||||
|
from app.dependencies import (
|
||||||
|
AppDep,
|
||||||
|
AuthDep,
|
||||||
|
Fail2BanConfigDirDep,
|
||||||
|
Fail2BanSocketDep,
|
||||||
|
Fail2BanStartCommandDep,
|
||||||
|
PendingRecoveryDep,
|
||||||
|
)
|
||||||
|
from app.exceptions import (
|
||||||
|
ConfigOperationError,
|
||||||
|
ConfigValidationError,
|
||||||
|
JailNotFoundError,
|
||||||
|
)
|
||||||
|
from app.models.config import (
|
||||||
|
ActivateJailRequest,
|
||||||
|
AddLogPathRequest,
|
||||||
|
InactiveJailListResponse,
|
||||||
|
JailActivationResponse,
|
||||||
|
JailConfigListResponse,
|
||||||
|
JailConfigResponse,
|
||||||
|
JailConfigUpdate,
|
||||||
|
JailValidationResult,
|
||||||
|
PendingRecovery,
|
||||||
|
RollbackResponse,
|
||||||
|
)
|
||||||
|
from app.services import config_service, jail_config_service, jail_service
|
||||||
|
from app.services.jail_config_service import (
|
||||||
|
ConfigWriteError,
|
||||||
|
JailAlreadyActiveError,
|
||||||
|
JailAlreadyInactiveError,
|
||||||
|
JailNameError,
|
||||||
|
JailNotFoundInConfigError,
|
||||||
|
)
|
||||||
|
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||||
|
|
||||||
|
router: APIRouter = APIRouter()
|
||||||
|
|
||||||
|
_NamePath = Annotated[str, Path(description='Jail name as configured in fail2ban.')]
|
||||||
|
|
||||||
|
|
||||||
|
def _not_found(name: str) -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Jail not found: {name!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bad_gateway(exc: Exception) -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Cannot reach fail2ban: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unprocessable(message: str) -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bad_request(message: str) -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/jails",
|
||||||
|
response_model=JailConfigListResponse,
|
||||||
|
summary="List configuration for all active jails",
|
||||||
|
)
|
||||||
|
async def get_jail_configs(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
) -> JailConfigListResponse:
|
||||||
|
"""Return editable configuration for every active fail2ban jail.
|
||||||
|
|
||||||
|
Fetches ban time, find time, max retries, regex patterns, log paths,
|
||||||
|
date pattern, encoding, backend, and attached actions for all jails.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Incoming request (used to access ``app.state``).
|
||||||
|
_auth: Validated session — enforces authentication.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.JailConfigListResponse`.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await config_service.list_jail_configs(socket_path)
|
||||||
|
except Fail2BanConnectionError as exc:
|
||||||
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/jails/inactive",
|
||||||
|
response_model=InactiveJailListResponse,
|
||||||
|
summary="List all inactive jails discovered in config files",
|
||||||
|
)
|
||||||
|
async def get_inactive_jails(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
) -> InactiveJailListResponse:
|
||||||
|
"""Return all jails defined in fail2ban config files that are not running.
|
||||||
|
|
||||||
|
Parses ``jail.conf``, ``jail.local``, and ``jail.d/`` following the
|
||||||
|
fail2ban merge order. Jails that fail2ban currently reports as running
|
||||||
|
are excluded; only truly inactive entries are returned.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session — enforces authentication.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.InactiveJailListResponse`.
|
||||||
|
"""
|
||||||
|
return await jail_config_service.list_inactive_jails(config_dir, socket_path)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/jails/{name}",
|
||||||
|
response_model=JailConfigResponse,
|
||||||
|
summary="Return configuration for a single jail",
|
||||||
|
)
|
||||||
|
async def get_jail_config(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
name: _NamePath,
|
||||||
|
) -> JailConfigResponse:
|
||||||
|
"""Return the full editable configuration for one fail2ban jail.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Incoming request.
|
||||||
|
_auth: Validated session.
|
||||||
|
name: Jail name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.JailConfigResponse`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 when the jail does not exist.
|
||||||
|
HTTPException: 502 when fail2ban is unreachable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await config_service.get_jail_config(socket_path, name)
|
||||||
|
except JailNotFoundError:
|
||||||
|
raise _not_found(name) from None
|
||||||
|
except Fail2BanConnectionError as exc:
|
||||||
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/jails/{name}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Update jail configuration",
|
||||||
|
)
|
||||||
|
async def update_jail_config(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
name: _NamePath,
|
||||||
|
body: JailConfigUpdate,
|
||||||
|
) -> None:
|
||||||
|
"""Update one or more configuration fields for an active fail2ban jail.
|
||||||
|
|
||||||
|
Regex patterns are validated before being sent to fail2ban. An invalid
|
||||||
|
pattern returns 422 with the regex error message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Incoming request.
|
||||||
|
_auth: Validated session.
|
||||||
|
name: Jail name.
|
||||||
|
body: Partial update — only non-None fields are written.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 when the jail does not exist.
|
||||||
|
HTTPException: 422 when a regex pattern fails to compile.
|
||||||
|
HTTPException: 400 when a set command is rejected.
|
||||||
|
HTTPException: 502 when fail2ban is unreachable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await config_service.update_jail_config(socket_path, name, body)
|
||||||
|
except JailNotFoundError:
|
||||||
|
raise _not_found(name) from None
|
||||||
|
except ConfigValidationError as exc:
|
||||||
|
raise _unprocessable(str(exc)) from exc
|
||||||
|
except ConfigOperationError as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
except Fail2BanConnectionError as exc:
|
||||||
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Global configuration endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/jails/{name}/logpath",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Add a log file path to an existing jail",
|
||||||
|
)
|
||||||
|
async def add_log_path(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
name: _NamePath,
|
||||||
|
body: AddLogPathRequest,
|
||||||
|
) -> None:
|
||||||
|
"""Register an additional log file for an existing jail to monitor.
|
||||||
|
|
||||||
|
Uses ``set <jail> addlogpath <path> <tail|head>`` to add the path
|
||||||
|
without requiring a daemon restart.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Incoming request.
|
||||||
|
_auth: Validated session.
|
||||||
|
name: Jail name.
|
||||||
|
body: Log path and tail/head preference.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 when the jail does not exist.
|
||||||
|
HTTPException: 400 when the command is rejected.
|
||||||
|
HTTPException: 502 when fail2ban is unreachable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await config_service.add_log_path(socket_path, name, body)
|
||||||
|
except JailNotFoundError:
|
||||||
|
raise _not_found(name) from None
|
||||||
|
except ConfigOperationError as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
except Fail2BanConnectionError as exc:
|
||||||
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/jails/{name}/logpath",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Remove a monitored log path from a jail",
|
||||||
|
)
|
||||||
|
async def delete_log_path(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
name: _NamePath,
|
||||||
|
log_path: str = Query(..., description="Absolute path of the log file to stop monitoring."),
|
||||||
|
) -> None:
|
||||||
|
"""Stop a jail from monitoring the specified log file.
|
||||||
|
|
||||||
|
Uses ``set <jail> dellogpath <path>`` to remove the log path at runtime
|
||||||
|
without requiring a daemon restart.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Incoming request.
|
||||||
|
_auth: Validated session.
|
||||||
|
name: Jail name.
|
||||||
|
log_path: Absolute path to the log file to remove (query parameter).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 when the jail does not exist.
|
||||||
|
HTTPException: 400 when the command is rejected.
|
||||||
|
HTTPException: 502 when fail2ban is unreachable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await config_service.delete_log_path(socket_path, name, log_path)
|
||||||
|
except JailNotFoundError:
|
||||||
|
raise _not_found(name) from None
|
||||||
|
except ConfigOperationError as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
except Fail2BanConnectionError as exc:
|
||||||
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/jails/{name}/activate",
|
||||||
|
response_model=JailActivationResponse,
|
||||||
|
summary="Activate an inactive jail",
|
||||||
|
)
|
||||||
|
async def activate_jail(
|
||||||
|
app: AppDep,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
name: _NamePath,
|
||||||
|
body: ActivateJailRequest | None = None,
|
||||||
|
) -> JailActivationResponse:
|
||||||
|
"""Enable an inactive jail and reload fail2ban.
|
||||||
|
|
||||||
|
Writes ``enabled = true`` (plus any override values from the request
|
||||||
|
body) to ``jail.d/{name}.local`` and triggers a full fail2ban reload so
|
||||||
|
the jail starts immediately.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: FastAPI application instance.
|
||||||
|
_auth: Validated session.
|
||||||
|
name: Name of the jail to activate.
|
||||||
|
body: Optional override values (bantime, findtime, maxretry, port,
|
||||||
|
logpath).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.JailActivationResponse`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if *name* contains invalid characters.
|
||||||
|
HTTPException: 404 if *name* is not found in any config file.
|
||||||
|
HTTPException: 409 if the jail is already active.
|
||||||
|
HTTPException: 502 if fail2ban is unreachable.
|
||||||
|
"""
|
||||||
|
req = body if body is not None else ActivateJailRequest()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await jail_config_service.activate_jail(app, config_dir, socket_path, name, req)
|
||||||
|
except JailNameError as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
except JailNotFoundInConfigError:
|
||||||
|
raise _not_found(name) from None
|
||||||
|
except JailAlreadyActiveError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Jail {name!r} is already active.",
|
||||||
|
) from None
|
||||||
|
except ConfigWriteError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to write config override: {exc}",
|
||||||
|
) from exc
|
||||||
|
except Fail2BanConnectionError as exc:
|
||||||
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/jails/{name}/deactivate",
|
||||||
|
response_model=JailActivationResponse,
|
||||||
|
summary="Deactivate an active jail",
|
||||||
|
)
|
||||||
|
async def deactivate_jail(
|
||||||
|
app: AppDep,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
name: _NamePath,
|
||||||
|
) -> JailActivationResponse:
|
||||||
|
"""Disable an active jail and reload fail2ban.
|
||||||
|
|
||||||
|
Writes ``enabled = false`` to ``jail.d/{name}.local`` and triggers a
|
||||||
|
full fail2ban reload so the jail stops immediately.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: FastAPI application instance.
|
||||||
|
_auth: Validated session.
|
||||||
|
name: Name of the jail to deactivate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.JailActivationResponse`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if *name* contains invalid characters.
|
||||||
|
HTTPException: 404 if *name* is not found in any config file.
|
||||||
|
HTTPException: 409 if the jail is already inactive.
|
||||||
|
HTTPException: 502 if fail2ban is unreachable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await jail_config_service.deactivate_jail(app, config_dir, socket_path, name)
|
||||||
|
except JailNameError as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
except JailNotFoundInConfigError:
|
||||||
|
raise _not_found(name) from None
|
||||||
|
except JailAlreadyInactiveError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Jail {name!r} is already inactive.",
|
||||||
|
) from None
|
||||||
|
except ConfigWriteError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to write config override: {exc}",
|
||||||
|
) from exc
|
||||||
|
except Fail2BanConnectionError as exc:
|
||||||
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/jails/{name}/local",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Delete the jail.d override file for an inactive jail",
|
||||||
|
)
|
||||||
|
async def delete_jail_local_override(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
name: _NamePath,
|
||||||
|
) -> None:
|
||||||
|
"""Remove the ``jail.d/{name}.local`` override file for an inactive jail.
|
||||||
|
|
||||||
|
This endpoint is the clean-up action for inactive jails that still carry
|
||||||
|
a ``.local`` override file (e.g. one written with ``enabled = false`` by a
|
||||||
|
previous deactivation). The file is deleted without modifying fail2ban's
|
||||||
|
running state, since the jail is already inactive.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session.
|
||||||
|
name: Name of the jail whose ``.local`` file should be removed.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if *name* contains invalid characters.
|
||||||
|
HTTPException: 404 if *name* is not found in any config file.
|
||||||
|
HTTPException: 409 if the jail is currently active.
|
||||||
|
HTTPException: 500 if the file cannot be deleted.
|
||||||
|
HTTPException: 502 if fail2ban is unreachable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
await jail_config_service.delete_jail_local_override(config_dir, socket_path, name)
|
||||||
|
except JailNameError as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
except JailNotFoundInConfigError:
|
||||||
|
raise _not_found(name) from None
|
||||||
|
except JailAlreadyActiveError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Jail {name!r} is currently active; deactivate it first.",
|
||||||
|
) from None
|
||||||
|
except ConfigWriteError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to delete config override: {exc}",
|
||||||
|
) from exc
|
||||||
|
except Fail2BanConnectionError as exc:
|
||||||
|
raise _bad_gateway(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Jail validation & rollback endpoints (Task 3)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/jails/{name}/validate",
|
||||||
|
response_model=JailValidationResult,
|
||||||
|
summary="Validate jail configuration before activation",
|
||||||
|
)
|
||||||
|
async def validate_jail(
|
||||||
|
request: Request,
|
||||||
|
_auth: AuthDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
name: _NamePath,
|
||||||
|
) -> JailValidationResult:
|
||||||
|
"""Run pre-activation validation checks on a jail configuration.
|
||||||
|
|
||||||
|
Validates filter and action file existence, regex pattern compilation, and
|
||||||
|
log path existence without modifying any files or reloading fail2ban.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session.
|
||||||
|
name: Jail name to validate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.JailValidationResult` with any issues found.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if *name* contains invalid characters.
|
||||||
|
HTTPException: 404 if *name* is not found in any config file.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await jail_config_service.validate_jail_config(config_dir, name)
|
||||||
|
except JailNameError as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/pending-recovery",
|
||||||
|
response_model=PendingRecovery | None,
|
||||||
|
summary="Return active crash-recovery record if one exists",
|
||||||
|
)
|
||||||
|
async def get_pending_recovery(
|
||||||
|
_auth: AuthDep,
|
||||||
|
pending_recovery: PendingRecoveryDep,
|
||||||
|
) -> PendingRecovery | None:
|
||||||
|
"""Return the current :class:`~app.models.config.PendingRecovery` record.
|
||||||
|
|
||||||
|
A non-null response means fail2ban crashed shortly after a jail activation
|
||||||
|
and the user should be offered a rollback option. Returns ``null`` (HTTP
|
||||||
|
200 with ``null`` body) when no recovery is pending.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object.
|
||||||
|
_auth: Validated session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.PendingRecovery` or ``None``.
|
||||||
|
"""
|
||||||
|
return pending_recovery
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/jails/{name}/rollback",
|
||||||
|
response_model=RollbackResponse,
|
||||||
|
summary="Disable a bad jail config and restart fail2ban",
|
||||||
|
)
|
||||||
|
async def rollback_jail(
|
||||||
|
_auth: AuthDep,
|
||||||
|
app: AppDep,
|
||||||
|
config_dir: Fail2BanConfigDirDep,
|
||||||
|
socket_path: Fail2BanSocketDep,
|
||||||
|
start_cmd: Fail2BanStartCommandDep,
|
||||||
|
name: _NamePath,
|
||||||
|
) -> RollbackResponse:
|
||||||
|
"""Disable the specified jail and attempt to restart fail2ban.
|
||||||
|
|
||||||
|
Writes ``enabled = false`` to ``jail.d/{name}.local`` (works even when
|
||||||
|
fail2ban is down — no socket is needed), then runs the configured start
|
||||||
|
command and waits up to ten seconds for the daemon to come back online.
|
||||||
|
|
||||||
|
On success, clears the :class:`~app.models.config.PendingRecovery` record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
_auth: Validated session.
|
||||||
|
app: FastAPI application instance.
|
||||||
|
name: Jail name to disable and roll back.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
:class:`~app.models.config.RollbackResponse`.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if *name* contains invalid characters.
|
||||||
|
HTTPException: 500 if writing the .local override file fails.
|
||||||
|
"""
|
||||||
|
start_cmd_parts: list[str] = start_cmd.split()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await jail_config_service.rollback_jail(app, config_dir, socket_path, name, start_cmd_parts)
|
||||||
|
except JailNameError as exc:
|
||||||
|
raise _bad_request(str(exc)) from exc
|
||||||
|
except ConfigWriteError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to write config override: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Filter discovery endpoints (Task 2.1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user