- Add list_filters() and get_filter() to config_file_service.py:
scans filter.d/, parses [Definition] + [Init] sections, merges .local
overrides, and cross-references running jails to set active/used_by_jails
- Add FilterConfig.active, used_by_jails, source_file, has_local_override
fields to the Pydantic model; add FilterListResponse and FilterNotFoundError
- Add GET /api/config/filters and GET /api/config/filters/{name} to config.py
- Remove the shadowed GET /api/config/filters list route from file_config.py;
rename GET /api/config/filters/{name} raw variant to /filters/{name}/raw
- Update frontend: fetchFilterFiles() adapts FilterListResponse -> ConfFilesResponse;
add fetchFilters() and fetchFilter() to api/config.ts; remove unused
fetchFilterFiles/fetchActionFiles calls from useConfigActiveStatus
- Fix ConfigPageLogPath test mock to include fetchInactiveJails and related
exports introduced by Stage 1
- Backend: 169 tests pass, mypy --strict clean, ruff clean
- Frontend: 63 tests pass, tsc --noEmit clean, eslint clean
734 lines
23 KiB
Python
734 lines
23 KiB
Python
"""Configuration router.
|
|
|
|
Provides endpoints to inspect and edit fail2ban jail configuration and
|
|
global settings, test regex patterns, add log paths, and preview log files.
|
|
|
|
* ``GET /api/config/jails`` — list all jail configs
|
|
* ``GET /api/config/jails/{name}`` — full config for one jail
|
|
* ``PUT /api/config/jails/{name}`` — update a jail's config
|
|
* ``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
|
|
* ``GET /api/config/global`` — global fail2ban settings
|
|
* ``PUT /api/config/global`` — update global settings
|
|
* ``POST /api/config/reload`` — reload fail2ban
|
|
* ``POST /api/config/regex-test`` — test a regex pattern
|
|
* ``POST /api/config/jails/{name}/logpath`` — add a log path to a jail
|
|
* ``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
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, HTTPException, Path, Query, Request, status
|
|
|
|
from app.dependencies import AuthDep
|
|
from app.models.config import (
|
|
ActivateJailRequest,
|
|
AddLogPathRequest,
|
|
FilterConfig,
|
|
FilterListResponse,
|
|
GlobalConfigResponse,
|
|
GlobalConfigUpdate,
|
|
InactiveJailListResponse,
|
|
JailActivationResponse,
|
|
JailConfigListResponse,
|
|
JailConfigResponse,
|
|
JailConfigUpdate,
|
|
LogPreviewRequest,
|
|
LogPreviewResponse,
|
|
MapColorThresholdsResponse,
|
|
MapColorThresholdsUpdate,
|
|
RegexTestRequest,
|
|
RegexTestResponse,
|
|
)
|
|
from app.services import config_file_service, config_service, jail_service
|
|
from app.services.config_file_service import (
|
|
ConfigWriteError,
|
|
FilterNotFoundError,
|
|
JailAlreadyActiveError,
|
|
JailAlreadyInactiveError,
|
|
JailNameError,
|
|
JailNotFoundInConfigError,
|
|
)
|
|
from app.services.config_service import (
|
|
ConfigOperationError,
|
|
ConfigValidationError,
|
|
JailNotFoundError,
|
|
)
|
|
from app.utils.fail2ban_client import Fail2BanConnectionError
|
|
|
|
router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"])
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_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,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Jail configuration endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/jails",
|
|
response_model=JailConfigListResponse,
|
|
summary="List configuration for all active jails",
|
|
)
|
|
async def get_jail_configs(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
) -> 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`.
|
|
"""
|
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
|
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,
|
|
) -> 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`.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
|
return await config_file_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,
|
|
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.
|
|
"""
|
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
|
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,
|
|
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.
|
|
"""
|
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
|
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.get(
|
|
"/global",
|
|
response_model=GlobalConfigResponse,
|
|
summary="Return global fail2ban settings",
|
|
)
|
|
async def get_global_config(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
) -> 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.
|
|
"""
|
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
|
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,
|
|
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.
|
|
"""
|
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
|
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,
|
|
) -> 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: 502 when fail2ban is unreachable.
|
|
"""
|
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
|
try:
|
|
await jail_service.reload_all(socket_path)
|
|
except Fail2BanConnectionError as exc:
|
|
raise _bad_gateway(exc) from exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 config_service.test_regex(body)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Log path management
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@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,
|
|
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.
|
|
"""
|
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
|
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,
|
|
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.
|
|
"""
|
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
|
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(
|
|
"/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 config_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,
|
|
) -> 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(
|
|
request.app.state.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,
|
|
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(
|
|
request.app.state.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.post(
|
|
"/jails/{name}/activate",
|
|
response_model=JailActivationResponse,
|
|
summary="Activate an inactive jail",
|
|
)
|
|
async def activate_jail(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
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:
|
|
request: FastAPI request object.
|
|
_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.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
|
req = body if body is not None else ActivateJailRequest()
|
|
|
|
try:
|
|
return await config_file_service.activate_jail(
|
|
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
|
|
|
|
|
|
@router.post(
|
|
"/jails/{name}/deactivate",
|
|
response_model=JailActivationResponse,
|
|
summary="Deactivate an active jail",
|
|
)
|
|
async def deactivate_jail(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
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:
|
|
request: FastAPI request object.
|
|
_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.
|
|
"""
|
|
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.deactivate_jail(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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Filter discovery endpoints (Task 2.1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/filters",
|
|
response_model=FilterListResponse,
|
|
summary="List all available filters with active/inactive status",
|
|
)
|
|
async def list_filters(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
) -> 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.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
socket_path: str = request.app.state.settings.fail2ban_socket
|
|
result = await config_file_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,
|
|
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.
|
|
"""
|
|
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.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
|