- 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
832 lines
28 KiB
Python
832 lines
28 KiB
Python
"""File-based fail2ban configuration router.
|
|
|
|
Provides endpoints to list, view, edit, and create fail2ban configuration
|
|
files directly on the filesystem (``jail.d/``, ``filter.d/``, ``action.d/``).
|
|
|
|
Endpoints:
|
|
* ``GET /api/config/jail-files`` — list all jail config files
|
|
* ``GET /api/config/jail-files/{filename}`` — get one jail config file (with content)
|
|
* ``PUT /api/config/jail-files/{filename}`` — overwrite a jail config file
|
|
* ``PUT /api/config/jail-files/{filename}/enabled`` — enable/disable a jail config
|
|
* ``GET /api/config/filters/{name}/raw`` — get one filter file raw content
|
|
* ``PUT /api/config/filters/{name}`` — update a filter file
|
|
* ``POST /api/config/filters`` — create a new filter file
|
|
* ``GET /api/config/filters/{name}/parsed`` — parse a filter file into a structured model
|
|
* ``PUT /api/config/filters/{name}/parsed`` — update a filter file from a structured model
|
|
* ``GET /api/config/actions`` — list all action files
|
|
* ``GET /api/config/actions/{name}`` — get one action file (with content)
|
|
* ``PUT /api/config/actions/{name}`` — update an action file
|
|
* ``POST /api/config/actions`` — create a new action file
|
|
* ``GET /api/config/actions/{name}/parsed`` — parse an action file into a structured model
|
|
* ``PUT /api/config/actions/{name}/parsed`` — update an action file from a structured model
|
|
|
|
Note: ``GET /api/config/filters`` (enriched list) and
|
|
``GET /api/config/filters/{name}`` (full parsed detail) are handled by the
|
|
config router (``config.py``), which is registered first and therefore takes
|
|
precedence. The raw-content variant is at ``/filters/{name}/raw``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, HTTPException, Path, Request, status
|
|
|
|
from app.dependencies import AuthDep
|
|
from app.models.config import (
|
|
ActionConfig,
|
|
ActionConfigUpdate,
|
|
FilterConfig,
|
|
FilterConfigUpdate,
|
|
JailFileConfig,
|
|
JailFileConfigUpdate,
|
|
)
|
|
from app.models.file_config import (
|
|
ConfFileContent,
|
|
ConfFileCreateRequest,
|
|
ConfFilesResponse,
|
|
ConfFileUpdateRequest,
|
|
JailConfigFileContent,
|
|
JailConfigFileEnabledUpdate,
|
|
JailConfigFilesResponse,
|
|
)
|
|
from app.services import file_config_service
|
|
from app.services.file_config_service import (
|
|
ConfigDirError,
|
|
ConfigFileExistsError,
|
|
ConfigFileNameError,
|
|
ConfigFileNotFoundError,
|
|
ConfigFileWriteError,
|
|
)
|
|
|
|
router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"])
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Path type aliases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_FilenamePath = Annotated[
|
|
str, Path(description="Config filename including extension (e.g. ``sshd.conf``).")
|
|
]
|
|
_NamePath = Annotated[
|
|
str, Path(description="Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).")
|
|
]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _not_found(filename: str) -> HTTPException:
|
|
return HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Config file not found: {filename!r}",
|
|
)
|
|
|
|
|
|
def _bad_request(message: str) -> HTTPException:
|
|
return HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=message,
|
|
)
|
|
|
|
|
|
def _conflict(filename: str) -> HTTPException:
|
|
return HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Config file already exists: {filename!r}",
|
|
)
|
|
|
|
|
|
def _service_unavailable(message: str) -> HTTPException:
|
|
return HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail=message,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Jail config file endpoints (Task 4a)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/jail-files",
|
|
response_model=JailConfigFilesResponse,
|
|
summary="List all jail config files",
|
|
)
|
|
async def list_jail_config_files(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
) -> JailConfigFilesResponse:
|
|
"""Return metadata for every ``.conf`` and ``.local`` file in ``jail.d/``.
|
|
|
|
The ``enabled`` field reflects the value of the ``enabled`` key inside the
|
|
file (defaulting to ``true`` when the key is absent).
|
|
|
|
Args:
|
|
request: Incoming request (used for ``app.state.settings``).
|
|
_auth: Validated session — enforces authentication.
|
|
|
|
Returns:
|
|
:class:`~app.models.file_config.JailConfigFilesResponse`.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
return await file_config_service.list_jail_config_files(config_dir)
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|
|
|
|
|
|
@router.get(
|
|
"/jail-files/{filename}",
|
|
response_model=JailConfigFileContent,
|
|
summary="Return a single jail config file with its content",
|
|
)
|
|
async def get_jail_config_file(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
filename: _FilenamePath,
|
|
) -> JailConfigFileContent:
|
|
"""Return the metadata and raw content of one jail config file.
|
|
|
|
Args:
|
|
request: Incoming request.
|
|
_auth: Validated session.
|
|
filename: Filename including extension (e.g. ``sshd.conf``).
|
|
|
|
Returns:
|
|
:class:`~app.models.file_config.JailConfigFileContent`.
|
|
|
|
Raises:
|
|
HTTPException: 400 if *filename* is unsafe.
|
|
HTTPException: 404 if the file does not exist.
|
|
HTTPException: 503 if the config directory is unavailable.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
return await file_config_service.get_jail_config_file(config_dir, filename)
|
|
except ConfigFileNameError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigFileNotFoundError:
|
|
raise _not_found(filename) from None
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|
|
|
|
|
|
@router.put(
|
|
"/jail-files/{filename}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
summary="Overwrite a jail.d config file with new raw content",
|
|
)
|
|
async def write_jail_config_file(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
filename: _FilenamePath,
|
|
body: ConfFileUpdateRequest,
|
|
) -> None:
|
|
"""Overwrite the raw content of an existing jail.d config file.
|
|
|
|
The change is written directly to disk. You must reload fail2ban
|
|
(``POST /api/config/reload``) separately for the change to take effect.
|
|
|
|
Args:
|
|
request: Incoming request.
|
|
_auth: Validated session.
|
|
filename: Filename of the jail config file (e.g. ``sshd.conf``).
|
|
body: New raw file content.
|
|
|
|
Raises:
|
|
HTTPException: 400 if *filename* is unsafe or content is invalid.
|
|
HTTPException: 404 if the file does not exist.
|
|
HTTPException: 503 if the config directory is unavailable.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
await file_config_service.write_jail_config_file(config_dir, filename, body)
|
|
except ConfigFileNameError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigFileNotFoundError:
|
|
raise _not_found(filename) from None
|
|
except ConfigFileWriteError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|
|
|
|
|
|
@router.put(
|
|
"/jail-files/{filename}/enabled",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
summary="Enable or disable a jail configuration file",
|
|
)
|
|
async def set_jail_config_file_enabled(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
filename: _FilenamePath,
|
|
body: JailConfigFileEnabledUpdate,
|
|
) -> None:
|
|
"""Set the ``enabled = true/false`` key inside a jail config file.
|
|
|
|
The change modifies the file on disk. You must reload fail2ban
|
|
(``POST /api/config/reload``) separately for the change to take effect.
|
|
|
|
Args:
|
|
request: Incoming request.
|
|
_auth: Validated session.
|
|
filename: Filename of the jail config file (e.g. ``sshd.conf``).
|
|
body: New enabled state.
|
|
|
|
Raises:
|
|
HTTPException: 400 if *filename* is unsafe or the operation fails.
|
|
HTTPException: 404 if the file does not exist.
|
|
HTTPException: 503 if the config directory is unavailable.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
await file_config_service.set_jail_config_enabled(
|
|
config_dir, filename, body.enabled
|
|
)
|
|
except ConfigFileNameError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigFileNotFoundError:
|
|
raise _not_found(filename) from None
|
|
except ConfigFileWriteError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|
|
|
|
|
|
@router.post(
|
|
"/jail-files",
|
|
response_model=ConfFileContent,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Create a new jail.d config file",
|
|
)
|
|
async def create_jail_config_file(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
body: ConfFileCreateRequest,
|
|
) -> ConfFileContent:
|
|
"""Create a new ``.conf`` file in ``jail.d/``.
|
|
|
|
Args:
|
|
request: Incoming request.
|
|
_auth: Validated session.
|
|
body: :class:`~app.models.file_config.ConfFileCreateRequest` with name and content.
|
|
|
|
Returns:
|
|
:class:`~app.models.file_config.ConfFileContent` with the created file metadata.
|
|
|
|
Raises:
|
|
HTTPException: 400 if the name is unsafe or the content exceeds the size limit.
|
|
HTTPException: 409 if a file with that name already exists.
|
|
HTTPException: 503 if the config directory is unavailable.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
filename = await file_config_service.create_jail_config_file(config_dir, body)
|
|
except ConfigFileNameError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigFileExistsError:
|
|
raise _conflict(body.name) from None
|
|
except ConfigFileWriteError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|
|
|
|
return ConfFileContent(
|
|
name=body.name,
|
|
filename=filename,
|
|
content=body.content,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Filter file endpoints (Task 4d)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/filters/{name}/raw",
|
|
response_model=ConfFileContent,
|
|
summary="Return a filter definition file's raw content",
|
|
)
|
|
async def get_filter_file_raw(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
name: _NamePath,
|
|
) -> ConfFileContent:
|
|
"""Return the raw content of a filter definition file.
|
|
|
|
This endpoint provides direct access to the file bytes for the raw
|
|
config editor. For structured parsing with active/inactive status use
|
|
``GET /api/config/filters/{name}`` (served by the config router).
|
|
|
|
Args:
|
|
request: Incoming request.
|
|
_auth: Validated session.
|
|
name: Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).
|
|
|
|
Returns:
|
|
:class:`~app.models.file_config.ConfFileContent`.
|
|
|
|
Raises:
|
|
HTTPException: 400 if *name* is unsafe.
|
|
HTTPException: 404 if the file does not exist.
|
|
HTTPException: 503 if the config directory is unavailable.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
return await file_config_service.get_filter_file(config_dir, name)
|
|
except ConfigFileNameError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigFileNotFoundError:
|
|
raise _not_found(name) from None
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|
|
|
|
|
|
@router.put(
|
|
"/filters/{name}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
summary="Update a filter definition file",
|
|
)
|
|
async def write_filter_file(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
name: _NamePath,
|
|
body: ConfFileUpdateRequest,
|
|
) -> None:
|
|
"""Overwrite the content of an existing filter definition file.
|
|
|
|
Args:
|
|
request: Incoming request.
|
|
_auth: Validated session.
|
|
name: Base name with or without extension.
|
|
body: New file content.
|
|
|
|
Raises:
|
|
HTTPException: 400 if *name* is unsafe or content exceeds the size limit.
|
|
HTTPException: 404 if the file does not exist.
|
|
HTTPException: 503 if the config directory is unavailable.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
await file_config_service.write_filter_file(config_dir, name, body)
|
|
except ConfigFileNameError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigFileNotFoundError:
|
|
raise _not_found(name) from None
|
|
except ConfigFileWriteError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|
|
|
|
|
|
@router.post(
|
|
"/filters",
|
|
status_code=status.HTTP_201_CREATED,
|
|
response_model=ConfFileContent,
|
|
summary="Create a new filter definition file",
|
|
)
|
|
async def create_filter_file(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
body: ConfFileCreateRequest,
|
|
) -> ConfFileContent:
|
|
"""Create a new ``.conf`` file in ``filter.d/``.
|
|
|
|
Args:
|
|
request: Incoming request.
|
|
_auth: Validated session.
|
|
body: Name and initial content for the new file.
|
|
|
|
Returns:
|
|
The created :class:`~app.models.file_config.ConfFileContent`.
|
|
|
|
Raises:
|
|
HTTPException: 400 if *name* is invalid or content exceeds limit.
|
|
HTTPException: 409 if a file with that name already exists.
|
|
HTTPException: 503 if the config directory is unavailable.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
filename = await file_config_service.create_filter_file(config_dir, body)
|
|
except ConfigFileNameError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigFileExistsError:
|
|
raise _conflict(body.name) from None
|
|
except ConfigFileWriteError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|
|
|
|
return ConfFileContent(
|
|
name=body.name,
|
|
filename=filename,
|
|
content=body.content,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Action file endpoints (Task 4e)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/actions",
|
|
response_model=ConfFilesResponse,
|
|
summary="List all action definition files",
|
|
)
|
|
async def list_action_files(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
) -> ConfFilesResponse:
|
|
"""Return a list of every ``.conf`` and ``.local`` file in ``action.d/``.
|
|
|
|
Args:
|
|
request: Incoming request.
|
|
_auth: Validated session.
|
|
|
|
Returns:
|
|
:class:`~app.models.file_config.ConfFilesResponse`.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
return await file_config_service.list_action_files(config_dir)
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|
|
|
|
|
|
@router.get(
|
|
"/actions/{name}",
|
|
response_model=ConfFileContent,
|
|
summary="Return an action definition file with its content",
|
|
)
|
|
async def get_action_file(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
name: _NamePath,
|
|
) -> ConfFileContent:
|
|
"""Return the content of an action definition file.
|
|
|
|
Args:
|
|
request: Incoming request.
|
|
_auth: Validated session.
|
|
name: Base name with or without extension.
|
|
|
|
Returns:
|
|
:class:`~app.models.file_config.ConfFileContent`.
|
|
|
|
Raises:
|
|
HTTPException: 400 if *name* is unsafe.
|
|
HTTPException: 404 if the file does not exist.
|
|
HTTPException: 503 if the config directory is unavailable.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
return await file_config_service.get_action_file(config_dir, name)
|
|
except ConfigFileNameError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigFileNotFoundError:
|
|
raise _not_found(name) from None
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|
|
|
|
|
|
@router.put(
|
|
"/actions/{name}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
summary="Update an action definition file",
|
|
)
|
|
async def write_action_file(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
name: _NamePath,
|
|
body: ConfFileUpdateRequest,
|
|
) -> None:
|
|
"""Overwrite the content of an existing action definition file.
|
|
|
|
Args:
|
|
request: Incoming request.
|
|
_auth: Validated session.
|
|
name: Base name with or without extension.
|
|
body: New file content.
|
|
|
|
Raises:
|
|
HTTPException: 400 if *name* is unsafe or content exceeds the size limit.
|
|
HTTPException: 404 if the file does not exist.
|
|
HTTPException: 503 if the config directory is unavailable.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
await file_config_service.write_action_file(config_dir, name, body)
|
|
except ConfigFileNameError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigFileNotFoundError:
|
|
raise _not_found(name) from None
|
|
except ConfigFileWriteError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|
|
|
|
|
|
@router.post(
|
|
"/actions",
|
|
status_code=status.HTTP_201_CREATED,
|
|
response_model=ConfFileContent,
|
|
summary="Create a new action definition file",
|
|
)
|
|
async def create_action_file(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
body: ConfFileCreateRequest,
|
|
) -> ConfFileContent:
|
|
"""Create a new ``.conf`` file in ``action.d/``.
|
|
|
|
Args:
|
|
request: Incoming request.
|
|
_auth: Validated session.
|
|
body: Name and initial content for the new file.
|
|
|
|
Returns:
|
|
The created :class:`~app.models.file_config.ConfFileContent`.
|
|
|
|
Raises:
|
|
HTTPException: 400 if *name* is invalid or content exceeds limit.
|
|
HTTPException: 409 if a file with that name already exists.
|
|
HTTPException: 503 if the config directory is unavailable.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
filename = await file_config_service.create_action_file(config_dir, body)
|
|
except ConfigFileNameError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigFileExistsError:
|
|
raise _conflict(body.name) from None
|
|
except ConfigFileWriteError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|
|
|
|
return ConfFileContent(
|
|
name=body.name,
|
|
filename=filename,
|
|
content=body.content,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parsed filter endpoints (Task 2.1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/filters/{name}/parsed",
|
|
response_model=FilterConfig,
|
|
summary="Return a filter file parsed into a structured model",
|
|
)
|
|
async def get_parsed_filter(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
name: _NamePath,
|
|
) -> FilterConfig:
|
|
"""Parse a filter definition file and return its structured fields.
|
|
|
|
The file is read from ``filter.d/``, parsed as fail2ban INI format, and
|
|
returned as a :class:`~app.models.config.FilterConfig` JSON object. This
|
|
is the input model for the form-based filter editor (Task 2.3).
|
|
|
|
Args:
|
|
request: Incoming request.
|
|
_auth: Validated session.
|
|
name: Base name (e.g. ``sshd`` or ``sshd.conf``).
|
|
|
|
Returns:
|
|
:class:`~app.models.config.FilterConfig`.
|
|
|
|
Raises:
|
|
HTTPException: 400 if *name* is unsafe.
|
|
HTTPException: 404 if the file does not exist.
|
|
HTTPException: 503 if the config directory is unavailable.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
return await file_config_service.get_parsed_filter_file(config_dir, name)
|
|
except ConfigFileNameError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigFileNotFoundError:
|
|
raise _not_found(name) from None
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|
|
|
|
|
|
@router.put(
|
|
"/filters/{name}/parsed",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
summary="Update a filter file from a structured model",
|
|
)
|
|
async def update_parsed_filter(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
name: _NamePath,
|
|
body: FilterConfigUpdate,
|
|
) -> None:
|
|
"""Apply a partial structured update to a filter definition file.
|
|
|
|
Fields set to ``null`` in the request body are left unchanged. The file is
|
|
re-serialized to fail2ban INI format after merging.
|
|
|
|
Args:
|
|
request: Incoming request.
|
|
_auth: Validated session.
|
|
name: Base name of the filter to update.
|
|
body: Partial :class:`~app.models.config.FilterConfigUpdate`.
|
|
|
|
Raises:
|
|
HTTPException: 400 if *name* is unsafe or content exceeds the size limit.
|
|
HTTPException: 404 if the file does not exist.
|
|
HTTPException: 503 if the config directory is unavailable.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
await file_config_service.update_parsed_filter_file(config_dir, name, body)
|
|
except ConfigFileNameError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigFileNotFoundError:
|
|
raise _not_found(name) from None
|
|
except ConfigFileWriteError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parsed action endpoints (Task 3.1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/actions/{name}/parsed",
|
|
response_model=ActionConfig,
|
|
summary="Return an action file parsed into a structured model",
|
|
)
|
|
async def get_parsed_action(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
name: _NamePath,
|
|
) -> ActionConfig:
|
|
"""Parse an action definition file and return its structured fields.
|
|
|
|
The file is read from ``action.d/``, parsed as fail2ban INI format, and
|
|
returned as a :class:`~app.models.config.ActionConfig` JSON object. This
|
|
is the input model for the form-based action editor (Task 3.3).
|
|
|
|
Args:
|
|
request: Incoming request.
|
|
_auth: Validated session.
|
|
name: Base name (e.g. ``iptables`` or ``iptables.conf``).
|
|
|
|
Returns:
|
|
:class:`~app.models.config.ActionConfig`.
|
|
|
|
Raises:
|
|
HTTPException: 400 if *name* is unsafe.
|
|
HTTPException: 404 if the file does not exist.
|
|
HTTPException: 503 if the config directory is unavailable.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
return await file_config_service.get_parsed_action_file(config_dir, name)
|
|
except ConfigFileNameError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigFileNotFoundError:
|
|
raise _not_found(name) from None
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|
|
|
|
|
|
@router.put(
|
|
"/actions/{name}/parsed",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
summary="Update an action file from a structured model",
|
|
)
|
|
async def update_parsed_action(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
name: _NamePath,
|
|
body: ActionConfigUpdate,
|
|
) -> None:
|
|
"""Apply a partial structured update to an action definition file.
|
|
|
|
Fields set to ``null`` in the request body are left unchanged. The file is
|
|
re-serialized to fail2ban INI format after merging.
|
|
|
|
Args:
|
|
request: Incoming request.
|
|
_auth: Validated session.
|
|
name: Base name of the action to update.
|
|
body: Partial :class:`~app.models.config.ActionConfigUpdate`.
|
|
|
|
Raises:
|
|
HTTPException: 400 if *name* is unsafe or content exceeds the size limit.
|
|
HTTPException: 404 if the file does not exist.
|
|
HTTPException: 503 if the config directory is unavailable.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
await file_config_service.update_parsed_action_file(config_dir, name, body)
|
|
except ConfigFileNameError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigFileNotFoundError:
|
|
raise _not_found(name) from None
|
|
except ConfigFileWriteError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parsed jail file endpoints (Task 6.1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/jail-files/{filename}/parsed",
|
|
response_model=JailFileConfig,
|
|
summary="Return a jail.d file parsed into a structured model",
|
|
)
|
|
async def get_parsed_jail_file(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
filename: _NamePath,
|
|
) -> JailFileConfig:
|
|
"""Parse a jail.d config file and return its structured fields.
|
|
|
|
The file is read from ``jail.d/``, parsed as fail2ban INI format, and
|
|
returned as a :class:`~app.models.config.JailFileConfig` JSON object. This
|
|
is the input model for the form-based jail file editor (Task 6.2).
|
|
|
|
Args:
|
|
request: Incoming request.
|
|
_auth: Validated session.
|
|
filename: Filename including extension (e.g. ``sshd.conf``).
|
|
|
|
Returns:
|
|
:class:`~app.models.config.JailFileConfig`.
|
|
|
|
Raises:
|
|
HTTPException: 400 if *filename* is unsafe.
|
|
HTTPException: 404 if the file does not exist.
|
|
HTTPException: 503 if the config directory is unavailable.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
return await file_config_service.get_parsed_jail_file(config_dir, filename)
|
|
except ConfigFileNameError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigFileNotFoundError:
|
|
raise _not_found(filename) from None
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|
|
|
|
|
|
@router.put(
|
|
"/jail-files/{filename}/parsed",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
summary="Update a jail.d file from a structured model",
|
|
)
|
|
async def update_parsed_jail_file(
|
|
request: Request,
|
|
_auth: AuthDep,
|
|
filename: _NamePath,
|
|
body: JailFileConfigUpdate,
|
|
) -> None:
|
|
"""Apply a partial structured update to a jail.d config file.
|
|
|
|
Fields set to ``null`` in the request body are left unchanged. The file is
|
|
re-serialized to fail2ban INI format after merging.
|
|
|
|
Args:
|
|
request: Incoming request.
|
|
_auth: Validated session.
|
|
filename: Filename including extension (e.g. ``sshd.conf``).
|
|
body: Partial :class:`~app.models.config.JailFileConfigUpdate`.
|
|
|
|
Raises:
|
|
HTTPException: 400 if *filename* is unsafe or content exceeds size limit.
|
|
HTTPException: 404 if the file does not exist.
|
|
HTTPException: 503 if the config directory is unavailable.
|
|
"""
|
|
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
|
try:
|
|
await file_config_service.update_parsed_jail_file(config_dir, filename, body)
|
|
except ConfigFileNameError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigFileNotFoundError:
|
|
raise _not_found(filename) from None
|
|
except ConfigFileWriteError as exc:
|
|
raise _bad_request(str(exc)) from exc
|
|
except ConfigDirError as exc:
|
|
raise _service_unavailable(str(exc)) from exc
|