feature/ignore-self-toggle #1
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user