Task 4 (Better Jail Configuration) implementation:
- Add fail2ban_config_dir setting to app/config.py
- New file_config_service: list/view/edit/create jail.d, filter.d, action.d files
with path-traversal prevention and 512 KB content size limit
- New file_config router: GET/PUT/POST endpoints for jail files, filter files,
and action files; PUT .../enabled for toggle on/off
- Extend config_service with delete_log_path() and add_log_path()
- Add DELETE /api/config/jails/{name}/logpath and POST /api/config/jails/{name}/logpath
- Extend geo router with re-resolve endpoint; add geo_re_resolve background task
- Update blocklist_service with revised scheduling helpers
- Update Docker compose files with BANGUI_FAIL2BAN_CONFIG_DIR env var and
rw volume mount for the fail2ban config directory
- Frontend: new Jail Files, Filters, Actions tabs in ConfigPage; file editor
with accordion-per-file, editable textarea, save/create; add/delete log paths
- Frontend: types in types/config.ts; API calls in api/config.ts and api/endpoints.ts
- 63 new backend tests (test_file_config_service, test_file_config, test_geo_re_resolve)
- 6 new frontend tests in ConfigPageLogPath.test.tsx
- ruff, mypy --strict, tsc --noEmit, eslint: all clean; 617 backend tests pass
726 lines
24 KiB
Python
726 lines
24 KiB
Python
"""File-based fail2ban configuration service.
|
||
|
||
Provides functions to list, read, and write files in the fail2ban
|
||
configuration directory (``jail.d/``, ``filter.d/``, ``action.d/``).
|
||
|
||
All file operations are synchronous (wrapped in
|
||
:func:`asyncio.get_event_loop().run_in_executor` by callers that need async
|
||
behaviour) because the config files are small and infrequently touched — the
|
||
overhead of async I/O is not warranted here.
|
||
|
||
Security note: every path-related helper validates that the resolved path
|
||
stays strictly inside the configured config directory to prevent directory
|
||
traversal attacks.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import configparser
|
||
import re
|
||
from pathlib import Path
|
||
|
||
import structlog
|
||
|
||
from app.models.file_config import (
|
||
ConfFileContent,
|
||
ConfFileCreateRequest,
|
||
ConfFileEntry,
|
||
ConfFilesResponse,
|
||
ConfFileUpdateRequest,
|
||
JailConfigFile,
|
||
JailConfigFileContent,
|
||
JailConfigFilesResponse,
|
||
)
|
||
|
||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Constants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_MAX_CONTENT_BYTES: int = 512 * 1024 # 512 KB – hard cap on file write size
|
||
_CONF_EXTENSIONS: tuple[str, str] = (".conf", ".local")
|
||
|
||
# Allowed characters in a new file's base name. Tighter than the OS allows
|
||
# on purpose: alphanumeric, hyphen, underscore, dot (but not leading dot).
|
||
_SAFE_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Custom exceptions
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class ConfigDirError(Exception):
|
||
"""Raised when the fail2ban config directory is missing or inaccessible."""
|
||
|
||
|
||
class ConfigFileNotFoundError(Exception):
|
||
"""Raised when a requested config file does not exist."""
|
||
|
||
def __init__(self, filename: str) -> None:
|
||
"""Initialise with the filename that was not found.
|
||
|
||
Args:
|
||
filename: The filename that could not be located.
|
||
"""
|
||
self.filename = filename
|
||
super().__init__(f"Config file not found: {filename!r}")
|
||
|
||
|
||
class ConfigFileExistsError(Exception):
|
||
"""Raised when trying to create a file that already exists."""
|
||
|
||
def __init__(self, filename: str) -> None:
|
||
"""Initialise with the filename that already exists.
|
||
|
||
Args:
|
||
filename: The filename that conflicts.
|
||
"""
|
||
self.filename = filename
|
||
super().__init__(f"Config file already exists: {filename!r}")
|
||
|
||
|
||
class ConfigFileWriteError(Exception):
|
||
"""Raised when a file cannot be written (permissions, disk full, etc.)."""
|
||
|
||
|
||
class ConfigFileNameError(Exception):
|
||
"""Raised when a supplied filename is invalid or unsafe."""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Internal path helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _resolve_subdir(config_dir: str, subdir: str) -> Path:
|
||
"""Resolve and return the path of *subdir* inside *config_dir*.
|
||
|
||
Args:
|
||
config_dir: The top-level fail2ban config directory.
|
||
subdir: Subdirectory name (e.g. ``"jail.d"``).
|
||
|
||
Returns:
|
||
Resolved :class:`~pathlib.Path` to the subdirectory.
|
||
|
||
Raises:
|
||
ConfigDirError: If *config_dir* does not exist or is not a directory.
|
||
"""
|
||
base = Path(config_dir).resolve()
|
||
if not base.is_dir():
|
||
raise ConfigDirError(f"fail2ban config directory not found: {config_dir!r}")
|
||
return base / subdir
|
||
|
||
|
||
def _assert_within(base: Path, target: Path) -> None:
|
||
"""Raise :class:`ConfigFileNameError` if *target* is outside *base*.
|
||
|
||
Args:
|
||
base: The allowed root directory (resolved).
|
||
target: The path to validate (resolved).
|
||
|
||
Raises:
|
||
ConfigFileNameError: If *target* would escape *base*.
|
||
"""
|
||
try:
|
||
target.relative_to(base)
|
||
except ValueError as err:
|
||
raise ConfigFileNameError(
|
||
f"Path {str(target)!r} escapes config directory {str(base)!r}"
|
||
) from err
|
||
|
||
|
||
def _validate_new_name(name: str) -> None:
|
||
"""Validate a base name for a new config file.
|
||
|
||
Args:
|
||
name: The proposed base name (without extension).
|
||
|
||
Raises:
|
||
ConfigFileNameError: If *name* contains invalid characters or patterns.
|
||
"""
|
||
if not _SAFE_NAME_RE.match(name):
|
||
raise ConfigFileNameError(
|
||
f"Invalid config file name {name!r}. "
|
||
"Use only alphanumeric characters, hyphens, underscores, and dots; "
|
||
"must start with an alphanumeric character."
|
||
)
|
||
|
||
|
||
def _validate_content(content: str) -> None:
|
||
"""Reject content that exceeds the size limit.
|
||
|
||
Args:
|
||
content: The proposed file content.
|
||
|
||
Raises:
|
||
ConfigFileWriteError: If *content* exceeds :data:`_MAX_CONTENT_BYTES`.
|
||
"""
|
||
if len(content.encode("utf-8")) > _MAX_CONTENT_BYTES:
|
||
raise ConfigFileWriteError(
|
||
f"Content exceeds maximum allowed size of {_MAX_CONTENT_BYTES // 1024} KB."
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Internal helpers — INI parsing / patching
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _parse_enabled(path: Path) -> bool:
|
||
"""Return the ``enabled`` value for the primary section in *path*.
|
||
|
||
Reads the INI file with :mod:`configparser` and looks for an ``enabled``
|
||
key in the section whose name matches the file stem (or in ``DEFAULT``).
|
||
Returns ``True`` if the key is absent (fail2ban's own default).
|
||
|
||
Args:
|
||
path: Path to a ``.conf`` or ``.local`` jail config file.
|
||
|
||
Returns:
|
||
``True`` if the jail is (or defaults to) enabled, ``False`` otherwise.
|
||
"""
|
||
cp = configparser.ConfigParser(
|
||
# Treat all keys case-insensitively; interpolation disabled because
|
||
# fail2ban uses %(variables)s which would confuse configparser.
|
||
interpolation=None,
|
||
)
|
||
try:
|
||
cp.read(str(path), encoding="utf-8")
|
||
except configparser.Error:
|
||
return True # Unreadable files are treated as enabled (safe default).
|
||
|
||
jail_name = path.stem
|
||
# Prefer the jail-specific section; fall back to DEFAULT.
|
||
for section in (jail_name, "DEFAULT"):
|
||
if cp.has_option(section, "enabled"):
|
||
raw = cp.get(section, "enabled").strip().lower()
|
||
return raw in ("true", "1", "yes")
|
||
return True
|
||
|
||
|
||
def _set_enabled_in_content(content: str, enabled: bool) -> str:
|
||
"""Return *content* with the first ``enabled = …`` line replaced.
|
||
|
||
If no ``enabled`` line exists, appends one to the last ``[section]`` block
|
||
found in the file.
|
||
|
||
Args:
|
||
content: Current raw file content.
|
||
enabled: New value for the ``enabled`` key.
|
||
|
||
Returns:
|
||
Modified file content as a string.
|
||
"""
|
||
value = "true" if enabled else "false"
|
||
# Try to replace an existing "enabled = ..." line (inside any section).
|
||
pattern = re.compile(
|
||
r"^(\s*enabled\s*=\s*).*$",
|
||
re.MULTILINE | re.IGNORECASE,
|
||
)
|
||
if pattern.search(content):
|
||
return pattern.sub(rf"\g<1>{value}", content, count=1)
|
||
|
||
# No existing enabled line. Find the last [section] header and append
|
||
# the enabled setting right after it.
|
||
section_pattern = re.compile(r"^\[([^\[\]]+)\]\s*$", re.MULTILINE)
|
||
matches = list(section_pattern.finditer(content))
|
||
if matches:
|
||
# Insert after the last section header line.
|
||
last_match = matches[-1]
|
||
insert_pos = last_match.end()
|
||
return content[:insert_pos] + f"\nenabled = {value}" + content[insert_pos:]
|
||
|
||
# No section found at all — prepend a minimal block.
|
||
return f"[DEFAULT]\nenabled = {value}\n\n" + content
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Public API — jail config files (Task 4a)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def list_jail_config_files(config_dir: str) -> JailConfigFilesResponse:
|
||
"""List all jail config files in ``<config_dir>/jail.d/``.
|
||
|
||
Only ``.conf`` and ``.local`` files are returned. The ``enabled`` state
|
||
is parsed from each file's content.
|
||
|
||
Args:
|
||
config_dir: Path to the fail2ban configuration directory.
|
||
|
||
Returns:
|
||
:class:`~app.models.file_config.JailConfigFilesResponse`.
|
||
|
||
Raises:
|
||
ConfigDirError: If *config_dir* does not exist.
|
||
"""
|
||
|
||
def _do() -> JailConfigFilesResponse:
|
||
jail_d = _resolve_subdir(config_dir, "jail.d")
|
||
if not jail_d.is_dir():
|
||
log.warning("jail_d_not_found", config_dir=config_dir)
|
||
return JailConfigFilesResponse(files=[], total=0)
|
||
|
||
files: list[JailConfigFile] = []
|
||
for path in sorted(jail_d.iterdir()):
|
||
if not path.is_file():
|
||
continue
|
||
if path.suffix not in _CONF_EXTENSIONS:
|
||
continue
|
||
_assert_within(jail_d.resolve(), path.resolve())
|
||
files.append(
|
||
JailConfigFile(
|
||
name=path.stem,
|
||
filename=path.name,
|
||
enabled=_parse_enabled(path),
|
||
)
|
||
)
|
||
log.info("jail_config_files_listed", count=len(files))
|
||
return JailConfigFilesResponse(files=files, total=len(files))
|
||
|
||
return await asyncio.get_event_loop().run_in_executor(None, _do)
|
||
|
||
|
||
async def get_jail_config_file(config_dir: str, filename: str) -> JailConfigFileContent:
|
||
"""Return the content and metadata of a single jail config file.
|
||
|
||
Args:
|
||
config_dir: Path to the fail2ban configuration directory.
|
||
filename: The filename (e.g. ``sshd.conf``) — must end in ``.conf`` or ``.local``.
|
||
|
||
Returns:
|
||
:class:`~app.models.file_config.JailConfigFileContent`.
|
||
|
||
Raises:
|
||
ConfigFileNameError: If *filename* is unsafe.
|
||
ConfigFileNotFoundError: If the file does not exist.
|
||
ConfigDirError: If the config directory does not exist.
|
||
"""
|
||
|
||
def _do() -> JailConfigFileContent:
|
||
jail_d = _resolve_subdir(config_dir, "jail.d").resolve()
|
||
if not jail_d.is_dir():
|
||
raise ConfigFileNotFoundError(filename)
|
||
|
||
path = (jail_d / filename).resolve()
|
||
_assert_within(jail_d, path)
|
||
if path.suffix not in _CONF_EXTENSIONS:
|
||
raise ConfigFileNameError(
|
||
f"Invalid file extension for {filename!r}. "
|
||
"Only .conf and .local files are supported."
|
||
)
|
||
if not path.is_file():
|
||
raise ConfigFileNotFoundError(filename)
|
||
|
||
content = path.read_text(encoding="utf-8", errors="replace")
|
||
return JailConfigFileContent(
|
||
name=path.stem,
|
||
filename=path.name,
|
||
enabled=_parse_enabled(path),
|
||
content=content,
|
||
)
|
||
|
||
return await asyncio.get_event_loop().run_in_executor(None, _do)
|
||
|
||
|
||
async def set_jail_config_enabled(
|
||
config_dir: str,
|
||
filename: str,
|
||
enabled: bool,
|
||
) -> None:
|
||
"""Set the ``enabled`` flag in a jail config file.
|
||
|
||
Reads the file, modifies (or inserts) the ``enabled`` key, and writes it
|
||
back. The update preserves all other content including comments.
|
||
|
||
Args:
|
||
config_dir: Path to the fail2ban configuration directory.
|
||
filename: The filename (e.g. ``sshd.conf``).
|
||
enabled: New value for the ``enabled`` key.
|
||
|
||
Raises:
|
||
ConfigFileNameError: If *filename* is unsafe.
|
||
ConfigFileNotFoundError: If the file does not exist.
|
||
ConfigFileWriteError: If the file cannot be written.
|
||
ConfigDirError: If the config directory does not exist.
|
||
"""
|
||
|
||
def _do() -> None:
|
||
jail_d = _resolve_subdir(config_dir, "jail.d").resolve()
|
||
if not jail_d.is_dir():
|
||
raise ConfigFileNotFoundError(filename)
|
||
|
||
path = (jail_d / filename).resolve()
|
||
_assert_within(jail_d, path)
|
||
if path.suffix not in _CONF_EXTENSIONS:
|
||
raise ConfigFileNameError(
|
||
f"Only .conf and .local files are supported, got {filename!r}."
|
||
)
|
||
if not path.is_file():
|
||
raise ConfigFileNotFoundError(filename)
|
||
|
||
original = path.read_text(encoding="utf-8", errors="replace")
|
||
updated = _set_enabled_in_content(original, enabled)
|
||
try:
|
||
path.write_text(updated, encoding="utf-8")
|
||
except OSError as exc:
|
||
raise ConfigFileWriteError(
|
||
f"Cannot write {filename!r}: {exc}"
|
||
) from exc
|
||
log.info(
|
||
"jail_config_file_enabled_set",
|
||
filename=filename,
|
||
enabled=enabled,
|
||
)
|
||
|
||
await asyncio.get_event_loop().run_in_executor(None, _do)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Internal helpers — generic conf file listing / reading / writing
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _list_conf_files(subdir: Path) -> ConfFilesResponse:
|
||
"""List ``.conf`` and ``.local`` files in *subdir*.
|
||
|
||
Args:
|
||
subdir: Resolved path to the directory to scan.
|
||
|
||
Returns:
|
||
:class:`~app.models.file_config.ConfFilesResponse`.
|
||
"""
|
||
if not subdir.is_dir():
|
||
return ConfFilesResponse(files=[], total=0)
|
||
|
||
files: list[ConfFileEntry] = []
|
||
for path in sorted(subdir.iterdir()):
|
||
if not path.is_file():
|
||
continue
|
||
if path.suffix not in _CONF_EXTENSIONS:
|
||
continue
|
||
_assert_within(subdir.resolve(), path.resolve())
|
||
files.append(ConfFileEntry(name=path.stem, filename=path.name))
|
||
return ConfFilesResponse(files=files, total=len(files))
|
||
|
||
|
||
def _read_conf_file(subdir: Path, name: str) -> ConfFileContent:
|
||
"""Read a single conf file by base name.
|
||
|
||
Args:
|
||
subdir: Resolved path to the containing directory.
|
||
name: Base name with optional extension. If no extension is given,
|
||
``.conf`` is tried first, then ``.local``.
|
||
|
||
Returns:
|
||
:class:`~app.models.file_config.ConfFileContent`.
|
||
|
||
Raises:
|
||
ConfigFileNameError: If *name* is unsafe.
|
||
ConfigFileNotFoundError: If no matching file is found.
|
||
"""
|
||
resolved_subdir = subdir.resolve()
|
||
# Accept names with or without extension.
|
||
if "." in name and not name.startswith("."):
|
||
candidates = [resolved_subdir / name]
|
||
else:
|
||
candidates = [resolved_subdir / (name + ext) for ext in _CONF_EXTENSIONS]
|
||
|
||
for path in candidates:
|
||
resolved = path.resolve()
|
||
_assert_within(resolved_subdir, resolved)
|
||
if resolved.is_file():
|
||
content = resolved.read_text(encoding="utf-8", errors="replace")
|
||
return ConfFileContent(
|
||
name=resolved.stem,
|
||
filename=resolved.name,
|
||
content=content,
|
||
)
|
||
raise ConfigFileNotFoundError(name)
|
||
|
||
|
||
def _write_conf_file(subdir: Path, name: str, content: str) -> None:
|
||
"""Overwrite or create a conf file.
|
||
|
||
Args:
|
||
subdir: Resolved path to the containing directory.
|
||
name: Base name with optional extension.
|
||
content: New file content.
|
||
|
||
Raises:
|
||
ConfigFileNameError: If *name* is unsafe.
|
||
ConfigFileNotFoundError: If *name* does not match an existing file
|
||
(use :func:`_create_conf_file` for new files).
|
||
ConfigFileWriteError: If the file cannot be written.
|
||
"""
|
||
resolved_subdir = subdir.resolve()
|
||
_validate_content(content)
|
||
|
||
# Accept names with or without extension.
|
||
if "." in name and not name.startswith("."):
|
||
candidates = [resolved_subdir / name]
|
||
else:
|
||
candidates = [resolved_subdir / (name + ext) for ext in _CONF_EXTENSIONS]
|
||
|
||
target: Path | None = None
|
||
for path in candidates:
|
||
resolved = path.resolve()
|
||
_assert_within(resolved_subdir, resolved)
|
||
if resolved.is_file():
|
||
target = resolved
|
||
break
|
||
|
||
if target is None:
|
||
raise ConfigFileNotFoundError(name)
|
||
|
||
try:
|
||
target.write_text(content, encoding="utf-8")
|
||
except OSError as exc:
|
||
raise ConfigFileWriteError(f"Cannot write {name!r}: {exc}") from exc
|
||
|
||
|
||
def _create_conf_file(subdir: Path, name: str, content: str) -> str:
|
||
"""Create a new ``.conf`` file in *subdir*.
|
||
|
||
Args:
|
||
subdir: Resolved path to the containing directory.
|
||
name: Base name for the new file (without extension).
|
||
content: Initial file content.
|
||
|
||
Returns:
|
||
The filename that was created (e.g. ``myfilter.conf``).
|
||
|
||
Raises:
|
||
ConfigFileNameError: If *name* is invalid.
|
||
ConfigFileExistsError: If a ``.conf`` or ``.local`` file with *name* already exists.
|
||
ConfigFileWriteError: If the file cannot be written.
|
||
"""
|
||
resolved_subdir = subdir.resolve()
|
||
_validate_new_name(name)
|
||
_validate_content(content)
|
||
|
||
for ext in _CONF_EXTENSIONS:
|
||
existing = (resolved_subdir / (name + ext)).resolve()
|
||
_assert_within(resolved_subdir, existing)
|
||
if existing.exists():
|
||
raise ConfigFileExistsError(name + ext)
|
||
|
||
target = (resolved_subdir / (name + ".conf")).resolve()
|
||
_assert_within(resolved_subdir, target)
|
||
try:
|
||
target.write_text(content, encoding="utf-8")
|
||
except OSError as exc:
|
||
raise ConfigFileWriteError(f"Cannot create {name!r}: {exc}") from exc
|
||
|
||
return target.name
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Public API — filter files (Task 4d)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def list_filter_files(config_dir: str) -> ConfFilesResponse:
|
||
"""List all filter definition files in ``<config_dir>/filter.d/``.
|
||
|
||
Args:
|
||
config_dir: Path to the fail2ban configuration directory.
|
||
|
||
Returns:
|
||
:class:`~app.models.file_config.ConfFilesResponse`.
|
||
|
||
Raises:
|
||
ConfigDirError: If *config_dir* does not exist.
|
||
"""
|
||
|
||
def _do() -> ConfFilesResponse:
|
||
filter_d = _resolve_subdir(config_dir, "filter.d")
|
||
result = _list_conf_files(filter_d)
|
||
log.info("filter_files_listed", count=result.total)
|
||
return result
|
||
|
||
return await asyncio.get_event_loop().run_in_executor(None, _do)
|
||
|
||
|
||
async def get_filter_file(config_dir: str, name: str) -> ConfFileContent:
|
||
"""Return the content of a filter definition file.
|
||
|
||
Args:
|
||
config_dir: Path to the fail2ban configuration directory.
|
||
name: Base name (with or without ``.conf``/``.local`` extension).
|
||
|
||
Returns:
|
||
:class:`~app.models.file_config.ConfFileContent`.
|
||
|
||
Raises:
|
||
ConfigFileNotFoundError: If no matching file is found.
|
||
ConfigDirError: If *config_dir* does not exist.
|
||
"""
|
||
|
||
def _do() -> ConfFileContent:
|
||
filter_d = _resolve_subdir(config_dir, "filter.d")
|
||
return _read_conf_file(filter_d, name)
|
||
|
||
return await asyncio.get_event_loop().run_in_executor(None, _do)
|
||
|
||
|
||
async def write_filter_file(
|
||
config_dir: str,
|
||
name: str,
|
||
req: ConfFileUpdateRequest,
|
||
) -> None:
|
||
"""Overwrite an existing filter definition file.
|
||
|
||
Args:
|
||
config_dir: Path to the fail2ban configuration directory.
|
||
name: Base name of the file to update (with or without extension).
|
||
req: :class:`~app.models.file_config.ConfFileUpdateRequest` with new content.
|
||
|
||
Raises:
|
||
ConfigFileNotFoundError: If no matching file is found.
|
||
ConfigFileWriteError: If the file cannot be written.
|
||
ConfigDirError: If *config_dir* does not exist.
|
||
"""
|
||
|
||
def _do() -> None:
|
||
filter_d = _resolve_subdir(config_dir, "filter.d")
|
||
_write_conf_file(filter_d, name, req.content)
|
||
log.info("filter_file_written", name=name)
|
||
|
||
await asyncio.get_event_loop().run_in_executor(None, _do)
|
||
|
||
|
||
async def create_filter_file(
|
||
config_dir: str,
|
||
req: ConfFileCreateRequest,
|
||
) -> str:
|
||
"""Create a new filter definition 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:
|
||
filter_d = _resolve_subdir(config_dir, "filter.d")
|
||
filename = _create_conf_file(filter_d, req.name, req.content)
|
||
log.info("filter_file_created", filename=filename)
|
||
return filename
|
||
|
||
return await asyncio.get_event_loop().run_in_executor(None, _do)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Public API — action files (Task 4e)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def list_action_files(config_dir: str) -> ConfFilesResponse:
|
||
"""List all action definition files in ``<config_dir>/action.d/``.
|
||
|
||
Args:
|
||
config_dir: Path to the fail2ban configuration directory.
|
||
|
||
Returns:
|
||
:class:`~app.models.file_config.ConfFilesResponse`.
|
||
|
||
Raises:
|
||
ConfigDirError: If *config_dir* does not exist.
|
||
"""
|
||
|
||
def _do() -> ConfFilesResponse:
|
||
action_d = _resolve_subdir(config_dir, "action.d")
|
||
result = _list_conf_files(action_d)
|
||
log.info("action_files_listed", count=result.total)
|
||
return result
|
||
|
||
return await asyncio.get_event_loop().run_in_executor(None, _do)
|
||
|
||
|
||
async def get_action_file(config_dir: str, name: str) -> ConfFileContent:
|
||
"""Return the content of an action definition file.
|
||
|
||
Args:
|
||
config_dir: Path to the fail2ban configuration directory.
|
||
name: Base name (with or without ``.conf``/``.local`` extension).
|
||
|
||
Returns:
|
||
:class:`~app.models.file_config.ConfFileContent`.
|
||
|
||
Raises:
|
||
ConfigFileNotFoundError: If no matching file is found.
|
||
ConfigDirError: If *config_dir* does not exist.
|
||
"""
|
||
|
||
def _do() -> ConfFileContent:
|
||
action_d = _resolve_subdir(config_dir, "action.d")
|
||
return _read_conf_file(action_d, name)
|
||
|
||
return await asyncio.get_event_loop().run_in_executor(None, _do)
|
||
|
||
|
||
async def write_action_file(
|
||
config_dir: str,
|
||
name: str,
|
||
req: ConfFileUpdateRequest,
|
||
) -> None:
|
||
"""Overwrite an existing action definition file.
|
||
|
||
Args:
|
||
config_dir: Path to the fail2ban configuration directory.
|
||
name: Base name of the file to update.
|
||
req: :class:`~app.models.file_config.ConfFileUpdateRequest` with new content.
|
||
|
||
Raises:
|
||
ConfigFileNotFoundError: If no matching file is found.
|
||
ConfigFileWriteError: If the file cannot be written.
|
||
ConfigDirError: If *config_dir* does not exist.
|
||
"""
|
||
|
||
def _do() -> None:
|
||
action_d = _resolve_subdir(config_dir, "action.d")
|
||
_write_conf_file(action_d, name, req.content)
|
||
log.info("action_file_written", name=name)
|
||
|
||
await asyncio.get_event_loop().run_in_executor(None, _do)
|
||
|
||
|
||
async def create_action_file(
|
||
config_dir: str,
|
||
req: ConfFileCreateRequest,
|
||
) -> str:
|
||
"""Create a new action definition 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:
|
||
action_d = _resolve_subdir(config_dir, "action.d")
|
||
filename = _create_conf_file(action_d, req.name, req.content)
|
||
log.info("action_file_created", filename=filename)
|
||
return filename
|
||
|
||
return await asyncio.get_event_loop().run_in_executor(None, _do)
|