Files
BanGUI/backend/app/routers/file_config.py
Lukas 8f26776bb3 docs: add OpenAPI responses={} to all router endpoints
Add explicit HTTP status code documentation to every endpoint
across 15 router files. Each endpoint now declares all possible
response codes (200/201/204/400/401/404/409/429/502/503) with
descriptions so frontend can distinguish error types.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 01:12:08 +02:00

728 lines
27 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}/raw`` — update a filter file (raw content)
* ``POST /api/config/filters/raw`` — create a new filter file (raw content)
* ``GET /api/config/filters/{name}/parsed`` — parse a filter file into a structured model
* ``PUT /api/config/filters/{name}/parsed`` — update a filter file from a structured model
* ``GET /api/config/actions`` — list all action files
* ``GET /api/config/actions/{name}/raw`` — get one action file (raw content)
* ``PUT /api/config/actions/{name}/raw`` — update an action file (raw content)
* ``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. Raw-content read/write variants are at ``/filters/{name}/raw``
and ``POST /filters/raw``.
"""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Path, status
from app.dependencies import AuthDep, Fail2BanConfigDirDep
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 raw_config_io_service
router: APIRouter = APIRouter(prefix="/api/v1/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``).")
]
# ---------------------------------------------------------------------------
# Jail config file endpoints (Task 4a)
# ---------------------------------------------------------------------------
@router.get(
"/jail-files",
response_model=JailConfigFilesResponse,
summary="List all jail config files",
responses={
200: {"description": "Jail config files returned", "model": JailConfigFilesResponse},
401: {"description": "Session missing, expired, or invalid"},
503: {"description": "Config directory unavailable"},
},
)
async def list_jail_config_files(
config_dir: Fail2BanConfigDirDep,
_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:
config_dir: Config directory path injected from application settings.
_auth: Validated session — enforces authentication.
Returns:
:class:`~app.models.file_config.JailConfigFilesResponse`.
"""
return await raw_config_io_service.list_jail_config_files(config_dir)
@router.get(
"/jail-files/{filename}",
response_model=JailConfigFileContent,
summary="Return a single jail config file with its content",
responses={
200: {"description": "Jail config file returned", "model": JailConfigFileContent},
400: {"description": "Filename unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "File not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_jail_config_file(
config_dir: Fail2BanConfigDirDep,
_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.
"""
return await raw_config_io_service.get_jail_config_file(config_dir, filename)
@router.put(
"/jail-files/{filename}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Overwrite a jail.d config file with new raw content",
responses={
204: {"description": "File overwritten successfully"},
400: {"description": "Filename unsafe or content invalid"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "File not found"},
503: {"description": "Config directory unavailable"},
},
)
async def write_jail_config_file(
config_dir: Fail2BanConfigDirDep,
_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.
"""
await raw_config_io_service.write_jail_config_file(config_dir, filename, body)
@router.put(
"/jail-files/{filename}/enabled",
status_code=status.HTTP_204_NO_CONTENT,
summary="Enable or disable a jail configuration file",
responses={
204: {"description": "Enabled state updated successfully"},
400: {"description": "Filename unsafe or operation failed"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "File not found"},
503: {"description": "Config directory unavailable"},
},
)
async def set_jail_config_file_enabled(
config_dir: Fail2BanConfigDirDep,
_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.
"""
await raw_config_io_service.set_jail_config_enabled(
config_dir, filename, body.enabled
)
@router.post(
"/jail-files",
response_model=ConfFileContent,
status_code=status.HTTP_201_CREATED,
summary="Create a new jail.d config file",
responses={
201: {"description": "File created", "model": ConfFileContent},
400: {"description": "Name unsafe or content exceeds size limit"},
401: {"description": "Session missing, expired, or invalid"},
409: {"description": "File with that name already exists"},
503: {"description": "Config directory unavailable"},
},
)
async def create_jail_config_file(
config_dir: Fail2BanConfigDirDep,
_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.
"""
filename = await raw_config_io_service.create_jail_config_file(config_dir, body)
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",
responses={
200: {"description": "Filter file returned", "model": ConfFileContent},
400: {"description": "Name unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "File not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_filter_file_raw(
config_dir: Fail2BanConfigDirDep,
_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.
"""
return await raw_config_io_service.get_filter_file(config_dir, name)
@router.put(
"/filters/{name}/raw",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update a filter definition file (raw content)",
responses={
204: {"description": "Filter file updated successfully"},
400: {"description": "Name unsafe or content exceeds size limit"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "File not found"},
503: {"description": "Config directory unavailable"},
},
)
async def write_filter_file(
config_dir: Fail2BanConfigDirDep,
_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.
"""
await raw_config_io_service.write_filter_file(config_dir, name, body)
@router.post(
"/filters/raw",
status_code=status.HTTP_201_CREATED,
response_model=ConfFileContent,
summary="Create a new filter definition file (raw content)",
responses={
201: {"description": "Filter file created", "model": ConfFileContent},
400: {"description": "Name invalid or content exceeds limit"},
401: {"description": "Session missing, expired, or invalid"},
409: {"description": "File with that name already exists"},
503: {"description": "Config directory unavailable"},
},
)
async def create_filter_file(
config_dir: Fail2BanConfigDirDep,
_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.
"""
filename = await raw_config_io_service.create_filter_file(config_dir, body)
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",
responses={
200: {"description": "Action files returned", "model": ConfFilesResponse},
401: {"description": "Session missing, expired, or invalid"},
503: {"description": "Config directory unavailable"},
},
)
async def list_action_files(
config_dir: Fail2BanConfigDirDep,
_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`.
"""
return await raw_config_io_service.list_action_files(config_dir)
@router.get(
"/actions/{name}/raw",
response_model=ConfFileContent,
summary="Return an action definition file with its content",
responses={
200: {"description": "Action file returned", "model": ConfFileContent},
400: {"description": "Name unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "File not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_action_file(
config_dir: Fail2BanConfigDirDep,
_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.
"""
return await raw_config_io_service.get_action_file(config_dir, name)
@router.put(
"/actions/{name}/raw",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update an action definition file",
responses={
204: {"description": "Action file updated successfully"},
400: {"description": "Name unsafe or content exceeds size limit"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "File not found"},
503: {"description": "Config directory unavailable"},
},
)
async def write_action_file(
config_dir: Fail2BanConfigDirDep,
_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.
"""
await raw_config_io_service.write_action_file(config_dir, name, body)
@router.post(
"/actions",
status_code=status.HTTP_201_CREATED,
response_model=ConfFileContent,
summary="Create a new action definition file",
responses={
201: {"description": "Action file created", "model": ConfFileContent},
400: {"description": "Name invalid or content exceeds limit"},
401: {"description": "Session missing, expired, or invalid"},
409: {"description": "File with that name already exists"},
503: {"description": "Config directory unavailable"},
},
)
async def create_action_file(
config_dir: Fail2BanConfigDirDep,
_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.
"""
filename = await raw_config_io_service.create_action_file(config_dir, body)
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",
responses={
200: {"description": "Filter config returned", "model": FilterConfig},
400: {"description": "Name unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Filter file not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_parsed_filter(
config_dir: Fail2BanConfigDirDep,
_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.
"""
return await raw_config_io_service.get_parsed_filter_file(config_dir, name)
@router.put(
"/filters/{name}/parsed",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update a filter file from a structured model",
responses={
204: {"description": "Filter file updated successfully"},
400: {"description": "Name unsafe or content exceeds size limit"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Filter file not found"},
503: {"description": "Config directory unavailable"},
},
)
async def update_parsed_filter(
config_dir: Fail2BanConfigDirDep,
_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.
"""
await raw_config_io_service.update_parsed_filter_file(config_dir, name, body)
# ---------------------------------------------------------------------------
# Parsed action endpoints (Task 3.1)
# ---------------------------------------------------------------------------
@router.get(
"/actions/{name}/parsed",
response_model=ActionConfig,
summary="Return an action file parsed into a structured model",
responses={
200: {"description": "Action config returned", "model": ActionConfig},
400: {"description": "Name unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Action file not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_parsed_action(
config_dir: Fail2BanConfigDirDep,
_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.
"""
return await raw_config_io_service.get_parsed_action_file(config_dir, name)
@router.put(
"/actions/{name}/parsed",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update an action file from a structured model",
responses={
204: {"description": "Action file updated successfully"},
400: {"description": "Name unsafe or content exceeds size limit"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Action file not found"},
503: {"description": "Config directory unavailable"},
},
)
async def update_parsed_action(
config_dir: Fail2BanConfigDirDep,
_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.
"""
await raw_config_io_service.update_parsed_action_file(config_dir, name, body)
# ---------------------------------------------------------------------------
# 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",
responses={
200: {"description": "Jail file config returned", "model": JailFileConfig},
400: {"description": "Filename unsafe"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail file not found"},
503: {"description": "Config directory unavailable"},
},
)
async def get_parsed_jail_file(
config_dir: Fail2BanConfigDirDep,
_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.
"""
return await raw_config_io_service.get_parsed_jail_file(config_dir, filename)
@router.put(
"/jail-files/{filename}/parsed",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update a jail.d file from a structured model",
responses={
204: {"description": "Jail file updated successfully"},
400: {"description": "Filename unsafe or content exceeds size limit"},
401: {"description": "Session missing, expired, or invalid"},
404: {"description": "Jail file not found"},
503: {"description": "Config directory unavailable"},
},
)
async def update_parsed_jail_file(
config_dir: Fail2BanConfigDirDep,
_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.
"""
await raw_config_io_service.update_parsed_jail_file(config_dir, filename, body)