refactor: Remove duplicate router-level exception helpers

All routers now let domain exceptions propagate to the global handlers in main.py
instead of catching and converting them to HTTPException. This eliminates:

- Duplicate exception-to-HTTP-status mappings across 8 routers
- Duplicate helper functions (_bad_gateway, _not_found, _conflict, etc.)
- Inconsistent error response formats

Changes:
- Removed all try/except blocks from routers that catch domain exceptions
- Removed duplicate helper functions from all routers
- Added missing exception handlers to main.py for:
  * ActionNameError
  * FilterNameError
  * JailNameError
  * JailNotFoundInConfigError
  * FilterInvalidRegexError
- Removed unused imports from affected routers

All domain exceptions now propagate to the single authoritative mapping in
main.py, ensuring consistent error codes, messages, and logging across the API.

Affected routers:
- action_config.py: Removed _action_not_found, _bad_request, _not_found helpers
- bans.py: Removed try/except in ban/unban endpoints
- config_misc.py: Removed try/except blocks
- file_config.py: Removed 6 try/except blocks and _service_unavailable helper
- filter_config.py: Removed try/except blocks
- geo.py: Removed try/except in lookup_ip endpoint
- jail_config.py: Removed try/except blocks
- jails.py: Removed try/except blocks
- server.py: Removed try/except blocks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-23 16:00:37 +02:00
parent b634ce876a
commit 5480dce221
12 changed files with 229 additions and 977 deletions

View File

@@ -251,6 +251,33 @@ async def jail_not_found_handler(request: Request, exc: JailNotFoundError) -> JS
return JSONResponse(status_code=404, content={"detail": f"Jail '{exc.name}' not found"}) return JSONResponse(status_code=404, content={"detail": f"Jail '{exc.name}' not found"})
``` ```
### Routers and Exception Propagation
- **Routers must NOT construct `HTTPException` for domain errors** — let domain exceptions propagate.
- Routers should never have helper functions like `_bad_gateway()`, `_not_found()`, `_conflict()` etc. that convert domain exceptions to `HTTPException`.
- All domain exception types must have corresponding handlers registered in `main.py` via `app.add_exception_handler()`.
- Exception handlers are registered in order from most specific to least specific — FastAPI evaluates them in registration order.
```python
# ❌ BAD — routers constructing HTTPException for domain exceptions
@router.get("/{name}")
async def get_jail(name: str, socket_path: Fail2BanSocketDep) -> JailDetailResponse:
try:
return await jail_service.get_jail(socket_path, name)
except JailNotFoundError:
raise HTTPException(status_code=404, detail=f"Jail not found: {name!r}") from None
# ✅ GOOD — domain exception propagates to global handler
@router.get("/{name}")
async def get_jail(name: str, socket_path: Fail2BanSocketDep) -> JailDetailResponse:
return await jail_service.get_jail(socket_path, name)
```
All domain exceptions raised by services propagate to handlers in `main.py`, ensuring:
1. Consistent error response format across the entire API.
2. No duplicated exception-to-HTTP-status mapping logic.
3. Easy to audit all error codes — they are all in one place.
--- ---
## 9. Testing ## 9. Testing

View File

@@ -1,27 +1,3 @@
### T-01 · Extract `_ok()` / `_to_dict()` into shared util module
**Where found:** `backend/app/services/ban_service.py`, `jail_service.py`, `config_service.py`, `health_service.py`, `server_service.py`, `log_service.py`, `utils/config_file_utils.py`
**Why this is needed:** The same two helper functions are copy-pasted across 67 service modules. `config_service.py` even has a comment admitting it: *"mirrored from jail_service for isolation"*. Any bug fix or behavioural change requires touching every copy independently.
**Goal:** Single authoritative implementation. All services import from `app/utils/fail2ban_response.py`.
**What to do:**
1. Create `backend/app/utils/fail2ban_response.py` with `ok()`, `to_dict()`, `ensure_list()`, `is_not_found_error()`.
2. Delete local definitions in all service files.
3. Import from `fail2ban_response` in each service.
**Possible traps and issues:**
- `config_file_utils.py` defines `_to_dict_inner` as a nested function inside another function — needs to be unwrapped.
- `ban_service._ok` uses `response` typed as `object`; `server_service._ok` types it as `Fail2BanResponse`. Unify the signature.
- Run all service tests after the change to confirm no subtle type differences.
**Docs changes needed:** Update `Docs/Backend-Development.md` to document `fail2ban_response.py` as the canonical response-parsing utility.
**Doc references:** `Docs/Backend-Development.md`, `Docs/Architekture.md`
---
### T-02 · Remove duplicate router-level exception helpers — use global handlers only ### T-02 · Remove duplicate router-level exception helpers — use global handlers only
**Where found:** `backend/app/routers/jails.py`, `bans.py`, `jail_config.py`, `server.py`, `config_misc.py` each define `_bad_gateway()`, `_not_found()`, `_conflict()`. Global handlers for the same exceptions exist in `backend/app/main.py`. **Where found:** `backend/app/routers/jails.py`, `bans.py`, `jail_config.py`, `server.py`, `config_misc.py` each define `_bad_gateway()`, `_not_found()`, `_conflict()`. Global handlers for the same exceptions exist in `backend/app/main.py`.

View File

@@ -31,23 +31,31 @@ from app import __version__
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.exceptions import ( from app.exceptions import (
ActionAlreadyExistsError, ActionAlreadyExistsError,
ActionNameError,
ActionNotFoundError, ActionNotFoundError,
ActionReadonlyError, ActionReadonlyError,
ConfigDirError,
ConfigFileExistsError, ConfigFileExistsError,
ConfigFileNameError, ConfigFileNameError,
ConfigFileNotFoundError, ConfigFileNotFoundError,
ConfigFileWriteError,
ConfigOperationError, ConfigOperationError,
ConfigValidationError, ConfigValidationError,
ConfigWriteError, ConfigWriteError,
Fail2BanConnectionError, Fail2BanConnectionError,
Fail2BanProtocolError, Fail2BanProtocolError,
FilterAlreadyExistsError, FilterAlreadyExistsError,
FilterInvalidRegexError,
FilterNameError,
FilterNotFoundError, FilterNotFoundError,
FilterReadonlyError, FilterReadonlyError,
JailAlreadyActiveError, JailAlreadyActiveError,
JailAlreadyInactiveError, JailAlreadyInactiveError,
JailNameError,
JailNotFoundError, JailNotFoundError,
JailNotFoundInConfigError,
JailOperationError, JailOperationError,
ServerOperationError,
) )
from app.routers import ( from app.routers import (
auth, auth,
@@ -308,6 +316,56 @@ async def _domain_error_handler(
) )
async def _value_error_handler(
request: Request,
exc: ValueError,
) -> JSONResponse:
"""Return a ``400 Bad Request`` response for validation and value errors.
Args:
request: The incoming FastAPI request.
exc: The :class:`ValueError`.
Returns:
A :class:`fastapi.responses.JSONResponse` with status 400.
"""
log.warning(
"value_error",
path=request.url.path,
method=request.method,
error=str(exc),
)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": str(exc)},
)
async def _service_unavailable_handler(
request: Request,
exc: Exception,
) -> JSONResponse:
"""Return a ``503 Service Unavailable`` response for infrastructure errors.
Args:
request: The incoming FastAPI request.
exc: The infrastructure exception (e.g., ConfigDirError).
Returns:
A :class:`fastapi.responses.JSONResponse` with status 503.
"""
log.warning(
"service_unavailable",
path=request.url.path,
method=request.method,
error=str(exc),
)
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={"detail": str(exc)},
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Setup-redirect middleware # Setup-redirect middleware
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -426,12 +484,19 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.add_exception_handler(Fail2BanConnectionError, _fail2ban_connection_handler) # type: ignore[arg-type] app.add_exception_handler(Fail2BanConnectionError, _fail2ban_connection_handler) # type: ignore[arg-type]
app.add_exception_handler(Fail2BanProtocolError, _fail2ban_protocol_handler) # type: ignore[arg-type] app.add_exception_handler(Fail2BanProtocolError, _fail2ban_protocol_handler) # type: ignore[arg-type]
app.add_exception_handler(JailNotFoundError, _not_found_handler) # type: ignore[arg-type] app.add_exception_handler(JailNotFoundError, _not_found_handler) # type: ignore[arg-type]
app.add_exception_handler(JailNotFoundInConfigError, _not_found_handler) # type: ignore[arg-type]
app.add_exception_handler(FilterNotFoundError, _not_found_handler) # type: ignore[arg-type] app.add_exception_handler(FilterNotFoundError, _not_found_handler) # type: ignore[arg-type]
app.add_exception_handler(ActionNotFoundError, _not_found_handler) # type: ignore[arg-type] app.add_exception_handler(ActionNotFoundError, _not_found_handler) # type: ignore[arg-type]
app.add_exception_handler(ConfigFileNotFoundError, _not_found_handler) # type: ignore[arg-type] app.add_exception_handler(ConfigFileNotFoundError, _not_found_handler) # type: ignore[arg-type]
app.add_exception_handler(ConfigValidationError, _bad_request_handler) # type: ignore[arg-type] app.add_exception_handler(ConfigValidationError, _bad_request_handler) # type: ignore[arg-type]
app.add_exception_handler(ConfigFileNameError, _bad_request_handler) # type: ignore[arg-type] app.add_exception_handler(ConfigFileNameError, _bad_request_handler) # type: ignore[arg-type]
app.add_exception_handler(ConfigOperationError, _bad_request_handler) # type: ignore[arg-type] app.add_exception_handler(ConfigOperationError, _bad_request_handler) # type: ignore[arg-type]
app.add_exception_handler(ServerOperationError, _bad_request_handler) # type: ignore[arg-type]
app.add_exception_handler(ActionNameError, _bad_request_handler) # type: ignore[arg-type]
app.add_exception_handler(FilterNameError, _bad_request_handler) # type: ignore[arg-type]
app.add_exception_handler(JailNameError, _bad_request_handler) # type: ignore[arg-type]
app.add_exception_handler(FilterInvalidRegexError, _bad_request_handler) # type: ignore[arg-type]
app.add_exception_handler(ValueError, _value_error_handler) # type: ignore[arg-type]
app.add_exception_handler(JailOperationError, _conflict_handler) # type: ignore[arg-type] app.add_exception_handler(JailOperationError, _conflict_handler) # type: ignore[arg-type]
app.add_exception_handler(JailAlreadyActiveError, _conflict_handler) # type: ignore[arg-type] app.add_exception_handler(JailAlreadyActiveError, _conflict_handler) # type: ignore[arg-type]
app.add_exception_handler(JailAlreadyInactiveError, _conflict_handler) # type: ignore[arg-type] app.add_exception_handler(JailAlreadyInactiveError, _conflict_handler) # type: ignore[arg-type]
@@ -441,6 +506,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.add_exception_handler(ActionReadonlyError, _conflict_handler) # type: ignore[arg-type] app.add_exception_handler(ActionReadonlyError, _conflict_handler) # type: ignore[arg-type]
app.add_exception_handler(ConfigFileExistsError, _conflict_handler) # type: ignore[arg-type] app.add_exception_handler(ConfigFileExistsError, _conflict_handler) # type: ignore[arg-type]
app.add_exception_handler(ConfigWriteError, _domain_error_handler) # type: ignore[arg-type] app.add_exception_handler(ConfigWriteError, _domain_error_handler) # type: ignore[arg-type]
app.add_exception_handler(ConfigDirError, _service_unavailable_handler) # type: ignore[arg-type]
app.add_exception_handler(ConfigFileWriteError, _bad_request_handler) # type: ignore[arg-type]
app.add_exception_handler(Exception, _unhandled_exception_handler) app.add_exception_handler(Exception, _unhandled_exception_handler)
# --- Routers --- # --- Routers ---

View File

@@ -32,24 +32,6 @@ _NamePath = Annotated[
Path(description='Jail name as configured in fail2ban.'), Path(description='Jail name as configured in fail2ban.'),
] ]
def _action_not_found(name: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
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( @router.get(
"", "",
response_model=ActionListResponse, response_model=ActionListResponse,
@@ -116,10 +98,7 @@ async def get_action(
Raises: Raises:
HTTPException: 404 if the action is not found in ``action.d/``. HTTPException: 404 if the action is not found in ``action.d/``.
""" """
try: return await action_config_service.get_action(config_dir, socket_path, name)
return await action_config_service.get_action(config_dir, socket_path, name)
except ActionNotFoundError:
raise _action_not_found(name) from None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -163,17 +142,7 @@ async def update_action(
HTTPException: 404 if the action does not exist. HTTPException: 404 if the action does not exist.
HTTPException: 500 if writing the ``.local`` file fails. HTTPException: 500 if writing the ``.local`` file fails.
""" """
try: return await action_config_service.update_action(config_dir, socket_path, name, body, do_reload=reload)
return await action_config_service.update_action(config_dir, socket_path, name, body, do_reload=reload)
except ActionNameError as exc:
raise _bad_request(str(exc)) from exc
except ActionNotFoundError:
raise _action_not_found(name) from None
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write action override: {exc}",
) from exc
@@ -211,20 +180,7 @@ async def create_action(
HTTPException: 409 if the action already exists. HTTPException: 409 if the action already exists.
HTTPException: 500 if writing fails. HTTPException: 500 if writing fails.
""" """
try: return await action_config_service.create_action(config_dir, socket_path, body, do_reload=reload)
return await action_config_service.create_action(config_dir, socket_path, body, do_reload=reload)
except ActionNameError as exc:
raise _bad_request(str(exc)) from exc
except ActionAlreadyExistsError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Action {exc.name!r} already exists.",
) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write action: {exc}",
) from exc
@@ -256,22 +212,7 @@ async def delete_action(
HTTPException: 409 if the action is a shipped default (conf-only). HTTPException: 409 if the action is a shipped default (conf-only).
HTTPException: 500 if deletion fails. HTTPException: 500 if deletion fails.
""" """
try: await action_config_service.delete_action(config_dir, name)
await action_config_service.delete_action(config_dir, name)
except ActionNameError as exc:
raise _bad_request(str(exc)) from exc
except ActionNotFoundError:
raise _action_not_found(name) from None
except ActionReadonlyError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete action: {exc}",
) from exc

View File

@@ -28,21 +28,6 @@ from app.exceptions import Fail2BanConnectionError
router: APIRouter = APIRouter(prefix="/api/bans", tags=["Bans"]) router: APIRouter = APIRouter(prefix="/api/bans", tags=["Bans"])
def _bad_gateway(exc: Exception) -> HTTPException:
"""Return a 502 response when fail2ban is unreachable.
Args:
exc: The underlying connection error.
Returns:
:class:`fastapi.HTTPException` with status 502.
"""
return HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Cannot reach fail2ban: {exc}",
)
@router.get( @router.get(
"/active", "/active",
response_model=ActiveBanListResponse, response_model=ActiveBanListResponse,
@@ -71,15 +56,12 @@ async def get_active_bans(
Raises: Raises:
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: return await ban_service.get_active_bans(
return await ban_service.get_active_bans( socket_path,
socket_path, geo_batch_lookup=geo_batch_lookup,
geo_batch_lookup=geo_batch_lookup, http_session=http_session,
http_session=http_session, app_db=db,
app_db=db, )
)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.post( @router.post(
@@ -113,29 +95,11 @@ async def ban_ip(
HTTPException: 409 when fail2ban reports the ban failed. HTTPException: 409 when fail2ban reports the ban failed.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: await ban_service.ban_ip(socket_path, body.jail, body.ip)
await ban_service.ban_ip(socket_path, body.jail, body.ip) return JailCommandResponse(
return JailCommandResponse( message=f"IP {body.ip!r} banned in jail {body.jail!r}.",
message=f"IP {body.ip!r} banned in jail {body.jail!r}.", jail=body.jail,
jail=body.jail, )
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
except JailNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Jail not found: {body.jail!r}",
) from None
except JailOperationError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.delete( @router.delete(
@@ -173,30 +137,12 @@ async def unban_ip(
# Determine target jail (None means all jails). # Determine target jail (None means all jails).
target_jail: str | None = None if (body.unban_all or body.jail is None) else body.jail target_jail: str | None = None if (body.unban_all or body.jail is None) else body.jail
try: await ban_service.unban_ip(socket_path, body.ip, jail=target_jail)
await ban_service.unban_ip(socket_path, body.ip, jail=target_jail) scope = f"jail {target_jail!r}" if target_jail else "all jails"
scope = f"jail {target_jail!r}" if target_jail else "all jails" return JailCommandResponse(
return JailCommandResponse( message=f"IP {body.ip!r} unbanned from {scope}.",
message=f"IP {body.ip!r} unbanned from {scope}.", jail=target_jail or "*",
jail=target_jail or "*", )
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
except JailNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Jail not found: {target_jail!r}",
) from None
except JailOperationError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.delete( @router.delete(
@@ -225,11 +171,8 @@ async def unban_all(
Raises: Raises:
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: count: int = await jail_service.unban_all_ips(socket_path)
count: int = await jail_service.unban_all_ips(socket_path) return UnbanAllResponse(
return UnbanAllResponse( message=f"All bans cleared. {count} IP address{'es' if count != 1 else ''} unbanned.",
message=f"All bans cleared. {count} IP address{'es' if count != 1 else ''} unbanned.", count=count,
count=count, )
)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc

View File

@@ -39,20 +39,6 @@ log: structlog.stdlib.BoundLogger = structlog.get_logger()
router: APIRouter = APIRouter(tags=["Config Misc"]) router: APIRouter = APIRouter(tags=["Config Misc"])
def _bad_gateway(exc: Exception) -> HTTPException:
return HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Cannot reach fail2ban: {exc}",
)
def _bad_request(message: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message,
)
@router.get( @router.get(
"/global", "/global",
response_model=GlobalConfigResponse, response_model=GlobalConfigResponse,
@@ -77,10 +63,7 @@ async def get_global_config(
Raises: Raises:
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: return await config_service.get_global_config(socket_path)
return await config_service.get_global_config(socket_path)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.put( @router.put(
@@ -105,12 +88,7 @@ async def update_global_config(
HTTPException: 400 when a set command is rejected. HTTPException: 400 when a set command is rejected.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: await config_service.update_global_config(socket_path, body)
await config_service.update_global_config(socket_path, body)
except ConfigOperationError as exc:
raise _bad_request(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -139,15 +117,7 @@ async def reload_fail2ban(
HTTPException: 409 when fail2ban reports the reload failed. HTTPException: 409 when fail2ban reports the reload failed.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: await jail_service.reload_all(socket_path)
await jail_service.reload_all(socket_path)
except JailOperationError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"fail2ban reload failed: {exc}",
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# Restart endpoint # Restart endpoint
@@ -186,18 +156,10 @@ async def restart_fail2ban(
""" """
start_cmd_parts: list[str] = start_cmd.split() start_cmd_parts: list[str] = start_cmd.split()
try: restarted = await jail_service.restart_daemon(
restarted = await jail_service.restart_daemon( socket_path,
socket_path, start_cmd_parts,
start_cmd_parts, )
)
except JailOperationError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"fail2ban stop command failed: {exc}",
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
if not restarted: if not restarted:
raise HTTPException( raise HTTPException(
@@ -323,11 +285,7 @@ async def update_map_color_thresholds(
HTTPException: 400 if validation fails (thresholds not HTTPException: 400 if validation fails (thresholds not
properly ordered). properly ordered).
""" """
try: await config_service.update_map_color_thresholds(db, body)
await config_service.update_map_color_thresholds(db, body)
except ValueError as exc:
raise _bad_request(str(exc)) from exc
return await config_service.get_map_color_thresholds(db) return await config_service.get_map_color_thresholds(db)
@@ -379,12 +337,7 @@ async def get_fail2ban_log(
the allowed directory. the allowed directory.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: return await log_service.read_fail2ban_log(socket_path, lines, filter_)
return await log_service.read_fail2ban_log(socket_path, lines, filter_)
except ConfigOperationError as exc:
raise _bad_request(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.get( @router.get(
@@ -415,10 +368,7 @@ async def get_service_status(
""" """
from app.services import health_service from app.services import health_service
try: return await health_service.get_service_status(
return await health_service.get_service_status( socket_path,
socket_path, probe_fn=health_service.probe,
probe_fn=health_service.probe, )
)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc

View File

@@ -31,7 +31,7 @@ from __future__ import annotations
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, HTTPException, Path, status from fastapi import APIRouter, Path, status
from app.dependencies import AuthDep, Fail2BanConfigDirDep from app.dependencies import AuthDep, Fail2BanConfigDirDep
from app.models.config import ( from app.models.config import (
@@ -52,13 +52,6 @@ from app.models.file_config import (
JailConfigFilesResponse, JailConfigFilesResponse,
) )
from app.services import raw_config_io_service from app.services import raw_config_io_service
from app.exceptions import (
ConfigDirError,
ConfigFileExistsError,
ConfigFileNameError,
ConfigFileNotFoundError,
ConfigFileWriteError,
)
router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"]) router: APIRouter = APIRouter(prefix="/api/config", tags=["Config"])
@@ -73,39 +66,6 @@ _NamePath = Annotated[
str, Path(description="Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).") str, Path(description="Base name with or without extension (e.g. ``sshd`` or ``sshd.conf``).")
] ]
# ---------------------------------------------------------------------------
# Error helpers
# ---------------------------------------------------------------------------
def _not_found(filename: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Config file not found: {filename!r}",
)
def _bad_request(message: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message,
)
def _conflict(filename: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Config file already exists: {filename!r}",
)
def _service_unavailable(message: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=message,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Jail config file endpoints (Task 4a) # Jail config file endpoints (Task 4a)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -132,12 +92,7 @@ async def list_jail_config_files(
Returns: Returns:
:class:`~app.models.file_config.JailConfigFilesResponse`. :class:`~app.models.file_config.JailConfigFilesResponse`.
""" """
try: return await raw_config_io_service.list_jail_config_files(config_dir)
return await raw_config_io_service.list_jail_config_files(config_dir)
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.get( @router.get(
"/jail-files/{filename}", "/jail-files/{filename}",
response_model=JailConfigFileContent, response_model=JailConfigFileContent,
@@ -163,16 +118,7 @@ async def get_jail_config_file(
HTTPException: 404 if the file does not exist. HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable. HTTPException: 503 if the config directory is unavailable.
""" """
try: return await raw_config_io_service.get_jail_config_file(config_dir, filename)
return await raw_config_io_service.get_jail_config_file(config_dir, filename)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(filename) from None
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.put( @router.put(
"/jail-files/{filename}", "/jail-files/{filename}",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
@@ -200,18 +146,7 @@ async def write_jail_config_file(
HTTPException: 404 if the file does not exist. HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable. HTTPException: 503 if the config directory is unavailable.
""" """
try: await raw_config_io_service.write_jail_config_file(config_dir, filename, body)
await raw_config_io_service.write_jail_config_file(config_dir, filename, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(filename) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.put( @router.put(
"/jail-files/{filename}/enabled", "/jail-files/{filename}/enabled",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
@@ -239,20 +174,9 @@ async def set_jail_config_file_enabled(
HTTPException: 404 if the file does not exist. HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable. HTTPException: 503 if the config directory is unavailable.
""" """
try: await raw_config_io_service.set_jail_config_enabled(
await raw_config_io_service.set_jail_config_enabled( config_dir, filename, body.enabled
config_dir, filename, body.enabled )
)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(filename) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.post( @router.post(
"/jail-files", "/jail-files",
response_model=ConfFileContent, response_model=ConfFileContent,
@@ -279,17 +203,7 @@ async def create_jail_config_file(
HTTPException: 409 if a file with that name already exists. HTTPException: 409 if a file with that name already exists.
HTTPException: 503 if the config directory is unavailable. HTTPException: 503 if the config directory is unavailable.
""" """
try: filename = await raw_config_io_service.create_jail_config_file(config_dir, body)
filename = await raw_config_io_service.create_jail_config_file(config_dir, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileExistsError:
raise _conflict(body.name) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
return ConfFileContent( return ConfFileContent(
name=body.name, name=body.name,
filename=filename, filename=filename,
@@ -331,16 +245,7 @@ async def get_filter_file_raw(
HTTPException: 404 if the file does not exist. HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable. HTTPException: 503 if the config directory is unavailable.
""" """
try: return await raw_config_io_service.get_filter_file(config_dir, name)
return await raw_config_io_service.get_filter_file(config_dir, name)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(name) from None
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.put( @router.put(
"/filters/{name}/raw", "/filters/{name}/raw",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
@@ -365,18 +270,7 @@ async def write_filter_file(
HTTPException: 404 if the file does not exist. HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable. HTTPException: 503 if the config directory is unavailable.
""" """
try: await raw_config_io_service.write_filter_file(config_dir, name, body)
await raw_config_io_service.write_filter_file(config_dir, name, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(name) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.post( @router.post(
"/filters/raw", "/filters/raw",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
@@ -403,17 +297,7 @@ async def create_filter_file(
HTTPException: 409 if a file with that name already exists. HTTPException: 409 if a file with that name already exists.
HTTPException: 503 if the config directory is unavailable. HTTPException: 503 if the config directory is unavailable.
""" """
try: filename = await raw_config_io_service.create_filter_file(config_dir, body)
filename = await raw_config_io_service.create_filter_file(config_dir, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileExistsError:
raise _conflict(body.name) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
return ConfFileContent( return ConfFileContent(
name=body.name, name=body.name,
filename=filename, filename=filename,
@@ -444,12 +328,7 @@ async def list_action_files(
Returns: Returns:
:class:`~app.models.file_config.ConfFilesResponse`. :class:`~app.models.file_config.ConfFilesResponse`.
""" """
try: return await raw_config_io_service.list_action_files(config_dir)
return await raw_config_io_service.list_action_files(config_dir)
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.get( @router.get(
"/actions/{name}/raw", "/actions/{name}/raw",
response_model=ConfFileContent, response_model=ConfFileContent,
@@ -475,16 +354,7 @@ async def get_action_file(
HTTPException: 404 if the file does not exist. HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable. HTTPException: 503 if the config directory is unavailable.
""" """
try: return await raw_config_io_service.get_action_file(config_dir, name)
return await raw_config_io_service.get_action_file(config_dir, name)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(name) from None
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.put( @router.put(
"/actions/{name}/raw", "/actions/{name}/raw",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
@@ -509,18 +379,7 @@ async def write_action_file(
HTTPException: 404 if the file does not exist. HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable. HTTPException: 503 if the config directory is unavailable.
""" """
try: await raw_config_io_service.write_action_file(config_dir, name, body)
await raw_config_io_service.write_action_file(config_dir, name, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(name) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.post( @router.post(
"/actions", "/actions",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
@@ -547,17 +406,7 @@ async def create_action_file(
HTTPException: 409 if a file with that name already exists. HTTPException: 409 if a file with that name already exists.
HTTPException: 503 if the config directory is unavailable. HTTPException: 503 if the config directory is unavailable.
""" """
try: filename = await raw_config_io_service.create_action_file(config_dir, body)
filename = await raw_config_io_service.create_action_file(config_dir, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileExistsError:
raise _conflict(body.name) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
return ConfFileContent( return ConfFileContent(
name=body.name, name=body.name,
filename=filename, filename=filename,
@@ -599,16 +448,7 @@ async def get_parsed_filter(
HTTPException: 404 if the file does not exist. HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable. HTTPException: 503 if the config directory is unavailable.
""" """
try: return await raw_config_io_service.get_parsed_filter_file(config_dir, name)
return await raw_config_io_service.get_parsed_filter_file(config_dir, name)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(name) from None
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.put( @router.put(
"/filters/{name}/parsed", "/filters/{name}/parsed",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
@@ -636,18 +476,7 @@ async def update_parsed_filter(
HTTPException: 404 if the file does not exist. HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable. HTTPException: 503 if the config directory is unavailable.
""" """
try: await raw_config_io_service.update_parsed_filter_file(config_dir, name, body)
await raw_config_io_service.update_parsed_filter_file(config_dir, name, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(name) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Parsed action endpoints (Task 3.1) # Parsed action endpoints (Task 3.1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -682,16 +511,7 @@ async def get_parsed_action(
HTTPException: 404 if the file does not exist. HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable. HTTPException: 503 if the config directory is unavailable.
""" """
try: return await raw_config_io_service.get_parsed_action_file(config_dir, name)
return await raw_config_io_service.get_parsed_action_file(config_dir, name)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(name) from None
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.put( @router.put(
"/actions/{name}/parsed", "/actions/{name}/parsed",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
@@ -719,18 +539,7 @@ async def update_parsed_action(
HTTPException: 404 if the file does not exist. HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable. HTTPException: 503 if the config directory is unavailable.
""" """
try: await raw_config_io_service.update_parsed_action_file(config_dir, name, body)
await raw_config_io_service.update_parsed_action_file(config_dir, name, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(name) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Parsed jail file endpoints (Task 6.1) # Parsed jail file endpoints (Task 6.1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -765,16 +574,7 @@ async def get_parsed_jail_file(
HTTPException: 404 if the file does not exist. HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable. HTTPException: 503 if the config directory is unavailable.
""" """
try: return await raw_config_io_service.get_parsed_jail_file(config_dir, filename)
return await raw_config_io_service.get_parsed_jail_file(config_dir, filename)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(filename) from None
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc
@router.put( @router.put(
"/jail-files/{filename}/parsed", "/jail-files/{filename}/parsed",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,
@@ -802,13 +602,4 @@ async def update_parsed_jail_file(
HTTPException: 404 if the file does not exist. HTTPException: 404 if the file does not exist.
HTTPException: 503 if the config directory is unavailable. HTTPException: 503 if the config directory is unavailable.
""" """
try: await raw_config_io_service.update_parsed_jail_file(config_dir, filename, body)
await raw_config_io_service.update_parsed_jail_file(config_dir, filename, body)
except ConfigFileNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigFileNotFoundError:
raise _not_found(filename) from None
except ConfigFileWriteError as exc:
raise _bad_request(str(exc)) from exc
except ConfigDirError as exc:
raise _service_unavailable(str(exc)) from exc

View File

@@ -23,38 +23,11 @@ from app.services import filter_config_service
router: APIRouter = APIRouter(prefix="/filters", tags=["Filter Config"]) router: APIRouter = APIRouter(prefix="/filters", tags=["Filter Config"])
_NamePath = Annotated[
str,
Path(description='Jail name as configured in fail2ban.'),
]
_FilterNamePath = Annotated[ _FilterNamePath = Annotated[
str, str,
Path(description='Filter base name, e.g. ``sshd`` or ``sshd.conf``.'), Path(description='Filter base name, e.g. ``sshd`` or ``sshd.conf``.'),
] ]
def _filter_not_found(name: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
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( @router.get(
"", "",
response_model=FilterListResponse, response_model=FilterListResponse,
@@ -123,13 +96,7 @@ async def get_filter(
HTTPException: 404 if the filter is not found in ``filter.d/``. HTTPException: 404 if the filter is not found in ``filter.d/``.
HTTPException: 502 if fail2ban is unreachable. HTTPException: 502 if fail2ban is unreachable.
""" """
try: return await filter_config_service.get_filter(config_dir, socket_path, name)
return await filter_config_service.get_filter(config_dir, socket_path, name)
except FilterNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Filter not found: {name!r}",
) from None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -143,15 +110,6 @@ _FilterNamePath = Annotated[
] ]
def _filter_not_found(name: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Filter not found: {name!r}",
)
@router.put( @router.put(
"/{name}", "/{name}",
response_model=FilterConfig, response_model=FilterConfig,
@@ -189,19 +147,7 @@ async def update_filter(
HTTPException: 422 if any regex pattern fails to compile. HTTPException: 422 if any regex pattern fails to compile.
HTTPException: 500 if writing the ``.local`` file fails. HTTPException: 500 if writing the ``.local`` file fails.
""" """
try: return await filter_config_service.update_filter(config_dir, socket_path, name, body, do_reload=reload)
return await filter_config_service.update_filter(config_dir, socket_path, name, body, do_reload=reload)
except FilterNameError as exc:
raise _bad_request(str(exc)) from exc
except FilterNotFoundError:
raise _filter_not_found(name) from None
except FilterInvalidRegexError as exc:
raise _unprocessable(str(exc)) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write filter override: {exc}",
) from exc
@@ -241,22 +187,7 @@ async def create_filter(
HTTPException: 422 if any regex pattern is invalid. HTTPException: 422 if any regex pattern is invalid.
HTTPException: 500 if writing fails. HTTPException: 500 if writing fails.
""" """
try: return await filter_config_service.create_filter(config_dir, socket_path, body, do_reload=reload)
return await filter_config_service.create_filter(config_dir, socket_path, body, do_reload=reload)
except FilterNameError as exc:
raise _bad_request(str(exc)) from exc
except FilterAlreadyExistsError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Filter {exc.name!r} already exists.",
) from exc
except FilterInvalidRegexError as exc:
raise _unprocessable(str(exc)) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write filter: {exc}",
) from exc
@@ -290,22 +221,7 @@ async def delete_filter(
HTTPException: 409 if the filter is a shipped default (conf-only). HTTPException: 409 if the filter is a shipped default (conf-only).
HTTPException: 500 if deletion fails. HTTPException: 500 if deletion fails.
""" """
try: await filter_config_service.delete_filter(config_dir, name)
await filter_config_service.delete_filter(config_dir, name)
except FilterNameError as exc:
raise _bad_request(str(exc)) from exc
except FilterNotFoundError:
raise _filter_not_found(name) from None
except FilterReadonlyError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete filter: {exc}",
) from exc
@@ -314,18 +230,5 @@ async def delete_filter(
# Action discovery endpoints (Task 3.1) # Action discovery endpoints (Task 3.1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_ActionNamePath = Annotated[
str,
Path(description="Action base name, e.g. ``iptables`` or ``iptables.conf``."),
]
def _action_not_found(name: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Action not found: {name!r}",
)

View File

@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Annotated
if TYPE_CHECKING: if TYPE_CHECKING:
from app.services.jail_service import IpLookupResult from app.services.jail_service import IpLookupResult
from fastapi import APIRouter, HTTPException, Path, status from fastapi import APIRouter, Path
from app.dependencies import ( from app.dependencies import (
AuthDep, AuthDep,
@@ -21,7 +21,6 @@ from app.dependencies import (
Fail2BanSocketDep, Fail2BanSocketDep,
HttpSessionDep, HttpSessionDep,
) )
from app.exceptions import Fail2BanConnectionError
from app.models.geo import GeoCacheStatsResponse, GeoReResolveResponse, IpLookupResponse from app.models.geo import GeoCacheStatsResponse, GeoReResolveResponse, IpLookupResponse
from app.services import geo_service, jail_service from app.services import geo_service, jail_service
@@ -58,22 +57,11 @@ async def lookup_ip(
HTTPException: 400 when *ip* is not a valid IP address. HTTPException: 400 when *ip* is not a valid IP address.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: result: IpLookupResult = await jail_service.lookup_ip(
result: IpLookupResult = await jail_service.lookup_ip( socket_path,
socket_path, ip,
ip, http_session=http_session,
http_session=http_session, )
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
except Fail2BanConnectionError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Cannot reach fail2ban: {exc}",
) from exc
return IpLookupResponse(**result) return IpLookupResponse(**result)

View File

@@ -57,48 +57,6 @@ router: APIRouter = APIRouter(prefix="/jails", tags=["Jail Config"])
_NamePath = Annotated[str, Path(description='Jail name as configured in fail2ban.')] _NamePath = Annotated[str, Path(description='Jail name as configured in fail2ban.')]
def _not_found(name: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Jail not found: {name!r}",
)
def _bad_gateway(exc: Exception) -> HTTPException:
return HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Cannot reach fail2ban: {exc}",
)
def _unprocessable(message: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=message,
)
def _bad_request(message: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message,
)
def _filter_not_found(name: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Filter not found: {name!r}",
)
def _action_not_found(name: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Action not found: {name!r}",
)
@router.get( @router.get(
"", "",
response_model=JailConfigListResponse, response_model=JailConfigListResponse,
@@ -121,10 +79,7 @@ async def get_jail_configs(
Returns: Returns:
:class:`~app.models.config.JailConfigListResponse`. :class:`~app.models.config.JailConfigListResponse`.
""" """
try: return await config_service.list_jail_configs(socket_path)
return await config_service.list_jail_configs(socket_path)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@@ -206,12 +161,7 @@ async def get_jail_config(
HTTPException: 404 when the jail does not exist. HTTPException: 404 when the jail does not exist.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: return await config_service.get_jail_config(socket_path, name)
return await config_service.get_jail_config(socket_path, name)
except JailNotFoundError:
raise _not_found(name) from None
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@@ -245,16 +195,7 @@ async def update_jail_config(
HTTPException: 400 when a set command is rejected. HTTPException: 400 when a set command is rejected.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: await config_service.update_jail_config(socket_path, name, body)
await config_service.update_jail_config(socket_path, name, body)
except JailNotFoundError:
raise _not_found(name) from None
except ConfigValidationError as exc:
raise _unprocessable(str(exc)) from exc
except ConfigOperationError as exc:
raise _bad_request(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -292,14 +233,7 @@ async def add_log_path(
HTTPException: 400 when the command is rejected. HTTPException: 400 when the command is rejected.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: await config_service.add_log_path(socket_path, name, body)
await config_service.add_log_path(socket_path, name, body)
except JailNotFoundError:
raise _not_found(name) from None
except ConfigOperationError as exc:
raise _bad_request(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@@ -332,14 +266,7 @@ async def delete_log_path(
HTTPException: 400 when the command is rejected. HTTPException: 400 when the command is rejected.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: await config_service.delete_log_path(socket_path, name, log_path)
await config_service.delete_log_path(socket_path, name, log_path)
except JailNotFoundError:
raise _not_found(name) from None
except ConfigOperationError as exc:
raise _bad_request(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@@ -381,24 +308,7 @@ async def activate_jail(
""" """
req = body if body is not None else ActivateJailRequest() req = body if body is not None else ActivateJailRequest()
try: result = await jail_config_service.activate_jail(config_dir, socket_path, name, req)
result = await jail_config_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
if result.active: if result.active:
record_activation(app, name) record_activation(app, name)
@@ -438,25 +348,7 @@ async def deactivate_jail(
HTTPException: 502 if fail2ban is unreachable. HTTPException: 502 if fail2ban is unreachable.
""" """
try: result = await jail_config_service.deactivate_jail(config_dir, socket_path, name)
result = await jail_config_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
return result return result
@@ -494,24 +386,7 @@ async def delete_jail_local_override(
HTTPException: 502 if fail2ban is unreachable. HTTPException: 502 if fail2ban is unreachable.
""" """
try: await jail_config_service.delete_jail_local_override(config_dir, socket_path, name)
await jail_config_service.delete_jail_local_override(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 JailAlreadyActiveError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Jail {name!r} is currently active; deactivate it first.",
) from None
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete config override: {exc}",
) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -549,10 +424,7 @@ async def validate_jail(
HTTPException: 400 if *name* contains invalid characters. HTTPException: 400 if *name* contains invalid characters.
HTTPException: 404 if *name* is not found in any config file. HTTPException: 404 if *name* is not found in any config file.
""" """
try: return await jail_config_service.validate_jail_config(config_dir, name)
return await jail_config_service.validate_jail_config(config_dir, name)
except JailNameError as exc:
raise _bad_request(str(exc)) from exc
@router.post( @router.post(
@@ -590,15 +462,7 @@ async def rollback_jail(
""" """
start_cmd_parts: list[str] = start_cmd.split() start_cmd_parts: list[str] = start_cmd.split()
try: result = await jail_config_service.rollback_jail(config_dir, socket_path, name, start_cmd_parts)
result = await jail_config_service.rollback_jail(config_dir, socket_path, name, start_cmd_parts)
except JailNameError as exc:
raise _bad_request(str(exc)) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write config override: {exc}",
) from exc
if result.fail2ban_running: if result.fail2ban_running:
clear_pending_recovery(app) clear_pending_recovery(app)
@@ -638,22 +502,7 @@ async def assign_filter_to_jail(
HTTPException: 404 if the jail or filter does not exist. HTTPException: 404 if the jail or filter does not exist.
HTTPException: 500 if writing fails. HTTPException: 500 if writing fails.
""" """
try: await filter_config_service.assign_filter_to_jail(config_dir, socket_path, name, body, do_reload=reload)
await filter_config_service.assign_filter_to_jail(config_dir, socket_path, name, body, do_reload=reload)
except (JailNameError, FilterNameError) as exc:
raise _bad_request(str(exc)) from exc
except JailNotFoundInConfigError:
raise _not_found(name) from None
except FilterNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Filter not found: {exc.name!r}",
) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write jail override: {exc}",
) from exc
@router.post( @router.post(
@@ -688,22 +537,7 @@ async def assign_action_to_jail(
HTTPException: 404 if the jail or action does not exist. HTTPException: 404 if the jail or action does not exist.
HTTPException: 500 if writing fails. HTTPException: 500 if writing fails.
""" """
try: await action_config_service.assign_action_to_jail(config_dir, socket_path, name, body, do_reload=reload)
await action_config_service.assign_action_to_jail(config_dir, socket_path, name, body, do_reload=reload)
except (JailNameError, ActionNameError) as exc:
raise _bad_request(str(exc)) from exc
except JailNotFoundInConfigError:
raise _not_found(name) from None
except ActionNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Action not found: {exc.name!r}",
) from exc
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write jail override: {exc}",
) from exc
@router.delete( @router.delete(
@@ -737,23 +571,13 @@ async def remove_action_from_jail(
HTTPException: 404 if the jail is not found in config files. HTTPException: 404 if the jail is not found in config files.
HTTPException: 500 if writing fails. HTTPException: 500 if writing fails.
""" """
try: await action_config_service.remove_action_from_jail(
await action_config_service.remove_action_from_jail( config_dir,
config_dir, socket_path,
socket_path, name,
name, action_name,
action_name, do_reload=reload,
do_reload=reload, )
)
except (JailNameError, ActionNameError) as exc:
raise _bad_request(str(exc)) from exc
except JailNotFoundInConfigError:
raise _not_found(name) from None
except ConfigWriteError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to write jail override: {exc}",
) from exc
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Filter discovery endpoints (Task 2.1) # Filter discovery endpoints (Task 2.1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -46,58 +46,8 @@ from app.services import jail_service
router: APIRouter = APIRouter(prefix="/api/jails", tags=["Jails"]) router: APIRouter = APIRouter(prefix="/api/jails", tags=["Jails"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_NamePath = Annotated[str, Path(description="Jail name as configured in fail2ban.")] _NamePath = Annotated[str, Path(description="Jail name as configured in fail2ban.")]
def _not_found(name: str) -> HTTPException:
"""Return a 404 response for an unknown jail.
Args:
name: Jail name that was not found.
Returns:
:class:`fastapi.HTTPException` with status 404.
"""
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Jail not found: {name!r}",
)
def _bad_gateway(exc: Exception) -> HTTPException:
"""Return a 502 response when fail2ban is unreachable.
Args:
exc: The underlying connection error.
Returns:
:class:`fastapi.HTTPException` with status 502.
"""
return HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Cannot reach fail2ban: {exc}",
)
def _conflict(message: str) -> HTTPException:
"""Return a 409 response for invalid jail state transitions.
Args:
message: Human-readable description of the conflict.
Returns:
:class:`fastapi.HTTPException` with status 409.
"""
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=message,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Jail listing & detail # Jail listing & detail
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -124,10 +74,7 @@ async def get_jails(
Returns: Returns:
:class:`~app.models.jail.JailListResponse` with all active jails. :class:`~app.models.jail.JailListResponse` with all active jails.
""" """
try: return await jail_service.list_jails(socket_path)
return await jail_service.list_jails(socket_path)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.get( @router.get(
@@ -157,12 +104,7 @@ async def get_jail(
HTTPException: 404 when the jail does not exist. HTTPException: 404 when the jail does not exist.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: return await jail_service.get_jail(socket_path, name)
return await jail_service.get_jail(socket_path, name)
except JailNotFoundError:
raise _not_found(name) from None
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -194,13 +136,8 @@ async def reload_all_jails(
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
HTTPException: 409 when fail2ban reports the operation failed. HTTPException: 409 when fail2ban reports the operation failed.
""" """
try: await jail_service.reload_all(socket_path)
await jail_service.reload_all(socket_path) return JailCommandResponse(message="All jails reloaded successfully.", jail="*")
return JailCommandResponse(message="All jails reloaded successfully.", jail="*")
except JailOperationError as exc:
raise _conflict(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.post( @router.post(
@@ -227,15 +164,8 @@ async def start_jail(
HTTPException: 409 when fail2ban reports the operation failed. HTTPException: 409 when fail2ban reports the operation failed.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: await jail_service.start_jail(socket_path, name)
await jail_service.start_jail(socket_path, name) return JailCommandResponse(message=f"Jail {name!r} started.", jail=name)
return JailCommandResponse(message=f"Jail {name!r} started.", jail=name)
except JailNotFoundError:
raise _not_found(name) from None
except JailOperationError as exc:
raise _conflict(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.post( @router.post(
@@ -265,13 +195,8 @@ async def stop_jail(
HTTPException: 409 when fail2ban reports the operation failed. HTTPException: 409 when fail2ban reports the operation failed.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: await jail_service.stop_jail(socket_path, name)
await jail_service.stop_jail(socket_path, name) return JailCommandResponse(message=f"Jail {name!r} stopped.", jail=name)
return JailCommandResponse(message=f"Jail {name!r} stopped.", jail=name)
except JailOperationError as exc:
raise _conflict(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.post( @router.post(
@@ -304,18 +229,11 @@ async def toggle_idle(
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
state_str = "on" if on else "off" state_str = "on" if on else "off"
try: await jail_service.set_idle(socket_path, name, on=on)
await jail_service.set_idle(socket_path, name, on=on) return JailCommandResponse(
return JailCommandResponse( message=f"Jail {name!r} idle mode turned {state_str}.",
message=f"Jail {name!r} idle mode turned {state_str}.", jail=name,
jail=name, )
)
except JailNotFoundError:
raise _not_found(name) from None
except JailOperationError as exc:
raise _conflict(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.post( @router.post(
@@ -342,15 +260,8 @@ async def reload_jail(
HTTPException: 409 when fail2ban reports the operation failed. HTTPException: 409 when fail2ban reports the operation failed.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: await jail_service.reload_jail(socket_path, name)
await jail_service.reload_jail(socket_path, name) return JailCommandResponse(message=f"Jail {name!r} reloaded.", jail=name)
return JailCommandResponse(message=f"Jail {name!r} reloaded.", jail=name)
except JailNotFoundError:
raise _not_found(name) from None
except JailOperationError as exc:
raise _conflict(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -389,12 +300,7 @@ async def get_ignore_list(
HTTPException: 404 when the jail does not exist. HTTPException: 404 when the jail does not exist.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: return await jail_service.get_ignore_list(socket_path, name)
return await jail_service.get_ignore_list(socket_path, name)
except JailNotFoundError:
raise _not_found(name) from None
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.post( @router.post(
@@ -428,23 +334,11 @@ async def add_ignore_ip(
HTTPException: 409 when fail2ban reports the operation failed. HTTPException: 409 when fail2ban reports the operation failed.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: await jail_service.add_ignore_ip(socket_path, name, body.ip)
await jail_service.add_ignore_ip(socket_path, name, body.ip) return JailCommandResponse(
return JailCommandResponse( message=f"IP {body.ip!r} added to ignore list of jail {name!r}.",
message=f"IP {body.ip!r} added to ignore list of jail {name!r}.", jail=name,
jail=name, )
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
except JailNotFoundError:
raise _not_found(name) from None
except JailOperationError as exc:
raise _conflict(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.delete( @router.delete(
@@ -473,18 +367,11 @@ async def del_ignore_ip(
HTTPException: 409 when fail2ban reports the operation failed. HTTPException: 409 when fail2ban reports the operation failed.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: await jail_service.del_ignore_ip(socket_path, name, body.ip)
await jail_service.del_ignore_ip(socket_path, name, body.ip) return JailCommandResponse(
return JailCommandResponse( message=f"IP {body.ip!r} removed from ignore list of jail {name!r}.",
message=f"IP {body.ip!r} removed from ignore list of jail {name!r}.", jail=name,
jail=name, )
)
except JailNotFoundError:
raise _not_found(name) from None
except JailOperationError as exc:
raise _conflict(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.post( @router.post(
@@ -517,18 +404,11 @@ async def toggle_ignore_self(
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
state_str = "enabled" if on else "disabled" state_str = "enabled" if on else "disabled"
try: await jail_service.set_ignore_self(socket_path, name, on=on)
await jail_service.set_ignore_self(socket_path, name, on=on) return JailCommandResponse(
return JailCommandResponse( message=f"ignoreself {state_str} for jail {name!r}.",
message=f"ignoreself {state_str} for jail {name!r}.", jail=name,
jail=name, )
)
except JailNotFoundError:
raise _not_found(name) from None
except JailOperationError as exc:
raise _conflict(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -584,18 +464,13 @@ async def get_jail_banned_ips(
detail="page_size must be between 1 and 100.", detail="page_size must be between 1 and 100.",
) )
try: return await jail_service.get_jail_banned_ips(
return await jail_service.get_jail_banned_ips( socket_path=socket_path,
socket_path=socket_path, jail_name=name,
jail_name=name, page=page,
page=page, page_size=page_size,
page_size=page_size, search=search,
search=search, geo_batch_lookup=geo_batch_lookup,
geo_batch_lookup=geo_batch_lookup, http_session=http_session,
http_session=http_session, app_db=db,
app_db=db, )
)
except JailNotFoundError:
raise _not_found(name) from None
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc

View File

@@ -15,31 +15,11 @@ from fastapi import APIRouter, HTTPException, Request, status
from app.dependencies import AuthDep, Fail2BanSocketDep from app.dependencies import AuthDep, Fail2BanSocketDep
from app.models.server import ServerSettingsResponse, ServerSettingsUpdate from app.models.server import ServerSettingsResponse, ServerSettingsUpdate
from app.services import server_service from app.services import server_service
from app.exceptions import ServerOperationError from app.exceptions import ServerOperationError, Fail2BanConnectionError
from app.exceptions import Fail2BanConnectionError
router: APIRouter = APIRouter(prefix="/api/server", tags=["Server"]) router: APIRouter = APIRouter(prefix="/api/server", tags=["Server"])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _bad_gateway(exc: Exception) -> HTTPException:
return HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Cannot reach fail2ban: {exc}",
)
def _bad_request(message: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Endpoints # Endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -70,10 +50,7 @@ async def get_server_settings(
Raises: Raises:
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: return await server_service.get_settings(socket_path)
return await server_service.get_settings(socket_path)
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.put( @router.put(
@@ -101,12 +78,7 @@ async def update_server_settings(
HTTPException: 400 when a set command is rejected by fail2ban. HTTPException: 400 when a set command is rejected by fail2ban.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: await server_service.update_settings(socket_path, body)
await server_service.update_settings(socket_path, body)
except ServerOperationError as exc:
raise _bad_request(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc
@router.post( @router.post(
@@ -135,10 +107,5 @@ async def flush_logs(
HTTPException: 400 when the command is rejected. HTTPException: 400 when the command is rejected.
HTTPException: 502 when fail2ban is unreachable. HTTPException: 502 when fail2ban is unreachable.
""" """
try: result = await server_service.flush_logs(socket_path)
result = await server_service.flush_logs(socket_path) return {"message": result}
return {"message": result}
except ServerOperationError as exc:
raise _bad_request(str(exc)) from exc
except Fail2BanConnectionError as exc:
raise _bad_gateway(exc) from exc