feat(stage-1): inactive jail discovery and activation
- Backend: config_file_service.py parses jail.conf/jail.local/jail.d/*
following fail2ban merge order; discovers jails not running in fail2ban
- Backend: 3 new API endpoints (GET /jails/inactive, POST /jails/{name}/activate,
POST /jails/{name}/deactivate); moved /jails/inactive before /jails/{name}
to fix route-ordering conflict
- Frontend: ActivateJailDialog component with optional parameter overrides
- Frontend: JailsTab extended with inactive jail list and InactiveJailDetail pane
- Frontend: JailsPage JailOverviewSection shows inactive jails with toggle
- Tests: 57 service tests + 16 router tests for all new endpoints (all pass)
- Docs: Features.md, Architekture.md, Tasks.md updated; Tasks 1.1-1.5 marked done
This commit is contained in:
@@ -447,3 +447,119 @@ class JailFileConfigUpdate(BaseModel):
|
||||
default=None,
|
||||
description="Jail section updates. Only jails present in this dict are updated.",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inactive jail models (Stage 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InactiveJail(BaseModel):
|
||||
"""A jail defined in fail2ban config files that is not currently active.
|
||||
|
||||
A jail is considered inactive when its ``enabled`` key is ``false`` (or
|
||||
absent from the config, since fail2ban defaults to disabled) **or** when it
|
||||
is explicitly enabled in config but fail2ban is not reporting it as
|
||||
running.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
name: str = Field(..., description="Jail name from the config section header.")
|
||||
filter: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Filter name used by this jail. May include fail2ban mode suffix, "
|
||||
"e.g. ``sshd[mode=normal]``."
|
||||
),
|
||||
)
|
||||
actions: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Action references listed in the config (raw strings).",
|
||||
)
|
||||
port: str | None = Field(
|
||||
default=None,
|
||||
description="Port(s) to monitor, e.g. ``ssh`` or ``22,2222``.",
|
||||
)
|
||||
logpath: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Log file paths to monitor.",
|
||||
)
|
||||
bantime: str | None = Field(
|
||||
default=None,
|
||||
description="Ban duration as a raw config string, e.g. ``10m`` or ``-1``.",
|
||||
)
|
||||
findtime: str | None = Field(
|
||||
default=None,
|
||||
description="Failure-counting window as a raw config string, e.g. ``10m``.",
|
||||
)
|
||||
maxretry: int | None = Field(
|
||||
default=None,
|
||||
description="Number of failures before a ban is issued.",
|
||||
)
|
||||
source_file: str = Field(
|
||||
...,
|
||||
description="Absolute path to the config file where this jail is defined.",
|
||||
)
|
||||
enabled: bool = Field(
|
||||
...,
|
||||
description=(
|
||||
"Effective ``enabled`` value from the merged config. ``False`` for "
|
||||
"inactive jails that appear in this list."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class InactiveJailListResponse(BaseModel):
|
||||
"""Response for ``GET /api/config/jails/inactive``."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
jails: list[InactiveJail] = Field(default_factory=list)
|
||||
total: int = Field(..., ge=0)
|
||||
|
||||
|
||||
class ActivateJailRequest(BaseModel):
|
||||
"""Optional override values when activating an inactive jail.
|
||||
|
||||
All fields are optional. Omitted fields are not written to the
|
||||
``.local`` override file so that fail2ban falls back to its default
|
||||
values.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
bantime: str | None = Field(
|
||||
default=None,
|
||||
description="Override ban duration, e.g. ``1h`` or ``3600``.",
|
||||
)
|
||||
findtime: str | None = Field(
|
||||
default=None,
|
||||
description="Override failure-counting window, e.g. ``10m``.",
|
||||
)
|
||||
maxretry: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
description="Override maximum failures before a ban.",
|
||||
)
|
||||
port: str | None = Field(
|
||||
default=None,
|
||||
description="Override port(s) to monitor.",
|
||||
)
|
||||
logpath: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Override log file paths.",
|
||||
)
|
||||
|
||||
|
||||
class JailActivationResponse(BaseModel):
|
||||
"""Response for jail activation and deactivation endpoints."""
|
||||
|
||||
model_config = ConfigDict(strict=True)
|
||||
|
||||
name: str = Field(..., description="Name of the affected jail.")
|
||||
active: bool = Field(
|
||||
...,
|
||||
description="New activation state: ``True`` after activate, ``False`` after deactivate.",
|
||||
)
|
||||
message: str = Field(..., description="Human-readable result message.")
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
Provides endpoints to inspect and edit fail2ban jail configuration and
|
||||
global settings, test regex patterns, add log paths, and preview log files.
|
||||
|
||||
* ``GET /api/config/jails`` — list all jail configs
|
||||
* ``GET /api/config/jails/{name}`` — full config for one jail
|
||||
* ``PUT /api/config/jails/{name}`` — update a jail's config
|
||||
* ``GET /api/config/global`` — global fail2ban settings
|
||||
* ``PUT /api/config/global`` — update global settings
|
||||
* ``POST /api/config/reload`` — reload fail2ban
|
||||
* ``POST /api/config/regex-test`` — test a regex pattern
|
||||
* ``POST /api/config/jails/{name}/logpath`` — add a log path to a jail
|
||||
* ``POST /api/config/preview-log`` — preview log matches
|
||||
* ``GET /api/config/jails`` — list all jail configs
|
||||
* ``GET /api/config/jails/{name}`` — full config for one jail
|
||||
* ``PUT /api/config/jails/{name}`` — update a jail's config
|
||||
* ``GET /api/config/jails/inactive`` — list all inactive jails
|
||||
* ``POST /api/config/jails/{name}/activate`` — activate an inactive jail
|
||||
* ``POST /api/config/jails/{name}/deactivate`` — deactivate an active jail
|
||||
* ``GET /api/config/global`` — global fail2ban settings
|
||||
* ``PUT /api/config/global`` — update global settings
|
||||
* ``POST /api/config/reload`` — reload fail2ban
|
||||
* ``POST /api/config/regex-test`` — test a regex pattern
|
||||
* ``POST /api/config/jails/{name}/logpath`` — add a log path to a jail
|
||||
* ``POST /api/config/preview-log`` — preview log matches
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,9 +25,12 @@ from fastapi import APIRouter, HTTPException, Path, Query, Request, status
|
||||
|
||||
from app.dependencies import AuthDep
|
||||
from app.models.config import (
|
||||
ActivateJailRequest,
|
||||
AddLogPathRequest,
|
||||
GlobalConfigResponse,
|
||||
GlobalConfigUpdate,
|
||||
InactiveJailListResponse,
|
||||
JailActivationResponse,
|
||||
JailConfigListResponse,
|
||||
JailConfigResponse,
|
||||
JailConfigUpdate,
|
||||
@@ -35,7 +41,14 @@ from app.models.config import (
|
||||
RegexTestRequest,
|
||||
RegexTestResponse,
|
||||
)
|
||||
from app.services import config_service, jail_service
|
||||
from app.services import config_file_service, config_service, jail_service
|
||||
from app.services.config_file_service import (
|
||||
ConfigWriteError,
|
||||
JailAlreadyActiveError,
|
||||
JailAlreadyInactiveError,
|
||||
JailNameError,
|
||||
JailNotFoundInConfigError,
|
||||
)
|
||||
from app.services.config_service import (
|
||||
ConfigOperationError,
|
||||
ConfigValidationError,
|
||||
@@ -113,6 +126,33 @@ async def get_jail_configs(
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/jails/inactive",
|
||||
response_model=InactiveJailListResponse,
|
||||
summary="List all inactive jails discovered in config files",
|
||||
)
|
||||
async def get_inactive_jails(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
) -> InactiveJailListResponse:
|
||||
"""Return all jails defined in fail2ban config files that are not running.
|
||||
|
||||
Parses ``jail.conf``, ``jail.local``, and ``jail.d/`` following the
|
||||
fail2ban merge order. Jails that fail2ban currently reports as running
|
||||
are excluded; only truly inactive entries are returned.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
_auth: Validated session — enforces authentication.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.InactiveJailListResponse`.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
return await config_file_service.list_inactive_jails(config_dir, socket_path)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/jails/{name}",
|
||||
response_model=JailConfigResponse,
|
||||
@@ -495,3 +535,114 @@ async def update_map_color_thresholds(
|
||||
threshold_medium=body.threshold_medium,
|
||||
threshold_low=body.threshold_low,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/jails/{name}/activate",
|
||||
response_model=JailActivationResponse,
|
||||
summary="Activate an inactive jail",
|
||||
)
|
||||
async def activate_jail(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
body: ActivateJailRequest | None = None,
|
||||
) -> JailActivationResponse:
|
||||
"""Enable an inactive jail and reload fail2ban.
|
||||
|
||||
Writes ``enabled = true`` (plus any override values from the request
|
||||
body) to ``jail.d/{name}.local`` and triggers a full fail2ban reload so
|
||||
the jail starts immediately.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
_auth: Validated session.
|
||||
name: Name of the jail to activate.
|
||||
body: Optional override values (bantime, findtime, maxretry, port,
|
||||
logpath).
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.JailActivationResponse`.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if *name* contains invalid characters.
|
||||
HTTPException: 404 if *name* is not found in any config file.
|
||||
HTTPException: 409 if the jail is already active.
|
||||
HTTPException: 502 if fail2ban is unreachable.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
req = body if body is not None else ActivateJailRequest()
|
||||
|
||||
try:
|
||||
return await config_file_service.activate_jail(
|
||||
config_dir, socket_path, name, req
|
||||
)
|
||||
except JailNameError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except JailNotFoundInConfigError:
|
||||
raise _not_found(name) from None
|
||||
except JailAlreadyActiveError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Jail {name!r} is already active.",
|
||||
) from None
|
||||
except ConfigWriteError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to write config override: {exc}",
|
||||
) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/jails/{name}/deactivate",
|
||||
response_model=JailActivationResponse,
|
||||
summary="Deactivate an active jail",
|
||||
)
|
||||
async def deactivate_jail(
|
||||
request: Request,
|
||||
_auth: AuthDep,
|
||||
name: _NamePath,
|
||||
) -> JailActivationResponse:
|
||||
"""Disable an active jail and reload fail2ban.
|
||||
|
||||
Writes ``enabled = false`` to ``jail.d/{name}.local`` and triggers a
|
||||
full fail2ban reload so the jail stops immediately.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object.
|
||||
_auth: Validated session.
|
||||
name: Name of the jail to deactivate.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.JailActivationResponse`.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if *name* contains invalid characters.
|
||||
HTTPException: 404 if *name* is not found in any config file.
|
||||
HTTPException: 409 if the jail is already inactive.
|
||||
HTTPException: 502 if fail2ban is unreachable.
|
||||
"""
|
||||
config_dir: str = request.app.state.settings.fail2ban_config_dir
|
||||
socket_path: str = request.app.state.settings.fail2ban_socket
|
||||
|
||||
try:
|
||||
return await config_file_service.deactivate_jail(config_dir, socket_path, name)
|
||||
except JailNameError as exc:
|
||||
raise _bad_request(str(exc)) from exc
|
||||
except JailNotFoundInConfigError:
|
||||
raise _not_found(name) from None
|
||||
except JailAlreadyInactiveError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Jail {name!r} is already inactive.",
|
||||
) from None
|
||||
except ConfigWriteError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to write config override: {exc}",
|
||||
) from exc
|
||||
except Fail2BanConnectionError as exc:
|
||||
raise _bad_gateway(exc) from exc
|
||||
|
||||
666
backend/app/services/config_file_service.py
Normal file
666
backend/app/services/config_file_service.py
Normal file
@@ -0,0 +1,666 @@
|
||||
"""Fail2ban jail configuration file parser and activator.
|
||||
|
||||
Parses the full set of fail2ban jail configuration files
|
||||
(``jail.conf``, ``jail.local``, ``jail.d/*.conf``, ``jail.d/*.local``)
|
||||
to discover all defined jails — both active and inactive — and provides
|
||||
functions to activate or deactivate them by writing ``.local`` override
|
||||
files.
|
||||
|
||||
Merge order (fail2ban convention):
|
||||
1. ``jail.conf``
|
||||
2. ``jail.local``
|
||||
3. ``jail.d/*.conf`` (alphabetical)
|
||||
4. ``jail.d/*.local`` (alphabetical)
|
||||
|
||||
Security note: the ``activate_jail`` and ``deactivate_jail`` callers must
|
||||
supply a validated jail name. This module validates the name against an
|
||||
allowlist pattern before constructing any filesystem paths to prevent
|
||||
directory traversal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import configparser
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from app.models.config import (
|
||||
ActivateJailRequest,
|
||||
InactiveJail,
|
||||
InactiveJailListResponse,
|
||||
JailActivationResponse,
|
||||
)
|
||||
from app.services import jail_service
|
||||
from app.utils.fail2ban_client import Fail2BanClient, Fail2BanConnectionError
|
||||
|
||||
log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_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"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class JailNotFoundInConfigError(Exception):
|
||||
"""Raised when the requested jail name is not defined in any config file."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
"""Initialise with the jail name that was not found.
|
||||
|
||||
Args:
|
||||
name: The jail name that could not be located.
|
||||
"""
|
||||
self.name: str = name
|
||||
super().__init__(f"Jail not found in config files: {name!r}")
|
||||
|
||||
|
||||
class JailAlreadyActiveError(Exception):
|
||||
"""Raised when trying to activate a jail that is already active."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
"""Initialise with the jail name.
|
||||
|
||||
Args:
|
||||
name: The jail that is already active.
|
||||
"""
|
||||
self.name: str = name
|
||||
super().__init__(f"Jail is already active: {name!r}")
|
||||
|
||||
|
||||
class JailAlreadyInactiveError(Exception):
|
||||
"""Raised when trying to deactivate a jail that is already inactive."""
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
"""Initialise with the jail name.
|
||||
|
||||
Args:
|
||||
name: The jail that is already inactive.
|
||||
"""
|
||||
self.name: str = name
|
||||
super().__init__(f"Jail is already inactive: {name!r}")
|
||||
|
||||
|
||||
class JailNameError(Exception):
|
||||
"""Raised when a jail name contains invalid characters."""
|
||||
|
||||
|
||||
class ConfigWriteError(Exception):
|
||||
"""Raised when writing a ``.local`` override file fails."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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 _ordered_config_files(config_dir: Path) -> list[Path]:
|
||||
"""Return all jail config files in fail2ban merge order.
|
||||
|
||||
Args:
|
||||
config_dir: The fail2ban configuration root directory.
|
||||
|
||||
Returns:
|
||||
List of paths in ascending priority order (later entries override
|
||||
earlier ones).
|
||||
"""
|
||||
files: list[Path] = []
|
||||
|
||||
jail_conf = config_dir / "jail.conf"
|
||||
if jail_conf.is_file():
|
||||
files.append(jail_conf)
|
||||
|
||||
jail_local = config_dir / "jail.local"
|
||||
if jail_local.is_file():
|
||||
files.append(jail_local)
|
||||
|
||||
jail_d = config_dir / "jail.d"
|
||||
if jail_d.is_dir():
|
||||
files.extend(sorted(jail_d.glob("*.conf")))
|
||||
files.extend(sorted(jail_d.glob("*.local")))
|
||||
|
||||
return files
|
||||
|
||||
|
||||
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 _parse_int_safe(value: str) -> int | None:
|
||||
"""Parse *value* as int, returning ``None`` on failure.
|
||||
|
||||
Args:
|
||||
value: Raw string to parse.
|
||||
|
||||
Returns:
|
||||
Integer value, or ``None``.
|
||||
"""
|
||||
try:
|
||||
return int(value.strip())
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_multiline(raw: str) -> list[str]:
|
||||
"""Split a multi-line INI value into individual non-blank lines.
|
||||
|
||||
Args:
|
||||
raw: Raw multi-line string from configparser.
|
||||
|
||||
Returns:
|
||||
List of stripped, non-empty, non-comment strings.
|
||||
"""
|
||||
result: list[str] = []
|
||||
for line in raw.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped and not stripped.startswith("#"):
|
||||
result.append(stripped)
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_filter(raw_filter: str, jail_name: str, mode: str) -> str:
|
||||
"""Resolve fail2ban variable placeholders in a filter string.
|
||||
|
||||
Handles the common default ``%(__name__)s[mode=%(mode)s]`` pattern that
|
||||
fail2ban uses so the filter name displayed to the user is readable.
|
||||
|
||||
Args:
|
||||
raw_filter: Raw ``filter`` value from config (may contain ``%()s``).
|
||||
jail_name: The jail's section name, used to substitute ``%(__name__)s``.
|
||||
mode: The jail's ``mode`` value, used to substitute ``%(mode)s``.
|
||||
|
||||
Returns:
|
||||
Human-readable filter string.
|
||||
"""
|
||||
result = raw_filter.replace("%(__name__)s", jail_name)
|
||||
result = result.replace("%(mode)s", mode)
|
||||
return result
|
||||
|
||||
|
||||
def _parse_jails_sync(
|
||||
config_dir: Path,
|
||||
) -> tuple[dict[str, dict[str, str]], dict[str, str]]:
|
||||
"""Synchronously parse all jail configs and return merged definitions.
|
||||
|
||||
This is a CPU-bound / IO-bound sync function; callers must dispatch to
|
||||
an executor for async use.
|
||||
|
||||
Args:
|
||||
config_dir: The fail2ban configuration root directory.
|
||||
|
||||
Returns:
|
||||
A two-tuple ``(jails, source_files)`` where:
|
||||
|
||||
- ``jails``: ``{jail_name: {key: value}}`` – merged settings for each
|
||||
jail with DEFAULT values already applied.
|
||||
- ``source_files``: ``{jail_name: str(path)}`` – path of the file that
|
||||
last defined each jail section (for display in the UI).
|
||||
"""
|
||||
parser = _build_parser()
|
||||
files = _ordered_config_files(config_dir)
|
||||
|
||||
# Track which file each section came from (last write wins).
|
||||
source_files: dict[str, str] = {}
|
||||
for path in files:
|
||||
try:
|
||||
single = _build_parser()
|
||||
single.read(str(path), encoding="utf-8")
|
||||
for section in single.sections():
|
||||
if section not in _META_SECTIONS:
|
||||
source_files[section] = str(path)
|
||||
except (configparser.Error, OSError) as exc:
|
||||
log.warning("jail_config_read_error", path=str(path), error=str(exc))
|
||||
|
||||
# Full merged parse: configparser applies DEFAULT values to every section.
|
||||
try:
|
||||
parser.read([str(p) for p in files], encoding="utf-8")
|
||||
except configparser.Error as exc:
|
||||
log.warning("jail_config_parse_error", error=str(exc))
|
||||
|
||||
jails: dict[str, dict[str, str]] = {}
|
||||
for section in parser.sections():
|
||||
if section in _META_SECTIONS:
|
||||
continue
|
||||
try:
|
||||
# items() merges DEFAULT values automatically.
|
||||
jails[section] = dict(parser.items(section))
|
||||
except configparser.Error as exc:
|
||||
log.warning(
|
||||
"jail_section_parse_error", section=section, error=str(exc)
|
||||
)
|
||||
|
||||
log.debug("jails_parsed", count=len(jails), config_dir=str(config_dir))
|
||||
return jails, source_files
|
||||
|
||||
|
||||
def _build_inactive_jail(
|
||||
name: str,
|
||||
settings: dict[str, str],
|
||||
source_file: str,
|
||||
) -> InactiveJail:
|
||||
"""Construct an :class:`~app.models.config.InactiveJail` from raw settings.
|
||||
|
||||
Args:
|
||||
name: Jail section name.
|
||||
settings: Merged key→value dict (DEFAULT values already applied).
|
||||
source_file: Path of the file that last defined this section.
|
||||
|
||||
Returns:
|
||||
Populated :class:`~app.models.config.InactiveJail`.
|
||||
"""
|
||||
raw_filter = settings.get("filter", "")
|
||||
mode = settings.get("mode", "normal")
|
||||
filter_name = _resolve_filter(raw_filter, name, mode) if raw_filter else name
|
||||
|
||||
raw_action = settings.get("action", "")
|
||||
actions = _parse_multiline(raw_action) if raw_action else []
|
||||
|
||||
raw_logpath = settings.get("logpath", "")
|
||||
logpath = _parse_multiline(raw_logpath) if raw_logpath else []
|
||||
|
||||
enabled_raw = settings.get("enabled", "false")
|
||||
enabled = _is_truthy(enabled_raw)
|
||||
|
||||
maxretry_raw = settings.get("maxretry", "")
|
||||
maxretry = _parse_int_safe(maxretry_raw)
|
||||
|
||||
return InactiveJail(
|
||||
name=name,
|
||||
filter=filter_name,
|
||||
actions=actions,
|
||||
port=settings.get("port") or None,
|
||||
logpath=logpath,
|
||||
bantime=settings.get("bantime") or None,
|
||||
findtime=settings.get("findtime") or None,
|
||||
maxretry=maxretry,
|
||||
source_file=source_file,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
|
||||
async def _get_active_jail_names(socket_path: str) -> set[str]:
|
||||
"""Fetch the set of currently running jail names from fail2ban.
|
||||
|
||||
Returns an empty set gracefully if fail2ban is unreachable.
|
||||
|
||||
Args:
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
|
||||
Returns:
|
||||
Set of active jail names, or empty set on connection failure.
|
||||
"""
|
||||
try:
|
||||
client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT)
|
||||
|
||||
def _to_dict_inner(pairs: Any) -> dict[str, Any]:
|
||||
if not isinstance(pairs, (list, tuple)):
|
||||
return {}
|
||||
result: dict[str, Any] = {}
|
||||
for item in pairs:
|
||||
try:
|
||||
k, v = item
|
||||
result[str(k)] = v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return result
|
||||
|
||||
def _ok(response: Any) -> Any:
|
||||
code, data = response
|
||||
if code != 0:
|
||||
raise ValueError(f"fail2ban error {code}: {data!r}")
|
||||
return data
|
||||
|
||||
status_raw = _ok(await client.send(["status"]))
|
||||
status_dict = _to_dict_inner(status_raw)
|
||||
jail_list_raw: str = str(status_dict.get("Jail list", "") or "").strip()
|
||||
if not jail_list_raw:
|
||||
return set()
|
||||
return {j.strip() for j in jail_list_raw.split(",") if j.strip()}
|
||||
except Fail2BanConnectionError:
|
||||
log.warning("fail2ban_unreachable_during_inactive_list")
|
||||
return set()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning(
|
||||
"fail2ban_status_error_during_inactive_list", error=str(exc)
|
||||
)
|
||||
return set()
|
||||
|
||||
|
||||
def _write_local_override_sync(
|
||||
config_dir: Path,
|
||||
jail_name: str,
|
||||
enabled: bool,
|
||||
overrides: dict[str, Any],
|
||||
) -> None:
|
||||
"""Write a ``jail.d/{name}.local`` file atomically.
|
||||
|
||||
Always writes to ``jail.d/{jail_name}.local``. If the file already
|
||||
exists it is replaced entirely. The write is atomic: content is
|
||||
written to a temp file first, then renamed into place.
|
||||
|
||||
Args:
|
||||
config_dir: The fail2ban configuration root directory.
|
||||
jail_name: Validated jail name (used as filename stem).
|
||||
enabled: Value to write for ``enabled =``.
|
||||
overrides: Optional setting overrides (bantime, findtime, maxretry,
|
||||
port, logpath).
|
||||
|
||||
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"
|
||||
|
||||
lines: list[str] = [
|
||||
"# Managed by BanGUI — do not edit manually",
|
||||
"",
|
||||
f"[{jail_name}]",
|
||||
"",
|
||||
f"enabled = {'true' if enabled else 'false'}",
|
||||
]
|
||||
|
||||
if overrides.get("bantime") is not None:
|
||||
lines.append(f"bantime = {overrides['bantime']}")
|
||||
if overrides.get("findtime") is not None:
|
||||
lines.append(f"findtime = {overrides['findtime']}")
|
||||
if overrides.get("maxretry") is not None:
|
||||
lines.append(f"maxretry = {overrides['maxretry']}")
|
||||
if overrides.get("port") is not None:
|
||||
lines.append(f"port = {overrides['port']}")
|
||||
if overrides.get("logpath"):
|
||||
paths: list[str] = overrides["logpath"]
|
||||
if paths:
|
||||
lines.append(f"logpath = {paths[0]}")
|
||||
for p in paths[1:]:
|
||||
lines.append(f" {p}")
|
||||
|
||||
content = "\n".join(lines) + "\n"
|
||||
|
||||
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:
|
||||
# Clean up temp file if rename failed.
|
||||
try:
|
||||
os.unlink(tmp_name) # noqa: F821 — only reachable when tmp_name is set
|
||||
except OSError:
|
||||
pass
|
||||
raise ConfigWriteError(
|
||||
f"Failed to write {local_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
log.info(
|
||||
"jail_local_written",
|
||||
jail=jail_name,
|
||||
path=str(local_path),
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def list_inactive_jails(
|
||||
config_dir: str,
|
||||
socket_path: str,
|
||||
) -> InactiveJailListResponse:
|
||||
"""Return all jails defined in config files that are not currently active.
|
||||
|
||||
Parses ``jail.conf``, ``jail.local``, and ``jail.d/`` following the
|
||||
fail2ban merge order. A jail is considered inactive when:
|
||||
|
||||
- Its merged ``enabled`` value is ``false`` (or absent, which defaults to
|
||||
``false`` in fail2ban), **or**
|
||||
- Its ``enabled`` value is ``true`` in config but fail2ban does not report
|
||||
it as running.
|
||||
|
||||
Args:
|
||||
config_dir: Absolute path to the fail2ban configuration directory.
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.InactiveJailListResponse` with all
|
||||
inactive jails.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
parsed_result: tuple[dict[str, dict[str, str]], dict[str, str]] = (
|
||||
await loop.run_in_executor(None, _parse_jails_sync, Path(config_dir))
|
||||
)
|
||||
all_jails, source_files = parsed_result
|
||||
active_names: set[str] = await _get_active_jail_names(socket_path)
|
||||
|
||||
inactive: list[InactiveJail] = []
|
||||
for jail_name, settings in sorted(all_jails.items()):
|
||||
if jail_name in active_names:
|
||||
# fail2ban reports this jail as running — skip it.
|
||||
continue
|
||||
|
||||
source = source_files.get(jail_name, config_dir)
|
||||
inactive.append(_build_inactive_jail(jail_name, settings, source))
|
||||
|
||||
log.info(
|
||||
"inactive_jails_listed",
|
||||
total_defined=len(all_jails),
|
||||
active=len(active_names),
|
||||
inactive=len(inactive),
|
||||
)
|
||||
return InactiveJailListResponse(jails=inactive, total=len(inactive))
|
||||
|
||||
|
||||
async def activate_jail(
|
||||
config_dir: str,
|
||||
socket_path: str,
|
||||
name: str,
|
||||
req: ActivateJailRequest,
|
||||
) -> JailActivationResponse:
|
||||
"""Enable an inactive jail and reload fail2ban.
|
||||
|
||||
Writes ``enabled = true`` (plus any override values from *req*) to
|
||||
``jail.d/{name}.local`` and then triggers a full fail2ban reload so the
|
||||
jail starts immediately.
|
||||
|
||||
Args:
|
||||
config_dir: Absolute path to the fail2ban configuration directory.
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Name of the jail to activate. Must exist in the parsed config.
|
||||
req: Optional override values to write alongside ``enabled = true``.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.JailActivationResponse`.
|
||||
|
||||
Raises:
|
||||
JailNameError: If *name* contains invalid characters.
|
||||
JailNotFoundInConfigError: If *name* is not defined in any config file.
|
||||
JailAlreadyActiveError: If fail2ban already reports *name* as running.
|
||||
ConfigWriteError: If writing the ``.local`` file fails.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the fail2ban
|
||||
socket is unreachable during reload.
|
||||
"""
|
||||
_safe_jail_name(name)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
all_jails, _source_files = await loop.run_in_executor(
|
||||
None, _parse_jails_sync, Path(config_dir)
|
||||
)
|
||||
|
||||
if name not in all_jails:
|
||||
raise JailNotFoundInConfigError(name)
|
||||
|
||||
active_names = await _get_active_jail_names(socket_path)
|
||||
if name in active_names:
|
||||
raise JailAlreadyActiveError(name)
|
||||
|
||||
overrides: dict[str, Any] = {
|
||||
"bantime": req.bantime,
|
||||
"findtime": req.findtime,
|
||||
"maxretry": req.maxretry,
|
||||
"port": req.port,
|
||||
"logpath": req.logpath,
|
||||
}
|
||||
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
_write_local_override_sync,
|
||||
Path(config_dir),
|
||||
name,
|
||||
True,
|
||||
overrides,
|
||||
)
|
||||
|
||||
try:
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("reload_after_activate_failed", jail=name, error=str(exc))
|
||||
|
||||
log.info("jail_activated", jail=name)
|
||||
return JailActivationResponse(
|
||||
name=name,
|
||||
active=True,
|
||||
message=f"Jail {name!r} activated successfully.",
|
||||
)
|
||||
|
||||
|
||||
async def deactivate_jail(
|
||||
config_dir: str,
|
||||
socket_path: str,
|
||||
name: str,
|
||||
) -> JailActivationResponse:
|
||||
"""Disable an active jail and reload fail2ban.
|
||||
|
||||
Writes ``enabled = false`` to ``jail.d/{name}.local`` and triggers a
|
||||
full fail2ban reload so the jail stops immediately.
|
||||
|
||||
Args:
|
||||
config_dir: Absolute path to the fail2ban configuration directory.
|
||||
socket_path: Path to the fail2ban Unix domain socket.
|
||||
name: Name of the jail to deactivate. Must exist in the parsed config.
|
||||
|
||||
Returns:
|
||||
:class:`~app.models.config.JailActivationResponse`.
|
||||
|
||||
Raises:
|
||||
JailNameError: If *name* contains invalid characters.
|
||||
JailNotFoundInConfigError: If *name* is not defined in any config file.
|
||||
JailAlreadyInactiveError: If fail2ban already reports *name* as not
|
||||
running.
|
||||
ConfigWriteError: If writing the ``.local`` file fails.
|
||||
~app.utils.fail2ban_client.Fail2BanConnectionError: If the fail2ban
|
||||
socket is unreachable during reload.
|
||||
"""
|
||||
_safe_jail_name(name)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
all_jails, _source_files = await loop.run_in_executor(
|
||||
None, _parse_jails_sync, Path(config_dir)
|
||||
)
|
||||
|
||||
if name not in all_jails:
|
||||
raise JailNotFoundInConfigError(name)
|
||||
|
||||
active_names = await _get_active_jail_names(socket_path)
|
||||
if name not in active_names:
|
||||
raise JailAlreadyInactiveError(name)
|
||||
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
_write_local_override_sync,
|
||||
Path(config_dir),
|
||||
name,
|
||||
False,
|
||||
{},
|
||||
)
|
||||
|
||||
try:
|
||||
await jail_service.reload_all(socket_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("reload_after_deactivate_failed", jail=name, error=str(exc))
|
||||
|
||||
log.info("jail_deactivated", jail=name)
|
||||
return JailActivationResponse(
|
||||
name=name,
|
||||
active=False,
|
||||
message=f"Jail {name!r} deactivated successfully.",
|
||||
)
|
||||
@@ -575,3 +575,245 @@ class TestUpdateMapColorThresholds:
|
||||
|
||||
# Pydantic validates ge=1 constraint before our service code runs
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/config/jails/inactive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetInactiveJails:
|
||||
"""Tests for ``GET /api/config/jails/inactive``."""
|
||||
|
||||
async def test_200_returns_inactive_list(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/jails/inactive returns 200 with InactiveJailListResponse."""
|
||||
from app.models.config import InactiveJail, InactiveJailListResponse
|
||||
|
||||
mock_jail = InactiveJail(
|
||||
name="apache-auth",
|
||||
filter="apache-auth",
|
||||
actions=[],
|
||||
port="http,https",
|
||||
logpath=["/var/log/apache2/error.log"],
|
||||
bantime="10m",
|
||||
findtime="5m",
|
||||
maxretry=5,
|
||||
source_file="/etc/fail2ban/jail.conf",
|
||||
enabled=False,
|
||||
)
|
||||
mock_response = InactiveJailListResponse(jails=[mock_jail], total=1)
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.list_inactive_jails",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await config_client.get("/api/config/jails/inactive")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["jails"][0]["name"] == "apache-auth"
|
||||
|
||||
async def test_200_empty_list(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/jails/inactive returns 200 with empty list."""
|
||||
from app.models.config import InactiveJailListResponse
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.list_inactive_jails",
|
||||
AsyncMock(return_value=InactiveJailListResponse(jails=[], total=0)),
|
||||
):
|
||||
resp = await config_client.get("/api/config/jails/inactive")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 0
|
||||
assert resp.json()["jails"] == []
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
"""GET /api/config/jails/inactive returns 401 without a valid session."""
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).get("/api/config/jails/inactive")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/config/jails/{name}/activate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestActivateJail:
|
||||
"""Tests for ``POST /api/config/jails/{name}/activate``."""
|
||||
|
||||
async def test_200_activates_jail(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/jails/apache-auth/activate returns 200."""
|
||||
from app.models.config import JailActivationResponse
|
||||
|
||||
mock_response = JailActivationResponse(
|
||||
name="apache-auth",
|
||||
active=True,
|
||||
message="Jail 'apache-auth' activated successfully.",
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.activate_jail",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/jails/apache-auth/activate", json={}
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["active"] is True
|
||||
assert data["name"] == "apache-auth"
|
||||
|
||||
async def test_200_with_overrides(self, config_client: AsyncClient) -> None:
|
||||
"""POST .../activate accepts override fields."""
|
||||
from app.models.config import JailActivationResponse
|
||||
|
||||
mock_response = JailActivationResponse(
|
||||
name="apache-auth", active=True, message="Activated."
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.activate_jail",
|
||||
AsyncMock(return_value=mock_response),
|
||||
) as mock_activate:
|
||||
resp = await config_client.post(
|
||||
"/api/config/jails/apache-auth/activate",
|
||||
json={"bantime": "1h", "maxretry": 3},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
# Verify the override values were passed to the service
|
||||
called_req = mock_activate.call_args.args[3]
|
||||
assert called_req.bantime == "1h"
|
||||
assert called_req.maxretry == 3
|
||||
|
||||
async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/jails/missing/activate returns 404."""
|
||||
from app.services.config_file_service import JailNotFoundInConfigError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.activate_jail",
|
||||
AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/jails/missing/activate", json={}
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_409_when_already_active(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/jails/sshd/activate returns 409 if already active."""
|
||||
from app.services.config_file_service import JailAlreadyActiveError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.activate_jail",
|
||||
AsyncMock(side_effect=JailAlreadyActiveError("sshd")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/jails/sshd/activate", json={}
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
|
||||
async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/jails/ with bad name returns 400."""
|
||||
from app.services.config_file_service import JailNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.activate_jail",
|
||||
AsyncMock(side_effect=JailNameError("bad name")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/jails/bad-name/activate", json={}
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/jails/sshd/activate returns 401 without session."""
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).post("/api/config/jails/sshd/activate", json={})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/config/jails/{name}/deactivate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeactivateJail:
|
||||
"""Tests for ``POST /api/config/jails/{name}/deactivate``."""
|
||||
|
||||
async def test_200_deactivates_jail(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/jails/sshd/deactivate returns 200."""
|
||||
from app.models.config import JailActivationResponse
|
||||
|
||||
mock_response = JailActivationResponse(
|
||||
name="sshd",
|
||||
active=False,
|
||||
message="Jail 'sshd' deactivated successfully.",
|
||||
)
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.deactivate_jail",
|
||||
AsyncMock(return_value=mock_response),
|
||||
):
|
||||
resp = await config_client.post("/api/config/jails/sshd/deactivate")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["active"] is False
|
||||
assert data["name"] == "sshd"
|
||||
|
||||
async def test_404_for_unknown_jail(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/jails/missing/deactivate returns 404."""
|
||||
from app.services.config_file_service import JailNotFoundInConfigError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.deactivate_jail",
|
||||
AsyncMock(side_effect=JailNotFoundInConfigError("missing")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/jails/missing/deactivate"
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_409_when_already_inactive(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/jails/apache-auth/deactivate returns 409 if already inactive."""
|
||||
from app.services.config_file_service import JailAlreadyInactiveError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.deactivate_jail",
|
||||
AsyncMock(side_effect=JailAlreadyInactiveError("apache-auth")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/jails/apache-auth/deactivate"
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
|
||||
async def test_400_for_invalid_jail_name(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/jails/.../deactivate with bad name returns 400."""
|
||||
from app.services.config_file_service import JailNameError
|
||||
|
||||
with patch(
|
||||
"app.routers.config.config_file_service.deactivate_jail",
|
||||
AsyncMock(side_effect=JailNameError("bad")),
|
||||
):
|
||||
resp = await config_client.post(
|
||||
"/api/config/jails/sshd/deactivate"
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_401_when_unauthenticated(self, config_client: AsyncClient) -> None:
|
||||
"""POST /api/config/jails/sshd/deactivate returns 401 without session."""
|
||||
resp = await AsyncClient(
|
||||
transport=ASGITransport(app=config_client._transport.app), # type: ignore[attr-defined]
|
||||
base_url="http://test",
|
||||
).post("/api/config/jails/sshd/deactivate")
|
||||
assert resp.status_code == 401
|
||||
|
||||
559
backend/tests/test_services/test_config_file_service.py
Normal file
559
backend/tests/test_services/test_config_file_service.py
Normal file
@@ -0,0 +1,559 @@
|
||||
"""Tests for config_file_service — fail2ban jail config parser and activator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.config_file_service import (
|
||||
JailAlreadyActiveError,
|
||||
JailAlreadyInactiveError,
|
||||
JailNameError,
|
||||
JailNotFoundInConfigError,
|
||||
_build_inactive_jail,
|
||||
_ordered_config_files,
|
||||
_parse_jails_sync,
|
||||
_resolve_filter,
|
||||
_safe_jail_name,
|
||||
_write_local_override_sync,
|
||||
activate_jail,
|
||||
deactivate_jail,
|
||||
list_inactive_jails,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write(path: Path, content: str) -> None:
|
||||
"""Write text to *path*, creating parent directories if needed."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _safe_jail_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSafeJailName:
|
||||
def test_valid_simple(self) -> None:
|
||||
assert _safe_jail_name("sshd") == "sshd"
|
||||
|
||||
def test_valid_with_hyphen(self) -> None:
|
||||
assert _safe_jail_name("apache-auth") == "apache-auth"
|
||||
|
||||
def test_valid_with_dot(self) -> None:
|
||||
assert _safe_jail_name("nginx.http") == "nginx.http"
|
||||
|
||||
def test_valid_with_underscore(self) -> None:
|
||||
assert _safe_jail_name("my_jail") == "my_jail"
|
||||
|
||||
def test_invalid_path_traversal(self) -> None:
|
||||
with pytest.raises(JailNameError):
|
||||
_safe_jail_name("../evil")
|
||||
|
||||
def test_invalid_slash(self) -> None:
|
||||
with pytest.raises(JailNameError):
|
||||
_safe_jail_name("a/b")
|
||||
|
||||
def test_invalid_starts_with_dash(self) -> None:
|
||||
with pytest.raises(JailNameError):
|
||||
_safe_jail_name("-bad")
|
||||
|
||||
def test_invalid_empty(self) -> None:
|
||||
with pytest.raises(JailNameError):
|
||||
_safe_jail_name("")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveFilter:
|
||||
def test_name_substitution(self) -> None:
|
||||
result = _resolve_filter("%(__name__)s", "sshd", "normal")
|
||||
assert result == "sshd"
|
||||
|
||||
def test_mode_substitution(self) -> None:
|
||||
result = _resolve_filter("%(__name__)s[mode=%(mode)s]", "sshd", "aggressive")
|
||||
assert result == "sshd[mode=aggressive]"
|
||||
|
||||
def test_no_substitution_needed(self) -> None:
|
||||
result = _resolve_filter("my-filter", "sshd", "normal")
|
||||
assert result == "my-filter"
|
||||
|
||||
def test_empty_raw(self) -> None:
|
||||
result = _resolve_filter("", "sshd", "normal")
|
||||
assert result == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _ordered_config_files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOrderedConfigFiles:
|
||||
def test_empty_dir(self, tmp_path: Path) -> None:
|
||||
result = _ordered_config_files(tmp_path)
|
||||
assert result == []
|
||||
|
||||
def test_jail_conf_only(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", "[sshd]\nenabled=true\n")
|
||||
result = _ordered_config_files(tmp_path)
|
||||
assert result == [tmp_path / "jail.conf"]
|
||||
|
||||
def test_full_merge_order(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", "[DEFAULT]\n")
|
||||
_write(tmp_path / "jail.local", "[DEFAULT]\n")
|
||||
_write(tmp_path / "jail.d" / "custom.conf", "[sshd]\n")
|
||||
_write(tmp_path / "jail.d" / "custom.local", "[sshd]\n")
|
||||
|
||||
result = _ordered_config_files(tmp_path)
|
||||
|
||||
assert result[0] == tmp_path / "jail.conf"
|
||||
assert result[1] == tmp_path / "jail.local"
|
||||
assert result[2] == tmp_path / "jail.d" / "custom.conf"
|
||||
assert result[3] == tmp_path / "jail.d" / "custom.local"
|
||||
|
||||
def test_jail_d_sorted_alphabetically(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "jail.d").mkdir()
|
||||
for name in ("zzz.conf", "aaa.conf", "mmm.conf"):
|
||||
_write(tmp_path / "jail.d" / name, "")
|
||||
result = _ordered_config_files(tmp_path)
|
||||
names = [p.name for p in result]
|
||||
assert names == ["aaa.conf", "mmm.conf", "zzz.conf"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_jails_sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
JAIL_CONF = """\
|
||||
[DEFAULT]
|
||||
bantime = 10m
|
||||
findtime = 5m
|
||||
maxretry = 5
|
||||
|
||||
[sshd]
|
||||
enabled = true
|
||||
filter = sshd
|
||||
port = ssh
|
||||
logpath = /var/log/auth.log
|
||||
|
||||
[apache-auth]
|
||||
enabled = false
|
||||
filter = apache-auth
|
||||
port = http,https
|
||||
logpath = /var/log/apache2/error.log
|
||||
"""
|
||||
|
||||
JAIL_LOCAL = """\
|
||||
[sshd]
|
||||
bantime = 1h
|
||||
"""
|
||||
|
||||
JAIL_D_CUSTOM = """\
|
||||
[nginx-http-auth]
|
||||
enabled = false
|
||||
filter = nginx-http-auth
|
||||
port = http,https
|
||||
logpath = /var/log/nginx/error.log
|
||||
"""
|
||||
|
||||
|
||||
class TestParseJailsSync:
|
||||
def test_parses_all_jails(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
jails, _ = _parse_jails_sync(tmp_path)
|
||||
assert "sshd" in jails
|
||||
assert "apache-auth" in jails
|
||||
|
||||
def test_enabled_flag_parsing(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
jails, _ = _parse_jails_sync(tmp_path)
|
||||
assert jails["sshd"]["enabled"] == "true"
|
||||
assert jails["apache-auth"]["enabled"] == "false"
|
||||
|
||||
def test_default_inheritance(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
jails, _ = _parse_jails_sync(tmp_path)
|
||||
# DEFAULT values should flow into each jail via configparser
|
||||
assert jails["sshd"]["bantime"] == "10m"
|
||||
assert jails["apache-auth"]["maxretry"] == "5"
|
||||
|
||||
def test_local_override_wins(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
_write(tmp_path / "jail.local", JAIL_LOCAL)
|
||||
jails, _ = _parse_jails_sync(tmp_path)
|
||||
# jail.local overrides bantime for sshd from 10m → 1h
|
||||
assert jails["sshd"]["bantime"] == "1h"
|
||||
|
||||
def test_jail_d_conf_included(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
_write(tmp_path / "jail.d" / "custom.conf", JAIL_D_CUSTOM)
|
||||
jails, _ = _parse_jails_sync(tmp_path)
|
||||
assert "nginx-http-auth" in jails
|
||||
|
||||
def test_source_file_tracked(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
_write(tmp_path / "jail.d" / "custom.conf", JAIL_D_CUSTOM)
|
||||
_, source_files = _parse_jails_sync(tmp_path)
|
||||
# sshd comes from jail.conf; nginx-http-auth from jail.d/custom.conf
|
||||
assert source_files["sshd"] == str(tmp_path / "jail.conf")
|
||||
assert source_files["nginx-http-auth"] == str(tmp_path / "jail.d" / "custom.conf")
|
||||
|
||||
def test_source_file_local_override_tracked(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
_write(tmp_path / "jail.local", JAIL_LOCAL)
|
||||
_, source_files = _parse_jails_sync(tmp_path)
|
||||
# jail.local defines [sshd] again → that file wins
|
||||
assert source_files["sshd"] == str(tmp_path / "jail.local")
|
||||
|
||||
def test_default_section_excluded(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
jails, _ = _parse_jails_sync(tmp_path)
|
||||
assert "DEFAULT" not in jails
|
||||
|
||||
def test_includes_section_excluded(self, tmp_path: Path) -> None:
|
||||
content = "[INCLUDES]\nbefore = paths-debian.conf\n" + JAIL_CONF
|
||||
_write(tmp_path / "jail.conf", content)
|
||||
jails, _ = _parse_jails_sync(tmp_path)
|
||||
assert "INCLUDES" not in jails
|
||||
|
||||
def test_corrupt_file_skipped_gracefully(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", "[[bad section\n")
|
||||
# Should not raise; bad section just yields no jails
|
||||
jails, _ = _parse_jails_sync(tmp_path)
|
||||
assert isinstance(jails, dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_inactive_jail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildInactiveJail:
|
||||
def test_basic_fields(self) -> None:
|
||||
settings = {
|
||||
"enabled": "false",
|
||||
"filter": "sshd",
|
||||
"port": "ssh",
|
||||
"logpath": "/var/log/auth.log",
|
||||
"bantime": "10m",
|
||||
"findtime": "5m",
|
||||
"maxretry": "5",
|
||||
"action": "",
|
||||
}
|
||||
jail = _build_inactive_jail("sshd", settings, "/etc/fail2ban/jail.d/sshd.conf")
|
||||
assert jail.name == "sshd"
|
||||
assert jail.filter == "sshd"
|
||||
assert jail.port == "ssh"
|
||||
assert jail.logpath == ["/var/log/auth.log"]
|
||||
assert jail.bantime == "10m"
|
||||
assert jail.findtime == "5m"
|
||||
assert jail.maxretry == 5
|
||||
assert jail.enabled is False
|
||||
assert "sshd.conf" in jail.source_file
|
||||
|
||||
def test_filter_name_substitution(self) -> None:
|
||||
settings = {"enabled": "false", "filter": "%(__name__)s"}
|
||||
jail = _build_inactive_jail("myservice", settings, "/etc/fail2ban/jail.conf")
|
||||
assert jail.filter == "myservice"
|
||||
|
||||
def test_missing_optional_fields(self) -> None:
|
||||
jail = _build_inactive_jail("minimal", {}, "/etc/fail2ban/jail.conf")
|
||||
assert jail.filter == "minimal" # falls back to name
|
||||
assert jail.port is None
|
||||
assert jail.logpath == []
|
||||
assert jail.bantime is None
|
||||
assert jail.maxretry is None
|
||||
|
||||
def test_multiline_logpath(self) -> None:
|
||||
settings = {"logpath": "/var/log/app.log\n/var/log/app2.log"}
|
||||
jail = _build_inactive_jail("app", settings, "/etc/fail2ban/jail.conf")
|
||||
assert "/var/log/app.log" in jail.logpath
|
||||
assert "/var/log/app2.log" in jail.logpath
|
||||
|
||||
def test_multiline_actions(self) -> None:
|
||||
settings = {"action": "iptables-multiport\niptables-ipset"}
|
||||
jail = _build_inactive_jail("app", settings, "/etc/fail2ban/jail.conf")
|
||||
assert len(jail.actions) == 2
|
||||
|
||||
def test_enabled_true(self) -> None:
|
||||
settings = {"enabled": "true"}
|
||||
jail = _build_inactive_jail("active-jail", settings, "/etc/fail2ban/jail.conf")
|
||||
assert jail.enabled is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _write_local_override_sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWriteLocalOverrideSync:
|
||||
def test_creates_local_file(self, tmp_path: Path) -> None:
|
||||
_write_local_override_sync(tmp_path, "sshd", True, {})
|
||||
local = tmp_path / "jail.d" / "sshd.local"
|
||||
assert local.is_file()
|
||||
|
||||
def test_enabled_true_written(self, tmp_path: Path) -> None:
|
||||
_write_local_override_sync(tmp_path, "sshd", True, {})
|
||||
content = (tmp_path / "jail.d" / "sshd.local").read_text()
|
||||
assert "enabled = true" in content
|
||||
|
||||
def test_enabled_false_written(self, tmp_path: Path) -> None:
|
||||
_write_local_override_sync(tmp_path, "sshd", False, {})
|
||||
content = (tmp_path / "jail.d" / "sshd.local").read_text()
|
||||
assert "enabled = false" in content
|
||||
|
||||
def test_section_header_written(self, tmp_path: Path) -> None:
|
||||
_write_local_override_sync(tmp_path, "apache-auth", True, {})
|
||||
content = (tmp_path / "jail.d" / "apache-auth.local").read_text()
|
||||
assert "[apache-auth]" in content
|
||||
|
||||
def test_override_bantime(self, tmp_path: Path) -> None:
|
||||
_write_local_override_sync(tmp_path, "sshd", True, {"bantime": "1h"})
|
||||
content = (tmp_path / "jail.d" / "sshd.local").read_text()
|
||||
assert "bantime" in content
|
||||
assert "1h" in content
|
||||
|
||||
def test_override_findtime(self, tmp_path: Path) -> None:
|
||||
_write_local_override_sync(tmp_path, "sshd", True, {"findtime": "30m"})
|
||||
content = (tmp_path / "jail.d" / "sshd.local").read_text()
|
||||
assert "findtime" in content
|
||||
assert "30m" in content
|
||||
|
||||
def test_override_maxretry(self, tmp_path: Path) -> None:
|
||||
_write_local_override_sync(tmp_path, "sshd", True, {"maxretry": 3})
|
||||
content = (tmp_path / "jail.d" / "sshd.local").read_text()
|
||||
assert "maxretry" in content
|
||||
assert "3" in content
|
||||
|
||||
def test_override_port(self, tmp_path: Path) -> None:
|
||||
_write_local_override_sync(tmp_path, "sshd", True, {"port": "2222"})
|
||||
content = (tmp_path / "jail.d" / "sshd.local").read_text()
|
||||
assert "2222" in content
|
||||
|
||||
def test_override_logpath_list(self, tmp_path: Path) -> None:
|
||||
_write_local_override_sync(
|
||||
tmp_path, "sshd", True, {"logpath": ["/var/log/auth.log", "/var/log/secure"]}
|
||||
)
|
||||
content = (tmp_path / "jail.d" / "sshd.local").read_text()
|
||||
assert "/var/log/auth.log" in content
|
||||
assert "/var/log/secure" in content
|
||||
|
||||
def test_bang_gui_header_comment(self, tmp_path: Path) -> None:
|
||||
_write_local_override_sync(tmp_path, "sshd", True, {})
|
||||
content = (tmp_path / "jail.d" / "sshd.local").read_text()
|
||||
assert "BanGUI" in content
|
||||
|
||||
def test_overwrites_existing_file(self, tmp_path: Path) -> None:
|
||||
local = tmp_path / "jail.d" / "sshd.local"
|
||||
local.parent.mkdir()
|
||||
local.write_text("old content")
|
||||
_write_local_override_sync(tmp_path, "sshd", True, {})
|
||||
assert "old content" not in local.read_text()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_inactive_jails
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestListInactiveJails:
|
||||
async def test_returns_only_inactive(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
# sshd is enabled=true; apache-auth is enabled=false
|
||||
with patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value={"sshd"}),
|
||||
):
|
||||
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
|
||||
|
||||
names = [j.name for j in result.jails]
|
||||
assert "sshd" not in names
|
||||
assert "apache-auth" in names
|
||||
|
||||
async def test_total_matches_jails_count(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
with patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value={"sshd"}),
|
||||
):
|
||||
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
|
||||
|
||||
assert result.total == len(result.jails)
|
||||
|
||||
async def test_empty_config_dir(self, tmp_path: Path) -> None:
|
||||
with patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
):
|
||||
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
|
||||
|
||||
assert result.jails == []
|
||||
assert result.total == 0
|
||||
|
||||
async def test_all_active_returns_empty(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
with patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value={"sshd", "apache-auth"}),
|
||||
):
|
||||
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
|
||||
|
||||
assert result.jails == []
|
||||
|
||||
async def test_fail2ban_unreachable_shows_all(self, tmp_path: Path) -> None:
|
||||
# When fail2ban is unreachable, _get_active_jail_names returns empty set,
|
||||
# so every config-defined jail appears as inactive.
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
with patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
):
|
||||
result = await list_inactive_jails(str(tmp_path), "/fake.sock")
|
||||
|
||||
names = {j.name for j in result.jails}
|
||||
assert "sshd" in names
|
||||
assert "apache-auth" in names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# activate_jail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestActivateJail:
|
||||
async def test_activates_known_inactive_jail(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
from app.models.config import ActivateJailRequest
|
||||
|
||||
req = ActivateJailRequest()
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
),
|
||||
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||
):
|
||||
mock_js.reload_all = AsyncMock()
|
||||
result = await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req)
|
||||
|
||||
assert result.active is True
|
||||
assert "apache-auth" in result.name
|
||||
local = tmp_path / "jail.d" / "apache-auth.local"
|
||||
assert local.is_file()
|
||||
assert "enabled = true" in local.read_text()
|
||||
|
||||
async def test_raises_not_found_for_unknown_jail(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
from app.models.config import ActivateJailRequest
|
||||
|
||||
req = ActivateJailRequest()
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
),
|
||||
):
|
||||
with pytest.raises(JailNotFoundInConfigError):
|
||||
await activate_jail(str(tmp_path), "/fake.sock", "nonexistent", req)
|
||||
|
||||
async def test_raises_already_active(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
from app.models.config import ActivateJailRequest
|
||||
|
||||
req = ActivateJailRequest()
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value={"sshd"}),
|
||||
),
|
||||
):
|
||||
with pytest.raises(JailAlreadyActiveError):
|
||||
await activate_jail(str(tmp_path), "/fake.sock", "sshd", req)
|
||||
|
||||
async def test_raises_name_error_for_bad_name(self, tmp_path: Path) -> None:
|
||||
from app.models.config import ActivateJailRequest
|
||||
|
||||
req = ActivateJailRequest()
|
||||
with pytest.raises(JailNameError):
|
||||
await activate_jail(str(tmp_path), "/fake.sock", "../etc/passwd", req)
|
||||
|
||||
async def test_writes_overrides_to_local_file(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
from app.models.config import ActivateJailRequest
|
||||
|
||||
req = ActivateJailRequest(bantime="2h", maxretry=3)
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
),
|
||||
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||
):
|
||||
mock_js.reload_all = AsyncMock()
|
||||
await activate_jail(str(tmp_path), "/fake.sock", "apache-auth", req)
|
||||
|
||||
content = (tmp_path / "jail.d" / "apache-auth.local").read_text()
|
||||
assert "2h" in content
|
||||
assert "3" in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# deactivate_jail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDeactivateJail:
|
||||
async def test_deactivates_active_jail(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value={"sshd"}),
|
||||
),
|
||||
patch("app.services.config_file_service.jail_service") as mock_js,
|
||||
):
|
||||
mock_js.reload_all = AsyncMock()
|
||||
result = await deactivate_jail(str(tmp_path), "/fake.sock", "sshd")
|
||||
|
||||
assert result.active is False
|
||||
local = tmp_path / "jail.d" / "sshd.local"
|
||||
assert local.is_file()
|
||||
assert "enabled = false" in local.read_text()
|
||||
|
||||
async def test_raises_not_found(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value={"sshd"}),
|
||||
),
|
||||
):
|
||||
with pytest.raises(JailNotFoundInConfigError):
|
||||
await deactivate_jail(str(tmp_path), "/fake.sock", "nonexistent")
|
||||
|
||||
async def test_raises_already_inactive(self, tmp_path: Path) -> None:
|
||||
_write(tmp_path / "jail.conf", JAIL_CONF)
|
||||
with (
|
||||
patch(
|
||||
"app.services.config_file_service._get_active_jail_names",
|
||||
new=AsyncMock(return_value=set()),
|
||||
),
|
||||
):
|
||||
with pytest.raises(JailAlreadyInactiveError):
|
||||
await deactivate_jail(str(tmp_path), "/fake.sock", "apache-auth")
|
||||
|
||||
async def test_raises_name_error(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(JailNameError):
|
||||
await deactivate_jail(str(tmp_path), "/fake.sock", "a/b")
|
||||
Reference in New Issue
Block a user