Consolidate domain exceptions into app.exceptions

Move all shared domain exception classes to backend/app/exceptions.py and update services/routers to import the canonical exceptions. Update docs to reflect the shared exceptions source.
This commit is contained in:
2026-04-13 19:35:12 +02:00
parent 4b2e86edbb
commit a5674f9e4c
10 changed files with 199 additions and 381 deletions

View File

@@ -85,7 +85,7 @@ backend/
│ ├── `main.py` # FastAPI app factory, lifespan, exception handlers
│ ├── `config.py` # Pydantic settings (env vars, .env loading)
│ ├── `db.py` # Database connection and initialization
│ ├── `exceptions.py` # Shared domain exception classes
│ ├── `exceptions.py` # Shared domain exception classes; all services and routers import from here
│ ├── `dependencies.py` # FastAPI Depends() providers (DB, services, auth)
│ ├── `models/` # Pydantic schemas
│ │ ├── auth.py # Login request/response, session models

View File

@@ -90,6 +90,8 @@ Any config file write error in a filter operation (bad content, permission denie
### Task 4 — Consolidate all exception classes into app.exceptions
**Status:** Completed
**Severity:** High
**Where:**

View File

@@ -51,3 +51,83 @@ class ConfigWriteError(Exception):
def __init__(self, message: str) -> None:
self.message = message
super().__init__(message)
class JailNameError(Exception):
"""Raised when a jail name contains invalid characters."""
class JailAlreadyActiveError(Exception):
"""Raised when trying to activate a jail that is already active."""
def __init__(self, name: str) -> None:
self.name = 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:
self.name = name
super().__init__(f"Jail is already inactive: {name!r}")
class FilterNotFoundError(Exception):
"""Raised when the requested filter name is not found."""
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Filter not found: {name!r}")
class FilterAlreadyExistsError(Exception):
"""Raised when trying to create a filter whose `.conf` or `.local` already exists."""
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Filter already exists: {name!r}")
class FilterNameError(Exception):
"""Raised when a filter name contains invalid characters."""
class FilterReadonlyError(Exception):
"""Raised when trying to delete a shipped `.conf` filter with no `.local` override."""
def __init__(self, name: str) -> None:
self.name = name
super().__init__(
f"Filter {name!r} is a shipped default (.conf only); only user-created .local files can be deleted."
)
class ActionNotFoundError(Exception):
"""Raised when the requested action name is not found."""
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Action not found: {name!r}")
class ActionAlreadyExistsError(Exception):
"""Raised when trying to create an action whose `.conf` or `.local` already exists."""
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"Action already exists: {name!r}")
class ActionNameError(Exception):
"""Raised when an action name contains invalid characters."""
class ActionReadonlyError(Exception):
"""Raised when trying to delete a shipped `.conf` action with no `.local` override."""
def __init__(self, name: str) -> None:
self.name = name
super().__init__(
f"Action {name!r} is a shipped default (.conf only); only user-created .local files can be deleted."
)

View File

@@ -2,10 +2,18 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, HTTPException, Path, Query, status
from fastapi import APIRouter, HTTPException, Path, Query, Request, status
from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep
from app.exceptions import ConfigWriteError
from app.exceptions import (
ActionAlreadyExistsError,
ActionNameError,
ActionNotFoundError,
ActionReadonlyError,
ConfigWriteError,
JailNameError,
JailNotFoundInConfigError,
)
from app.models.config import (
ActionConfig,
ActionCreateRequest,
@@ -14,13 +22,6 @@ from app.models.config import (
AssignActionRequest,
)
from app.services import action_config_service
from app.services.action_config_service import (
ActionAlreadyExistsError,
ActionNameError,
ActionNotFoundError,
ActionReadonlyError,
)
from app.services.jail_config_service import JailNameError, JailNotFoundInConfigError
router: APIRouter = APIRouter()
@@ -29,6 +30,11 @@ _ActionNamePath = Annotated[
Path(description='Action base name, e.g. ``iptables`` or ``iptables.conf``.'),
]
_NamePath = Annotated[
str,
Path(description='Jail name as configured in fail2ban.'),
]
def _action_not_found(name: str) -> HTTPException:
return HTTPException(
@@ -36,6 +42,17 @@ def _action_not_found(name: str) -> HTTPException:
detail=f"Action not found: {name!r}",
)
def _bad_request(message: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
def _not_found(name: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Jail not found: {name!r}",
)
@router.get(
"/actions",
response_model=ActionListResponse,

View File

@@ -2,26 +2,27 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, HTTPException, Path, Query, status
from fastapi import APIRouter, HTTPException, Path, Query, Request, status
from app.dependencies import AuthDep, Fail2BanConfigDirDep, Fail2BanSocketDep
from app.exceptions import ConfigWriteError
from app.models.config import (
AssignFilterRequest,
FilterConfig,
FilterConfigUpdate,
FilterCreateRequest,
FilterListResponse,
)
from app.services import filter_config_service
from app.services.filter_config_service import (
from app.exceptions import (
ConfigWriteError,
FilterAlreadyExistsError,
FilterInvalidRegexError,
FilterNameError,
FilterNotFoundError,
FilterReadonlyError,
JailNameError,
JailNotFoundInConfigError,
)
from app.services.jail_config_service import JailNameError, JailNotFoundInConfigError
from app.models.config import (
AssignFilterRequest,
FilterConfig,
FilterCreateRequest,
FilterListResponse,
FilterUpdateRequest,
)
from app.services import filter_config_service
router: APIRouter = APIRouter()
@@ -42,6 +43,21 @@ def _filter_not_found(name: str) -> HTTPException:
detail=f"Filter not found: {name!r}",
)
def _bad_request(message: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
def _unprocessable(message: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=message)
def _not_found(name: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Jail not found: {name!r}",
)
@router.get(
"/filters",
response_model=FilterListResponse,

View File

@@ -15,7 +15,12 @@ from app.dependencies import (
from app.exceptions import (
ConfigOperationError,
ConfigValidationError,
ConfigWriteError,
JailAlreadyActiveError,
JailAlreadyInactiveError,
JailNameError,
JailNotFoundError,
JailNotFoundInConfigError,
)
from app.models.config import (
ActivateJailRequest,
@@ -30,13 +35,6 @@ from app.models.config import (
RollbackResponse,
)
from app.services import config_service, jail_config_service, jail_service
from app.services.jail_config_service import (
ConfigWriteError,
JailAlreadyActiveError,
JailAlreadyInactiveError,
JailNameError,
JailNotFoundInConfigError,
)
from app.utils.fail2ban_client import Fail2BanConnectionError
router: APIRouter = APIRouter()

View File

@@ -7,7 +7,6 @@ for fail2ban action configurations.
from __future__ import annotations
import asyncio
from app.utils.async_utils import run_blocking
import configparser
import contextlib
import io
@@ -18,7 +17,15 @@ from pathlib import Path
import structlog
from app.exceptions import ConfigWriteError, JailNotFoundInConfigError
from app.exceptions import (
ActionAlreadyExistsError,
ActionNameError,
ActionNotFoundError,
ActionReadonlyError,
ConfigWriteError,
JailNameError,
JailNotFoundInConfigError,
)
from app.helpers.config_file_helpers import (
_get_active_jail_names,
_parse_jails_sync,
@@ -33,59 +40,10 @@ from app.models.config import (
AssignActionRequest,
)
from app.utils import conffile_parser
from app.utils.async_utils import run_blocking
log: structlog.stdlib.BoundLogger = structlog.get_logger()
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class ActionNotFoundError(Exception):
"""Raised when the requested action name is not found in ``action.d/``."""
def __init__(self, name: str) -> None:
"""Initialise with the action name that was not found.
Args:
name: The action name that could not be located.
"""
self.name: str = name
super().__init__(f"Action not found: {name!r}")
class ActionAlreadyExistsError(Exception):
"""Raised when trying to create an action whose ``.conf`` or ``.local`` already exists."""
def __init__(self, name: str) -> None:
"""Initialise with the action name that already exists.
Args:
name: The action name that already exists.
"""
self.name: str = name
super().__init__(f"Action already exists: {name!r}")
class ActionReadonlyError(Exception):
"""Raised when trying to delete a shipped ``.conf`` action with no ``.local`` override."""
def __init__(self, name: str) -> None:
"""Initialise with the action name that cannot be deleted.
Args:
name: The action name that is read-only (shipped ``.conf`` only).
"""
self.name: str = name
super().__init__(
f"Action {name!r} is a shipped default (.conf only); only user-created .local files can be deleted."
)
class ActionNameError(Exception):
"""Raised when an action name contains invalid characters."""
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
@@ -108,15 +66,6 @@ _TRUE_VALUES: frozenset[str] = frozenset({"true", "yes", "1"})
_FALSE_VALUES: frozenset[str] = frozenset({"false", "no", "0"})
# ---------------------------------------------------------------------------
# Helper exceptions
# ---------------------------------------------------------------------------
class JailNameError(Exception):
"""Raised when a jail name contains invalid characters."""
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
@@ -601,7 +550,6 @@ async def list_actions(
``used_by_jails`` lists.
"""
action_d = Path(config_dir) / "action.d"
loop = asyncio.get_event_loop()
raw_actions: list[tuple[str, str, str, bool, str]] = await run_blocking( _parse_actions_sync, action_d)
@@ -676,7 +624,6 @@ async def get_action(
action_d = Path(config_dir) / "action.d"
conf_path = action_d / f"{base_name}.conf"
local_path = action_d / f"{base_name}.local"
loop = asyncio.get_event_loop()
def _read() -> tuple[str, bool, str]:
"""Read action content and return (content, has_local_override, source_path)."""
@@ -787,7 +734,6 @@ async def update_action(
content = conffile_parser.serialize_action_config(merged)
action_d = Path(config_dir) / "action.d"
loop = asyncio.get_event_loop()
await run_blocking( _write_action_local_sync, action_d, base_name, content)
if do_reload:
@@ -840,7 +786,6 @@ async def create_action(
if conf_path.is_file() or local_path.is_file():
raise ActionAlreadyExistsError(req.name)
loop = asyncio.get_event_loop()
await run_blocking( _check_not_exists)
cfg = ActionConfig(
@@ -903,7 +848,6 @@ async def delete_action(
conf_path = action_d / f"{base_name}.conf"
local_path = action_d / f"{base_name}.local"
loop = asyncio.get_event_loop()
def _delete() -> None:
has_conf = conf_path.is_file()
@@ -957,7 +901,6 @@ async def assign_action_to_jail(
_safe_jail_name(jail_name)
_safe_action_name(req.action_name)
loop = asyncio.get_event_loop()
all_jails, _src = await run_blocking( _parse_jails_sync, Path(config_dir))
if jail_name not in all_jails:
@@ -1035,7 +978,6 @@ async def remove_action_from_jail(
_safe_jail_name(jail_name)
_safe_action_name(action_name)
loop = asyncio.get_event_loop()
all_jails, _src = await run_blocking( _parse_jails_sync, Path(config_dir))
if jail_name not in all_jails:

View File

@@ -21,7 +21,6 @@ directory traversal.
from __future__ import annotations
import asyncio
from app.utils.async_utils import run_blocking
import configparser
import contextlib
import io
@@ -33,7 +32,23 @@ from typing import cast
import structlog
from app.exceptions import FilterInvalidRegexError, JailNotFoundError
from app.exceptions import (
ActionAlreadyExistsError,
ActionNameError,
ActionNotFoundError,
ActionReadonlyError,
ConfigWriteError,
FilterAlreadyExistsError,
FilterInvalidRegexError,
FilterNameError,
FilterNotFoundError,
FilterReadonlyError,
JailAlreadyActiveError,
JailAlreadyInactiveError,
JailNameError,
JailNotFoundError,
JailNotFoundInConfigError,
)
from app.helpers.jail_helpers import reload_jails
from app.models.config import (
ActionConfig,
@@ -58,6 +73,7 @@ from app.models.config import (
RollbackResponse,
)
from app.utils import conffile_parser
from app.utils.async_utils import run_blocking
from app.utils.fail2ban_client import (
Fail2BanClient,
Fail2BanConnectionError,
@@ -120,90 +136,6 @@ _TRUE_VALUES: frozenset[str] = frozenset({"true", "yes", "1"})
_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."""
class FilterNameError(Exception):
"""Raised when a filter name contains invalid characters."""
class FilterAlreadyExistsError(Exception):
"""Raised when trying to create a filter whose ``.conf`` or ``.local`` already exists."""
def __init__(self, name: str) -> None:
"""Initialise with the filter name that already exists.
Args:
name: The filter name that already exists.
"""
self.name: str = name
super().__init__(f"Filter already exists: {name!r}")
class FilterReadonlyError(Exception):
"""Raised when trying to delete a shipped ``.conf`` filter with no ``.local`` override."""
def __init__(self, name: str) -> None:
"""Initialise with the filter name that cannot be deleted.
Args:
name: The filter name that is read-only (shipped ``.conf`` only).
"""
self.name: str = name
super().__init__(
f"Filter {name!r} is a shipped default (.conf only); only user-created .local files can be deleted."
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
@@ -1098,8 +1030,9 @@ async def list_inactive_jails(
: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 run_blocking(_parse_jails_sync, Path(config_dir)
parsed_result: tuple[dict[str, dict[str, str]], dict[str, str]] = await run_blocking(
_parse_jails_sync,
Path(config_dir),
)
all_jails, source_files = parsed_result
active_names: set[str] = await _get_active_jail_names(socket_path)
@@ -1155,7 +1088,6 @@ async def activate_jail(
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir))
if name not in all_jails:
@@ -1207,7 +1139,8 @@ async def activate_jail(
# we can restore it if activation fails. #
# ---------------------------------------------------------------------- #
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
original_content: bytes | None = await run_blocking(lambda: local_path.read_bytes() if local_path.exists() else None,
original_content: bytes | None = await run_blocking(
lambda: local_path.read_bytes() if local_path.exists() else None,
)
await run_blocking(_write_local_override_sync,
@@ -1351,7 +1284,6 @@ async def _rollback_activation_async(
``True`` if fail2ban is responsive again after the rollback, ``False``
if recovery also failed.
"""
loop = asyncio.get_event_loop()
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
# Step 1 — restore original file (or delete it).
@@ -1411,7 +1343,6 @@ async def deactivate_jail(
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir))
if name not in all_jails:
@@ -1467,7 +1398,6 @@ async def delete_jail_local_override(
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir))
if name not in all_jails:
@@ -1507,7 +1437,6 @@ async def validate_jail_config(
JailNameError: If *name* contains invalid characters.
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
return await run_blocking(_validate_jail_config_sync,
Path(config_dir),
name,
@@ -1542,7 +1471,6 @@ async def rollback_jail(
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
# Write enabled=false — this must succeed even when fail2ban is down.
await run_blocking(_write_local_override_sync,
@@ -1596,19 +1524,6 @@ async def rollback_jail(
_SAFE_FILTER_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
class FilterNotFoundError(Exception):
"""Raised when the requested filter name is not found in ``filter.d/``."""
def __init__(self, name: str) -> None:
"""Initialise with the filter name that was not found.
Args:
name: The filter name that could not be located.
"""
self.name: str = name
super().__init__(f"Filter not found: {name!r}")
def _extract_filter_base_name(filter_raw: str) -> str:
"""Extract the base filter name from a raw fail2ban filter string.
@@ -1778,7 +1693,6 @@ async def list_filters(
``used_by_jails`` lists.
"""
filter_d = Path(config_dir) / "filter.d"
loop = asyncio.get_event_loop()
# Run the synchronous scan in a thread-pool executor.
raw_filters: list[tuple[str, str, str, bool, str]] = await run_blocking( _parse_filters_sync, filter_d)
@@ -1855,7 +1769,6 @@ async def get_filter(
filter_d = Path(config_dir) / "filter.d"
conf_path = filter_d / f"{base_name}.conf"
local_path = filter_d / f"{base_name}.local"
loop = asyncio.get_event_loop()
def _read() -> tuple[str, bool, str]:
"""Read filter content and return (content, has_local_override, source_path)."""
@@ -1976,7 +1889,6 @@ async def update_filter(
content = conffile_parser.serialize_filter_config(merged)
filter_d = Path(config_dir) / "filter.d"
loop = asyncio.get_event_loop()
await run_blocking( _write_filter_local_sync, filter_d, base_name, content)
if do_reload:
@@ -2033,7 +1945,6 @@ async def create_filter(
if conf_path.is_file() or local_path.is_file():
raise FilterAlreadyExistsError(req.name)
loop = asyncio.get_event_loop()
await run_blocking( _check_not_exists)
# Validate regex patterns.
@@ -2099,7 +2010,6 @@ async def delete_filter(
conf_path = filter_d / f"{base_name}.conf"
local_path = filter_d / f"{base_name}.local"
loop = asyncio.get_event_loop()
def _delete() -> None:
has_conf = conf_path.is_file()
@@ -2154,7 +2064,6 @@ async def assign_filter_to_jail(
_safe_jail_name(jail_name)
_safe_filter_name(req.filter_name)
loop = asyncio.get_event_loop()
# Verify the jail exists in config.
all_jails, _src = await run_blocking( _parse_jails_sync, Path(config_dir))
@@ -2206,51 +2115,6 @@ async def assign_filter_to_jail(
_SAFE_ACTION_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
class ActionNotFoundError(Exception):
"""Raised when the requested action name is not found in ``action.d/``."""
def __init__(self, name: str) -> None:
"""Initialise with the action name that was not found.
Args:
name: The action name that could not be located.
"""
self.name: str = name
super().__init__(f"Action not found: {name!r}")
class ActionAlreadyExistsError(Exception):
"""Raised when trying to create an action whose ``.conf`` or ``.local`` already exists."""
def __init__(self, name: str) -> None:
"""Initialise with the action name that already exists.
Args:
name: The action name that already exists.
"""
self.name: str = name
super().__init__(f"Action already exists: {name!r}")
class ActionReadonlyError(Exception):
"""Raised when trying to delete a shipped ``.conf`` action with no ``.local`` override."""
def __init__(self, name: str) -> None:
"""Initialise with the action name that cannot be deleted.
Args:
name: The action name that is read-only (shipped ``.conf`` only).
"""
self.name: str = name
super().__init__(
f"Action {name!r} is a shipped default (.conf only); only user-created .local files can be deleted."
)
class ActionNameError(Exception):
"""Raised when an action name contains invalid characters."""
def _safe_action_name(name: str) -> str:
"""Validate *name* and return it unchanged or raise :class:`ActionNameError`.
@@ -2642,7 +2506,6 @@ async def list_actions(
``used_by_jails`` lists.
"""
action_d = Path(config_dir) / "action.d"
loop = asyncio.get_event_loop()
raw_actions: list[tuple[str, str, str, bool, str]] = await run_blocking( _parse_actions_sync, action_d)
@@ -2717,7 +2580,6 @@ async def get_action(
action_d = Path(config_dir) / "action.d"
conf_path = action_d / f"{base_name}.conf"
local_path = action_d / f"{base_name}.local"
loop = asyncio.get_event_loop()
def _read() -> tuple[str, bool, str]:
"""Read action content and return (content, has_local_override, source_path)."""
@@ -2828,7 +2690,6 @@ async def update_action(
content = conffile_parser.serialize_action_config(merged)
action_d = Path(config_dir) / "action.d"
loop = asyncio.get_event_loop()
await run_blocking( _write_action_local_sync, action_d, base_name, content)
if do_reload:
@@ -2881,7 +2742,6 @@ async def create_action(
if conf_path.is_file() or local_path.is_file():
raise ActionAlreadyExistsError(req.name)
loop = asyncio.get_event_loop()
await run_blocking( _check_not_exists)
cfg = ActionConfig(
@@ -2944,7 +2804,6 @@ async def delete_action(
conf_path = action_d / f"{base_name}.conf"
local_path = action_d / f"{base_name}.local"
loop = asyncio.get_event_loop()
def _delete() -> None:
has_conf = conf_path.is_file()
@@ -2999,7 +2858,6 @@ async def assign_action_to_jail(
_safe_jail_name(jail_name)
_safe_action_name(req.action_name)
loop = asyncio.get_event_loop()
all_jails, _src = await run_blocking( _parse_jails_sync, Path(config_dir))
if jail_name not in all_jails:
@@ -3077,7 +2935,6 @@ async def remove_action_from_jail(
_safe_jail_name(jail_name)
_safe_action_name(action_name)
loop = asyncio.get_event_loop()
all_jails, _src = await run_blocking( _parse_jails_sync, Path(config_dir))
if jail_name not in all_jails:

View File

@@ -18,7 +18,16 @@ from pathlib import Path
import structlog
from app.exceptions import ConfigWriteError, FilterInvalidRegexError, JailNotFoundInConfigError
from app.exceptions import (
ConfigWriteError,
FilterAlreadyExistsError,
FilterInvalidRegexError,
FilterNameError,
FilterNotFoundError,
FilterReadonlyError,
JailNameError,
JailNotFoundInConfigError,
)
from app.helpers.config_file_helpers import (
_get_active_jail_names,
_parse_jails_sync,
@@ -37,65 +46,11 @@ from app.utils import conffile_parser
log: structlog.stdlib.BoundLogger = structlog.get_logger()
# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------
class FilterNotFoundError(Exception):
"""Raised when the requested filter name is not found in ``filter.d/``."""
def __init__(self, name: str) -> None:
"""Initialise with the filter name that was not found.
Args:
name: The filter name that could not be located.
"""
self.name: str = name
super().__init__(f"Filter not found: {name!r}")
class FilterAlreadyExistsError(Exception):
"""Raised when trying to create a filter whose ``.conf`` or ``.local`` already exists."""
def __init__(self, name: str) -> None:
"""Initialise with the filter name that already exists.
Args:
name: The filter name that already exists.
"""
self.name: str = name
super().__init__(f"Filter already exists: {name!r}")
class FilterReadonlyError(Exception):
"""Raised when trying to delete a shipped ``.conf`` filter with no ``.local`` override."""
def __init__(self, name: str) -> None:
"""Initialise with the filter name that cannot be deleted.
Args:
name: The filter name that is read-only (shipped ``.conf`` only).
"""
self.name: str = name
super().__init__(
f"Filter {name!r} is a shipped default (.conf only); only user-created .local files can be deleted."
)
class FilterNameError(Exception):
"""Raised when a filter name contains invalid characters."""
# ---------------------------------------------------------------------------
# Additional helper functions for this service
# ---------------------------------------------------------------------------
class JailNameError(Exception):
"""Raised when a jail name contains invalid characters."""
_SAFE_FILTER_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_SAFE_JAIL_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")

View File

@@ -9,7 +9,6 @@ overrides in jail.d/*.local files.
from __future__ import annotations
import asyncio
from app.utils.async_utils import run_blocking
import configparser
import contextlib
import io
@@ -21,7 +20,14 @@ from typing import TYPE_CHECKING, cast
import structlog
from app.exceptions import JailNotFoundError
from app.exceptions import (
ConfigWriteError,
JailAlreadyActiveError,
JailAlreadyInactiveError,
JailNameError,
JailNotFoundError,
JailNotFoundInConfigError,
)
from app.helpers.config_file_helpers import (
_build_inactive_jail,
_get_active_jail_names,
@@ -38,6 +44,7 @@ from app.models.config import (
RollbackResponse,
)
from app.tasks.health_check import run_probe
from app.utils.async_utils import run_blocking
from app.utils.fail2ban_client import Fail2BanClient
from app.utils.runtime_state import (
clear_activation_record,
@@ -76,58 +83,6 @@ _POST_RELOAD_PROBE_INTERVAL: float = 2.0
_POST_RELOAD_MAX_ATTEMPTS: int = 4
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
@@ -502,8 +457,9 @@ async def list_inactive_jails(
: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 run_blocking(_parse_jails_sync, Path(config_dir)
parsed_result: tuple[dict[str, dict[str, str]], dict[str, str]] = await run_blocking(
_parse_jails_sync,
Path(config_dir),
)
all_jails, source_files = parsed_result
active_names: set[str] = await _get_active_jail_names(socket_path)
@@ -587,7 +543,6 @@ async def _activate_jail(
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir))
if name not in all_jails:
@@ -639,7 +594,8 @@ async def _activate_jail(
# we can restore it if activation fails. #
# ---------------------------------------------------------------------- #
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
original_content: bytes | None = await run_blocking(lambda: local_path.read_bytes() if local_path.exists() else None,
original_content: bytes | None = await run_blocking(
lambda: local_path.read_bytes() if local_path.exists() else None,
)
await run_blocking(_write_local_override_sync,
@@ -783,7 +739,6 @@ async def _rollback_activation_async(
``True`` if fail2ban is responsive again after the rollback, ``False``
if recovery also failed.
"""
loop = asyncio.get_event_loop()
local_path = Path(config_dir) / "jail.d" / f"{name}.local"
# Step 1 — restore original file (or delete it).
@@ -860,7 +815,6 @@ async def _deactivate_jail(
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir))
if name not in all_jails:
@@ -916,7 +870,6 @@ async def delete_jail_local_override(
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
all_jails, _source_files = await run_blocking( _parse_jails_sync, Path(config_dir))
if name not in all_jails:
@@ -956,7 +909,6 @@ async def validate_jail_config(
JailNameError: If *name* contains invalid characters.
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
return await run_blocking(_validate_jail_config_sync,
Path(config_dir),
name,
@@ -1008,7 +960,6 @@ async def _rollback_jail(
"""
_safe_jail_name(name)
loop = asyncio.get_event_loop()
# Write enabled=false — this must succeed even when fail2ban is down.
await run_blocking(_write_local_override_sync,