Remove helper indirection and import shared service helpers directly

This commit is contained in:
2026-04-14 07:56:59 +02:00
parent a5674f9e4c
commit 37646e57f7
9 changed files with 96 additions and 572 deletions

View File

@@ -9,9 +9,7 @@ overrides in jail.d/*.local files.
from __future__ import annotations
import asyncio
import configparser
import contextlib
import io
import os
import re
import tempfile
@@ -24,17 +22,19 @@ from app.exceptions import (
ConfigWriteError,
JailAlreadyActiveError,
JailAlreadyInactiveError,
JailNameError,
JailNotFoundError,
JailNotFoundInConfigError,
)
from app.helpers.config_file_helpers import (
_build_inactive_jail,
_get_active_jail_names,
_parse_jails_sync,
_validate_jail_config_sync,
from app.services.config_file_service import (
build_inactive_jail,
get_active_jail_names,
parse_jails_sync,
safe_jail_name,
start_daemon,
validate_jail_config_sync,
wait_for_fail2ban,
)
from app.helpers.jail_helpers import reload_jails
from app.services.jail_service import reload_all
from app.models.config import (
ActivateJailRequest,
InactiveJail,
@@ -64,18 +64,9 @@ log: structlog.stdlib.BoundLogger = structlog.get_logger()
_SOCKET_TIMEOUT: float = 10.0
# Allowlist pattern for jail names used in path construction.
_SAFE_JAIL_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
# Sections that are not jail definitions.
_META_SECTIONS: frozenset[str] = frozenset({"INCLUDES", "DEFAULT"})
# True-ish values for the ``enabled`` key.
_TRUE_VALUES: frozenset[str] = frozenset({"true", "yes", "1"})
# False-ish values for the ``enabled`` key.
_FALSE_VALUES: frozenset[str] = frozenset({"false", "no", "0"})
# Seconds to wait between fail2ban liveness probes after a reload.
_POST_RELOAD_PROBE_INTERVAL: float = 2.0
@@ -88,51 +79,6 @@ _POST_RELOAD_MAX_ATTEMPTS: int = 4
# ---------------------------------------------------------------------------
def _safe_jail_name(name: str) -> str:
"""Validate *name* and return it unchanged or raise :class:`JailNameError`.
Args:
name: Proposed jail name.
Returns:
The name unchanged if valid.
Raises:
JailNameError: If *name* contains unsafe characters.
"""
if not _SAFE_JAIL_NAME_RE.match(name):
raise JailNameError(
f"Jail name {name!r} contains invalid characters. "
"Only alphanumeric characters, hyphens, underscores, and dots are "
"allowed; must start with an alphanumeric character."
)
return name
def _build_parser() -> configparser.RawConfigParser:
"""Create a :class:`configparser.RawConfigParser` for fail2ban configs.
Returns:
Parser with interpolation disabled and case-sensitive option names.
"""
parser = configparser.RawConfigParser(interpolation=None, strict=False)
# fail2ban keys are lowercase but preserve case to be safe.
parser.optionxform = str # type: ignore[assignment]
return parser
def _is_truthy(value: str) -> bool:
"""Return ``True`` if *value* is a fail2ban boolean true string.
Args:
value: Raw string from config (e.g. ``"true"``, ``"yes"``, ``"1"``).
Returns:
``True`` when the value represents enabled.
"""
return value.strip().lower() in _TRUE_VALUES
def _write_local_override_sync(
config_dir: Path,
jail_name: str,
@@ -275,81 +221,6 @@ def _validate_regex_patterns(patterns: list[str]) -> None:
raise FilterInvalidRegexError(pattern, str(exc)) from exc
def _set_jail_local_key_sync(
config_dir: Path,
jail_name: str,
key: str,
value: str,
) -> None:
"""Update ``jail.d/{jail_name}.local`` to set a single key in the jail section.
If the ``.local`` file already exists it is read, the key is updated (or
added), and the file is written back atomically without disturbing other
settings. If the file does not exist a new one is created containing
only the BanGUI header comment, the jail section, and the requested key.
Args:
config_dir: The fail2ban configuration root directory.
jail_name: Validated jail name (used as section name and filename stem).
key: Config key to set inside the jail section.
value: Config value to assign.
Raises:
ConfigWriteError: If writing fails.
"""
jail_d = config_dir / "jail.d"
try:
jail_d.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise ConfigWriteError(f"Cannot create jail.d directory: {exc}") from exc
local_path = jail_d / f"{jail_name}.local"
parser = _build_parser()
if local_path.is_file():
try:
parser.read(str(local_path), encoding="utf-8")
except (configparser.Error, OSError) as exc:
log.warning(
"jail_local_read_for_update_error",
jail=jail_name,
error=str(exc),
)
if not parser.has_section(jail_name):
parser.add_section(jail_name)
parser.set(jail_name, key, value)
# Serialize: write a BanGUI header then the parser output.
buf = io.StringIO()
buf.write("# Managed by BanGUI — do not edit manually\n\n")
parser.write(buf)
content = buf.getvalue()
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=jail_d,
delete=False,
suffix=".tmp",
) as tmp:
tmp.write(content)
tmp_name = tmp.name
os.replace(tmp_name, local_path)
except OSError as exc:
with contextlib.suppress(OSError):
os.unlink(tmp_name) # noqa: F821
raise ConfigWriteError(f"Failed to write {local_path}: {exc}") from exc
log.info(
"jail_local_key_set",
jail=jail_name,
key=key,
path=str(local_path),
)
async def _probe_fail2ban_running(socket_path: str) -> bool:
"""Return ``True`` if the fail2ban socket responds to a ping.
@@ -367,67 +238,8 @@ async def _probe_fail2ban_running(socket_path: str) -> bool:
return False
async def wait_for_fail2ban(
socket_path: str,
max_wait_seconds: float = 10.0,
poll_interval: float = 2.0,
) -> bool:
"""Poll the fail2ban socket until it responds or the timeout expires.
Args:
socket_path: Path to the fail2ban Unix domain socket.
max_wait_seconds: Total time budget in seconds.
poll_interval: Delay between probe attempts in seconds.
Returns:
``True`` if fail2ban came online within the budget.
"""
elapsed = 0.0
while elapsed < max_wait_seconds:
if await _probe_fail2ban_running(socket_path):
return True
await asyncio.sleep(poll_interval)
elapsed += poll_interval
return False
async def start_daemon(start_cmd_parts: list[str]) -> bool:
"""Start the fail2ban daemon using *start_cmd_parts*.
Uses :func:`asyncio.create_subprocess_exec` (no shell interpretation)
to avoid command injection.
Args:
start_cmd_parts: Command and arguments, e.g.
``["fail2ban-client", "start"]``.
Returns:
``True`` when the process exited with code 0.
"""
if not start_cmd_parts:
log.warning("fail2ban_start_cmd_empty")
return False
try:
proc = await asyncio.create_subprocess_exec(
*start_cmd_parts,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await asyncio.wait_for(proc.wait(), timeout=30.0)
success = proc.returncode == 0
if not success:
log.warning(
"fail2ban_start_cmd_nonzero",
cmd=start_cmd_parts,
returncode=proc.returncode,
)
return success
except (TimeoutError, OSError) as exc:
log.warning("fail2ban_start_cmd_error", cmd=start_cmd_parts, error=str(exc))
return False
# Shared functions from config_file_service are imported from app.helpers.config_file_helpers
# Shared functions from config_file_service are imported directly from the
# canonical shared helper module.
# ---------------------------------------------------------------------------
@@ -458,11 +270,11 @@ async def list_inactive_jails(
inactive jails.
"""
parsed_result: tuple[dict[str, dict[str, str]], dict[str, str]] = await run_blocking(
_parse_jails_sync,
parse_jails_sync,
Path(config_dir),
)
all_jails, source_files = parsed_result
active_names: set[str] = await _get_active_jail_names(socket_path)
active_names: set[str] = await get_active_jail_names(socket_path)
inactive: list[InactiveJail] = []
for jail_name, settings in sorted(all_jails.items()):
@@ -471,7 +283,7 @@ async def list_inactive_jails(
continue
source = source_files.get(jail_name, config_dir)
inactive.append(_build_inactive_jail(jail_name, settings, source, Path(config_dir)))
inactive.append(build_inactive_jail(jail_name, settings, source, Path(config_dir)))
log.info(
"inactive_jails_listed",
@@ -541,21 +353,21 @@ async def _activate_jail(
~app.utils.fail2ban_client.Fail2BanConnectionError: If the fail2ban
socket is unreachable during reload.
"""
_safe_jail_name(name)
safe_jail_name(name)
all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir))
all_jails, _source_files = await run_blocking(parse_jails_sync, Path(config_dir))
if name not in all_jails:
raise JailNotFoundInConfigError(name)
active_names = await _get_active_jail_names(socket_path)
active_names = await get_active_jail_names(socket_path)
if name in active_names:
raise JailAlreadyActiveError(name)
# ---------------------------------------------------------------------- #
# Pre-activation validation — collect warnings but do not block #
# ---------------------------------------------------------------------- #
validation_result: JailValidationResult = await run_blocking(_validate_jail_config_sync, Path(config_dir), name
validation_result: JailValidationResult = await run_blocking(validate_jail_config_sync, Path(config_dir), name
)
warnings: list[str] = [f"{i.field}: {i.message}" for i in validation_result.issues]
if warnings:
@@ -609,7 +421,7 @@ async def _activate_jail(
# Activation reload — if it fails, roll back immediately #
# ---------------------------------------------------------------------- #
try:
await reload_jails(socket_path, include_jails=[name])
await reload_all(socket_path, include_jails=[name])
except JailNotFoundError as exc:
# Jail configuration is invalid (e.g. missing logpath that prevents
# fail2ban from loading the jail). Roll back and provide a specific error.
@@ -680,7 +492,7 @@ async def _activate_jail(
)
# Verify the jail actually started (config error may prevent it silently).
post_reload_names = await _get_active_jail_names(socket_path)
post_reload_names = await get_active_jail_names(socket_path)
actually_running = name in post_reload_names
if not actually_running:
log.warning(
@@ -751,7 +563,7 @@ async def _rollback_activation_async(
# Step 2 — reload fail2ban with the restored config.
try:
await reload_jails(socket_path)
await reload_all(socket_path)
log.info("jail_activation_rollback_reload_ok", jail=name)
except Exception as exc: # noqa: BLE001
log.warning("jail_activation_rollback_reload_failed", jail=name, error=str(exc))
@@ -813,14 +625,14 @@ async def _deactivate_jail(
~app.utils.fail2ban_client.Fail2BanConnectionError: If the fail2ban
socket is unreachable during reload.
"""
_safe_jail_name(name)
safe_jail_name(name)
all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir))
all_jails, _source_files = await run_blocking(parse_jails_sync, Path(config_dir))
if name not in all_jails:
raise JailNotFoundInConfigError(name)
active_names = await _get_active_jail_names(socket_path)
active_names = await get_active_jail_names(socket_path)
if name not in active_names:
raise JailAlreadyInactiveError(name)
@@ -832,7 +644,7 @@ async def _deactivate_jail(
)
try:
await reload_jails(socket_path, exclude_jails=[name])
await reload_all(socket_path, exclude_jails=[name])
except Exception as exc: # noqa: BLE001
log.warning("reload_after_deactivate_failed", jail=name, error=str(exc))
@@ -868,14 +680,14 @@ async def delete_jail_local_override(
delete the live config file).
ConfigWriteError: If the file cannot be deleted.
"""
_safe_jail_name(name)
safe_jail_name(name)
all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir))
all_jails, _source_files = await run_blocking(parse_jails_sync, Path(config_dir))
if name not in all_jails:
raise JailNotFoundInConfigError(name)
active_names = await _get_active_jail_names(socket_path)
active_names = await get_active_jail_names(socket_path)
if name in active_names:
raise JailAlreadyActiveError(name)
@@ -908,8 +720,8 @@ async def validate_jail_config(
Raises:
JailNameError: If *name* contains invalid characters.
"""
_safe_jail_name(name)
return await run_blocking(_validate_jail_config_sync,
safe_jail_name(name)
return await run_blocking(validate_jail_config_sync,
Path(config_dir),
name,
)
@@ -958,7 +770,7 @@ async def _rollback_jail(
JailNameError: If *name* contains invalid characters.
ConfigWriteError: If writing the ``.local`` file fails.
"""
_safe_jail_name(name)
safe_jail_name(name)
# Write enabled=false — this must succeed even when fail2ban is down.
@@ -979,7 +791,7 @@ async def _rollback_jail(
active_jails = 0
if fail2ban_running:
names = await _get_active_jail_names(socket_path)
names = await get_active_jail_names(socket_path)
active_jails = len(names)
if fail2ban_running: