feat(backend): add file-config CRUD service and router

- file_config_service.py: service layer for reading, writing, and
  validating fail2ban conf files (jail.local, action.d/*, filter.d/*)
- file_config.py: REST router exposing GET/PUT endpoints for conf-file
  contents, sections, and key-value pairs; supports jails, actions,
  and filters
This commit is contained in:
2026-03-13 13:47:19 +01:00
parent 63b48849a7
commit 673eb4c7c2
2 changed files with 555 additions and 0 deletions

View File

@@ -11,10 +11,14 @@ Endpoints:
* ``GET /api/config/filters/{name}`` — get one filter file (with content) * ``GET /api/config/filters/{name}`` — get one filter file (with content)
* ``PUT /api/config/filters/{name}`` — update a filter file * ``PUT /api/config/filters/{name}`` — update a filter file
* ``POST /api/config/filters`` — create a new 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`` — list all action files
* ``GET /api/config/actions/{name}`` — get one action file (with content) * ``GET /api/config/actions/{name}`` — get one action file (with content)
* ``PUT /api/config/actions/{name}`` — update an action file * ``PUT /api/config/actions/{name}`` — update an action file
* ``POST /api/config/actions`` — create a new 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
""" """
from __future__ import annotations from __future__ import annotations
@@ -24,6 +28,14 @@ from typing import Annotated
from fastapi import APIRouter, HTTPException, Path, Request, status from fastapi import APIRouter, HTTPException, Path, Request, status
from app.dependencies import AuthDep from app.dependencies import AuthDep
from app.models.config import (
ActionConfig,
ActionConfigUpdate,
FilterConfig,
FilterConfigUpdate,
JailFileConfig,
JailFileConfigUpdate,
)
from app.models.file_config import ( from app.models.file_config import (
ConfFileContent, ConfFileContent,
ConfFileCreateRequest, ConfFileCreateRequest,
@@ -199,6 +211,51 @@ async def set_jail_config_file_enabled(
raise _service_unavailable(str(exc)) from 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) # Filter file endpoints (Task 4d)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -493,3 +550,258 @@ async def create_action_file(
filename=filename, filename=filename,
content=body.content, 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

View File

@@ -19,6 +19,7 @@ import asyncio
import configparser import configparser
import re import re
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
import structlog import structlog
@@ -33,6 +34,16 @@ from app.models.file_config import (
JailConfigFilesResponse, JailConfigFilesResponse,
) )
if TYPE_CHECKING:
from app.models.config import (
ActionConfig,
ActionConfigUpdate,
FilterConfig,
FilterConfigUpdate,
JailFileConfig,
JailFileConfigUpdate,
)
log: structlog.stdlib.BoundLogger = structlog.get_logger() log: structlog.stdlib.BoundLogger = structlog.get_logger()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -378,6 +389,35 @@ async def set_jail_config_enabled(
await asyncio.get_event_loop().run_in_executor(None, _do) await asyncio.get_event_loop().run_in_executor(None, _do)
async def create_jail_config_file(
config_dir: str,
req: ConfFileCreateRequest,
) -> str:
"""Create a new jail.d config file.
Args:
config_dir: Path to the fail2ban configuration directory.
req: :class:`~app.models.file_config.ConfFileCreateRequest`.
Returns:
The filename that was created.
Raises:
ConfigFileExistsError: If a file with that name already exists.
ConfigFileNameError: If the name is invalid.
ConfigFileWriteError: If the file cannot be created.
ConfigDirError: If *config_dir* does not exist.
"""
def _do() -> str:
jail_d = _resolve_subdir(config_dir, "jail.d")
filename = _create_conf_file(jail_d, req.name, req.content)
log.info("jail_config_file_created", filename=filename)
return filename
return await asyncio.get_event_loop().run_in_executor(None, _do)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Internal helpers — generic conf file listing / reading / writing # Internal helpers — generic conf file listing / reading / writing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -723,3 +763,206 @@ async def create_action_file(
return filename return filename
return await asyncio.get_event_loop().run_in_executor(None, _do) return await asyncio.get_event_loop().run_in_executor(None, _do)
# ---------------------------------------------------------------------------
# Public API — structured (parsed) filter files (Task 2.1)
# ---------------------------------------------------------------------------
async def get_parsed_filter_file(config_dir: str, name: str) -> FilterConfig:
"""Parse a filter definition file and return its structured representation.
Reads the raw ``.conf``/``.local`` file from ``filter.d/``, parses it with
:func:`~app.services.conffile_parser.parse_filter_file`, and returns the
result.
Args:
config_dir: Path to the fail2ban configuration directory.
name: Base name with or without extension.
Returns:
:class:`~app.models.config.FilterConfig`.
Raises:
ConfigFileNotFoundError: If no matching file is found.
ConfigDirError: If *config_dir* does not exist.
"""
from app.services.conffile_parser import parse_filter_file # avoid circular imports
def _do() -> FilterConfig:
filter_d = _resolve_subdir(config_dir, "filter.d")
raw = _read_conf_file(filter_d, name)
result = parse_filter_file(raw.content, name=raw.name, filename=raw.filename)
log.debug("filter_file_parsed", name=raw.name)
return result
return await asyncio.get_event_loop().run_in_executor(None, _do)
async def update_parsed_filter_file(
config_dir: str,
name: str,
update: FilterConfigUpdate,
) -> None:
"""Apply a structured partial update to a filter definition file.
Reads the existing file, merges *update* onto it, serializes to INI format,
and writes the result back to disk.
Args:
config_dir: Path to the fail2ban configuration directory.
name: Base name of the file to update.
update: Partial fields to apply.
Raises:
ConfigFileNotFoundError: If no matching file is found.
ConfigFileWriteError: If the file cannot be written.
ConfigDirError: If *config_dir* does not exist.
"""
from app.services.conffile_parser import ( # avoid circular imports
merge_filter_update,
parse_filter_file,
serialize_filter_config,
)
def _do() -> None:
filter_d = _resolve_subdir(config_dir, "filter.d")
raw = _read_conf_file(filter_d, name)
current = parse_filter_file(raw.content, name=raw.name, filename=raw.filename)
merged = merge_filter_update(current, update)
new_content = serialize_filter_config(merged)
_validate_content(new_content)
_write_conf_file(filter_d, name, new_content)
log.info("filter_file_updated_parsed", name=name)
await asyncio.get_event_loop().run_in_executor(None, _do)
# ---------------------------------------------------------------------------
# Public API — structured (parsed) action files (Task 3.1)
# ---------------------------------------------------------------------------
async def get_parsed_action_file(config_dir: str, name: str) -> ActionConfig:
"""Parse an action definition file and return its structured representation.
Args:
config_dir: Path to the fail2ban configuration directory.
name: Base name with or without extension.
Returns:
:class:`~app.models.config.ActionConfig`.
Raises:
ConfigFileNotFoundError: If no matching file is found.
ConfigDirError: If *config_dir* does not exist.
"""
from app.services.conffile_parser import parse_action_file # avoid circular imports
def _do() -> ActionConfig:
action_d = _resolve_subdir(config_dir, "action.d")
raw = _read_conf_file(action_d, name)
result = parse_action_file(raw.content, name=raw.name, filename=raw.filename)
log.debug("action_file_parsed", name=raw.name)
return result
return await asyncio.get_event_loop().run_in_executor(None, _do)
async def update_parsed_action_file(
config_dir: str,
name: str,
update: ActionConfigUpdate,
) -> None:
"""Apply a structured partial update to an action definition file.
Args:
config_dir: Path to the fail2ban configuration directory.
name: Base name of the file to update.
update: Partial fields to apply.
Raises:
ConfigFileNotFoundError: If no matching file is found.
ConfigFileWriteError: If the file cannot be written.
ConfigDirError: If *config_dir* does not exist.
"""
from app.services.conffile_parser import ( # avoid circular imports
merge_action_update,
parse_action_file,
serialize_action_config,
)
def _do() -> None:
action_d = _resolve_subdir(config_dir, "action.d")
raw = _read_conf_file(action_d, name)
current = parse_action_file(raw.content, name=raw.name, filename=raw.filename)
merged = merge_action_update(current, update)
new_content = serialize_action_config(merged)
_validate_content(new_content)
_write_conf_file(action_d, name, new_content)
log.info("action_file_updated_parsed", name=name)
await asyncio.get_event_loop().run_in_executor(None, _do)
async def get_parsed_jail_file(config_dir: str, filename: str) -> JailFileConfig:
"""Parse a jail.d config file into a structured :class:`~app.models.config.JailFileConfig`.
Args:
config_dir: Path to the fail2ban configuration directory.
filename: Filename including extension (e.g. ``"sshd.conf"``).
Returns:
:class:`~app.models.config.JailFileConfig`.
Raises:
ConfigFileNotFoundError: If no matching file is found.
ConfigDirError: If *config_dir* does not exist.
"""
from app.services.conffile_parser import parse_jail_file # avoid circular imports
def _do() -> JailFileConfig:
jail_d = _resolve_subdir(config_dir, "jail.d")
raw = _read_conf_file(jail_d, filename)
result = parse_jail_file(raw.content, filename=raw.filename)
log.debug("jail_file_parsed", filename=raw.filename)
return result
return await asyncio.get_event_loop().run_in_executor(None, _do)
async def update_parsed_jail_file(
config_dir: str,
filename: str,
update: JailFileConfigUpdate,
) -> None:
"""Apply a structured partial update to a jail.d config file.
Args:
config_dir: Path to the fail2ban configuration directory.
filename: Filename including extension (e.g. ``"sshd.conf"``).
update: Partial fields to apply.
Raises:
ConfigFileNotFoundError: If no matching file is found.
ConfigFileWriteError: If the file cannot be written.
ConfigDirError: If *config_dir* does not exist.
"""
from app.services.conffile_parser import ( # avoid circular imports
merge_jail_file_update,
parse_jail_file,
serialize_jail_file_config,
)
def _do() -> None:
jail_d = _resolve_subdir(config_dir, "jail.d")
raw = _read_conf_file(jail_d, filename)
current = parse_jail_file(raw.content, filename=raw.filename)
merged = merge_jail_file_update(current, update)
new_content = serialize_jail_file_config(merged)
_validate_content(new_content)
_write_conf_file(jail_d, filename, new_content)
log.info("jail_file_updated_parsed", filename=filename)
await asyncio.get_event_loop().run_in_executor(None, _do)