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:
@@ -19,6 +19,7 @@ import asyncio
|
||||
import configparser
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import structlog
|
||||
|
||||
@@ -33,6 +34,16 @@ from app.models.file_config import (
|
||||
JailConfigFilesResponse,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.config import (
|
||||
ActionConfig,
|
||||
ActionConfigUpdate,
|
||||
FilterConfig,
|
||||
FilterConfigUpdate,
|
||||
JailFileConfig,
|
||||
JailFileConfigUpdate,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -723,3 +763,206 @@ async def create_action_file(
|
||||
return filename
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user