Files
BanGUI/backend/app/services/file_config_service.py
Lukas ea35695221 Add better jail configuration: file CRUD, enable/disable, log paths
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
2026-03-12 20:08:33 +01:00

726 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)