diff --git a/Docs/Architekture.md b/Docs/Architekture.md index 16c766a..829f9fb 100644 --- a/Docs/Architekture.md +++ b/Docs/Architekture.md @@ -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 diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 4294927..4a84edf 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -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:** diff --git a/backend/app/exceptions.py b/backend/app/exceptions.py index 728019c..8bdaa4f 100644 --- a/backend/app/exceptions.py +++ b/backend/app/exceptions.py @@ -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." + ) diff --git a/backend/app/routers/action_config.py b/backend/app/routers/action_config.py index 41f9349..63e411a 100644 --- a/backend/app/routers/action_config.py +++ b/backend/app/routers/action_config.py @@ -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, diff --git a/backend/app/routers/filter_config.py b/backend/app/routers/filter_config.py index 6da4804..4acecde 100644 --- a/backend/app/routers/filter_config.py +++ b/backend/app/routers/filter_config.py @@ -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, diff --git a/backend/app/routers/jail_config.py b/backend/app/routers/jail_config.py index 81cb03b..c18abcc 100644 --- a/backend/app/routers/jail_config.py +++ b/backend/app/routers/jail_config.py @@ -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() diff --git a/backend/app/services/action_config_service.py b/backend/app/services/action_config_service.py index ef9b9c4..3711e00 100644 --- a/backend/app/services/action_config_service.py +++ b/backend/app/services/action_config_service.py @@ -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: diff --git a/backend/app/services/config_file_service.py b/backend/app/services/config_file_service.py index eacf4a2..ddbf11f 100644 --- a/backend/app/services/config_file_service.py +++ b/backend/app/services/config_file_service.py @@ -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: diff --git a/backend/app/services/filter_config_service.py b/backend/app/services/filter_config_service.py index 157159d..143516e 100644 --- a/backend/app/services/filter_config_service.py +++ b/backend/app/services/filter_config_service.py @@ -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}$") diff --git a/backend/app/services/jail_config_service.py b/backend/app/services/jail_config_service.py index 3380b43..bae61e4 100644 --- a/backend/app/services/jail_config_service.py +++ b/backend/app/services/jail_config_service.py @@ -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,