diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 482967a..31602e1 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -320,3 +320,89 @@ When the most recent blocklist import run completed with errors, a warning indic | Frontend layout | `frontend/src/layouts/MainLayout.tsx` | | Tests | `backend/tests/test_services/test_blocklist_service.py` | | Tests | `backend/tests/test_routers/test_blocklist.py` | + +--- + +## Task 6 — Mass Unban: Clear All Currently Banned IPs ✅ DONE + +**Completed:** Added `unban_all_ips()` service function using fail2ban's `unban --all` command. Added `DELETE /api/bans/all` endpoint returning `UnbanAllResponse` with count. Frontend: `bansAll` endpoint constant, `unbanAllBans()` API function, `UnbanAllResponse` type in `types/jail.ts`, `unbanAll` action exposed from `useActiveBans` hook. JailsPage `ActiveBansSection` shows a "Clear All Bans" button (only when bans > 0) that opens a Fluent UI confirmation Dialog before executing. Tests: 7 new tests (3 service + 4 router); 440 total pass. + +### Problem + +[Features.md § 5](Features.md) specifies: *"Option to unban all IPs at once across every jail."* +Currently the Jails page allows unbanning a single IP from a specific jail or from all jails, but there is no mechanism to clear every active ban globally in one operation. fail2ban supports this natively via the `unban --all` socket command, which returns the count of unbanned IPs. + +### Goal + +Add a **"Clear All Bans"** action that: +1. Sends `unban --all` to fail2ban, removing every active ban across every jail in a single command. +2. Returns a count of how many IPs were unbanned. +3. Is exposed in the UI as a "Clear All Bans" button with a confirmation dialog so users cannot trigger it accidentally. +4. Refreshes the active-bans list automatically after the operation completes. + +### Implementation Details + +**Backend — service function** + +1. **`backend/app/services/jail_service.py`** — Add `unban_all_ips(socket_path: str) -> int`: + - Sends `["unban", "--all"]` via `Fail2BanClient`. + - Returns the integer count reported by fail2ban (`_ok()` extracts it from the `(0, count)` tuple). + - Logs the operation at `info` level with the returned count. + +**Backend — new response model** + +2. **`backend/app/models/ban.py`** — Add `UnbanAllResponse(BaseModel)` with fields: + - `message: str` — human-readable summary. + - `count: int` — number of IPs that were unbanned. + +**Backend — new endpoint** + +3. **`backend/app/routers/bans.py`** — Add: + ``` + DELETE /api/bans/all — unban every currently banned IP across all jails + ``` + - Returns `UnbanAllResponse` with `count` from the service call. + - No request body required. + - Handles `Fail2BanConnectionError` → 502. + +**Frontend — API** + +4. **`frontend/src/api/endpoints.ts`** — Add `bansAll: "/bans/all"` to the `ENDPOINTS` map. +5. **`frontend/src/api/jails.ts`** — Add `unbanAllBans(): Promise` that calls `del(ENDPOINTS.bansAll)`. +6. **`frontend/src/types/jail.ts`** — Add `UnbanAllResponse` interface `{ message: string; count: number }`. + +**Frontend — hook** + +7. **`frontend/src/hooks/useJails.ts`** — In `useActiveBans`, add `unbanAll: () => Promise` action and expose it from the hook return value. + +**Frontend — UI** + +8. **`frontend/src/pages/JailsPage.tsx`** — In `ActiveBansSection`: + - Add a "Clear All Bans" `Button` (appearance `"outline"`, intent `"danger"`) in the section header next to the existing Refresh button. + - Wrap the confirm action in a Fluent UI `Dialog` with a warning body explaining the operation is irreversible. + - On confirmation, call `unbanAll()`, show a success `MessageBar` with the count, and call `refresh()`. + +**Tests** + +9. **`backend/tests/test_services/test_jail_service.py`** — Add `TestUnbanAllIps`: + - `test_unban_all_ips_returns_count` — mocks client with `(0, 5)` response, asserts return is `5`. + - `test_unban_all_ips_raises_on_fail2ban_error` — mocks client to raise `Fail2BanConnectionError`, asserts it propagates. +10. **`backend/tests/test_routers/test_bans.py`** — Add `TestUnbanAll`: + - `test_204_clears_all_bans` — patches `unban_all_ips` returning `3`, asserts 200 response with `count == 3`. + - `test_502_when_fail2ban_unreachable` — patches `unban_all_ips` raising `Fail2BanConnectionError`, asserts 502. + - `test_401_when_unauthenticated` — unauthenticated request returns 401. + +### Files Touched + +| Layer | File | +|-------|------| +| Model | `backend/app/models/ban.py` | +| Service | `backend/app/services/jail_service.py` | +| Router | `backend/app/routers/bans.py` | +| Frontend type | `frontend/src/types/jail.ts` | +| Frontend API | `frontend/src/api/endpoints.ts` | +| Frontend API | `frontend/src/api/jails.ts` | +| Frontend hook | `frontend/src/hooks/useJails.ts` | +| Frontend UI | `frontend/src/pages/JailsPage.tsx` | +| Tests | `backend/tests/test_services/test_jail_service.py` | +| Tests | `backend/tests/test_routers/test_bans.py` | diff --git a/backend/app/models/ban.py b/backend/app/models/ban.py index 5f78b4a..78a20cf 100644 --- a/backend/app/models/ban.py +++ b/backend/app/models/ban.py @@ -133,6 +133,15 @@ class ActiveBanListResponse(BaseModel): total: int = Field(..., ge=0) +class UnbanAllResponse(BaseModel): + """Response for ``DELETE /api/bans/all``.""" + + model_config = ConfigDict(strict=True) + + message: str = Field(..., description="Human-readable summary of the operation.") + count: int = Field(..., ge=0, description="Number of IPs that were unbanned.") + + # --------------------------------------------------------------------------- # Dashboard ban-list view models # --------------------------------------------------------------------------- diff --git a/backend/app/routers/bans.py b/backend/app/routers/bans.py index 8f2011f..1fc28f4 100644 --- a/backend/app/routers/bans.py +++ b/backend/app/routers/bans.py @@ -5,6 +5,7 @@ Manual ban and unban operations and the active-bans overview: * ``GET /api/bans/active`` — list all currently banned IPs * ``POST /api/bans`` — ban an IP in a specific jail * ``DELETE /api/bans`` — unban an IP from one or all jails +* ``DELETE /api/bans/all`` — unban every currently banned IP across all jails """ from __future__ import annotations @@ -17,7 +18,7 @@ if TYPE_CHECKING: from fastapi import APIRouter, HTTPException, Request, status from app.dependencies import AuthDep -from app.models.ban import ActiveBanListResponse, BanRequest, UnbanRequest +from app.models.ban import ActiveBanListResponse, BanRequest, UnbanAllResponse, UnbanRequest from app.models.jail import JailCommandResponse from app.services import geo_service, jail_service from app.services.jail_service import JailNotFoundError, JailOperationError @@ -193,3 +194,39 @@ async def unban_ip( ) from exc except Fail2BanConnectionError as exc: raise _bad_gateway(exc) from exc + + +@router.delete( + "/all", + response_model=UnbanAllResponse, + summary="Unban every currently banned IP across all jails", +) +async def unban_all( + request: Request, + _auth: AuthDep, +) -> UnbanAllResponse: + """Remove all active bans from every fail2ban jail in a single operation. + + Uses fail2ban's ``unban --all`` command to atomically clear every active + ban across all jails. Returns the number of IPs that were unbanned. + + Args: + request: Incoming request (used to access ``app.state``). + _auth: Validated session — enforces authentication. + + Returns: + :class:`~app.models.ban.UnbanAllResponse` with the count of + unbanned IPs. + + Raises: + HTTPException: 502 when fail2ban is unreachable. + """ + socket_path: str = request.app.state.settings.fail2ban_socket + try: + count: int = await jail_service.unban_all_ips(socket_path) + return UnbanAllResponse( + message=f"All bans cleared. {count} IP address{'es' if count != 1 else ''} unbanned.", + count=count, + ) + except Fail2BanConnectionError as exc: + raise _bad_gateway(exc) from exc diff --git a/backend/app/services/jail_service.py b/backend/app/services/jail_service.py index 5416c4f..edaee06 100644 --- a/backend/app/services/jail_service.py +++ b/backend/app/services/jail_service.py @@ -1014,3 +1014,25 @@ async def lookup_ip( "currently_banned_in": currently_banned_in, "geo": geo, } + + +async def unban_all_ips(socket_path: str) -> int: + """Unban every currently banned IP across all fail2ban jails. + + Uses fail2ban's global ``unban --all`` command, which atomically removes + every active ban from every jail in a single socket round-trip. + + Args: + socket_path: Path to the fail2ban Unix domain socket. + + Returns: + The number of IP addresses that were unbanned. + + Raises: + ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket + cannot be reached. + """ + client = Fail2BanClient(socket_path=socket_path, timeout=_SOCKET_TIMEOUT) + count: int = int(_ok(await client.send(["unban", "--all"]))) + log.info("all_ips_unbanned", count=count) + return count diff --git a/backend/tests/test_routers/test_bans.py b/backend/tests/test_routers/test_bans.py index 727b200..2be5d96 100644 --- a/backend/tests/test_routers/test_bans.py +++ b/backend/tests/test_routers/test_bans.py @@ -13,6 +13,7 @@ from app.config import Settings from app.db import init_db from app.main import create_app from app.models.ban import ActiveBan, ActiveBanListResponse +from app.utils.fail2ban_client import Fail2BanConnectionError # --------------------------------------------------------------------------- # Fixtures @@ -270,3 +271,61 @@ class TestUnbanIp: ) assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# DELETE /api/bans/all +# --------------------------------------------------------------------------- + + +class TestUnbanAll: + """Tests for ``DELETE /api/bans/all``.""" + + async def test_200_clears_all_bans(self, bans_client: AsyncClient) -> None: + """DELETE /api/bans/all returns 200 with count when successful.""" + with patch( + "app.routers.bans.jail_service.unban_all_ips", + AsyncMock(return_value=3), + ): + resp = await bans_client.request("DELETE", "/api/bans/all") + + assert resp.status_code == 200 + data = resp.json() + assert data["count"] == 3 + assert "3" in data["message"] + + async def test_200_with_zero_count(self, bans_client: AsyncClient) -> None: + """DELETE /api/bans/all returns 200 with count=0 when no bans existed.""" + with patch( + "app.routers.bans.jail_service.unban_all_ips", + AsyncMock(return_value=0), + ): + resp = await bans_client.request("DELETE", "/api/bans/all") + + assert resp.status_code == 200 + assert resp.json()["count"] == 0 + + async def test_502_when_fail2ban_unreachable( + self, bans_client: AsyncClient + ) -> None: + """DELETE /api/bans/all returns 502 when fail2ban is unreachable.""" + with patch( + "app.routers.bans.jail_service.unban_all_ips", + AsyncMock( + side_effect=Fail2BanConnectionError( + "cannot connect", + "/var/run/fail2ban/fail2ban.sock", + ) + ), + ): + resp = await bans_client.request("DELETE", "/api/bans/all") + + assert resp.status_code == 502 + + async def test_401_when_unauthenticated(self, bans_client: AsyncClient) -> None: + """DELETE /api/bans/all returns 401 without session.""" + resp = await AsyncClient( + transport=ASGITransport(app=bans_client._transport.app), # type: ignore[attr-defined] + base_url="http://test", + ).request("DELETE", "/api/bans/all") + assert resp.status_code == 401 diff --git a/backend/tests/test_services/test_jail_service.py b/backend/tests/test_services/test_jail_service.py index 2d23a54..146ee4b 100644 --- a/backend/tests/test_services/test_jail_service.py +++ b/backend/tests/test_services/test_jail_service.py @@ -554,3 +554,38 @@ class TestLookupIp: result = await jail_service.lookup_ip(_SOCKET, "9.9.9.9") assert result["currently_banned_in"] == [] + + +# --------------------------------------------------------------------------- +# unban_all_ips +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestUnbanAllIps: + """Tests for :func:`~app.services.jail_service.unban_all_ips`.""" + + async def test_unban_all_ips_returns_count(self) -> None: + """unban_all_ips returns the integer count from fail2ban.""" + responses = {"unban|--all": (0, 5)} + with _patch_client(responses): + count = await jail_service.unban_all_ips(_SOCKET) + + assert count == 5 + + async def test_unban_all_ips_returns_zero_when_none_banned(self) -> None: + """unban_all_ips returns 0 when no IPs are currently banned.""" + responses = {"unban|--all": (0, 0)} + with _patch_client(responses): + count = await jail_service.unban_all_ips(_SOCKET) + + assert count == 0 + + async def test_unban_all_ips_raises_on_connection_error(self) -> None: + """unban_all_ips propagates Fail2BanConnectionError.""" + with patch( + "app.services.jail_service.Fail2BanClient", + side_effect=Fail2BanConnectionError("unreachable", _SOCKET), + ): + with pytest.raises(Fail2BanConnectionError): + await jail_service.unban_all_ips(_SOCKET) diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 3398896..dec7163 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -48,6 +48,7 @@ export const ENDPOINTS = { // ------------------------------------------------------------------------- bans: "/bans", bansActive: "/bans/active", + bansAll: "/bans/all", // ------------------------------------------------------------------------- // Geo / IP lookup diff --git a/frontend/src/api/jails.ts b/frontend/src/api/jails.ts index a9cc77f..dab9349 100644 --- a/frontend/src/api/jails.ts +++ b/frontend/src/api/jails.ts @@ -13,6 +13,7 @@ import type { JailCommandResponse, JailDetailResponse, JailListResponse, + UnbanAllResponse, } from "../types/jail"; // --------------------------------------------------------------------------- @@ -197,6 +198,18 @@ export async function fetchActiveBans(): Promise { return get(ENDPOINTS.bansActive); } +/** + * Unban every currently banned IP across all jails in a single operation. + * + * Uses fail2ban's global `unban --all` command. + * + * @returns An {@link UnbanAllResponse} with the count of unbanned IPs. + * @throws {ApiError} On non-2xx responses. + */ +export async function unbanAllBans(): Promise { + return del(ENDPOINTS.bansAll); +} + // --------------------------------------------------------------------------- // Geo / IP lookup // --------------------------------------------------------------------------- diff --git a/frontend/src/hooks/useJails.ts b/frontend/src/hooks/useJails.ts index fec1e2c..019174a 100644 --- a/frontend/src/hooks/useJails.ts +++ b/frontend/src/hooks/useJails.ts @@ -20,6 +20,7 @@ import { setJailIdle, startJail, stopJail, + unbanAllBans, unbanIp, } from "../api/jails"; import type { @@ -27,6 +28,7 @@ import type { IpLookupResponse, Jail, JailSummary, + UnbanAllResponse, } from "../types/jail"; // --------------------------------------------------------------------------- @@ -238,6 +240,8 @@ export interface UseActiveBansResult { banIp: (jail: string, ip: string) => Promise; /** Unban an IP from a jail (or all jails when `jail` is omitted). */ unbanIp: (ip: string, jail?: string) => Promise; + /** Unban every currently banned IP across all jails. */ + unbanAll: () => Promise; } /** @@ -293,6 +297,12 @@ export function useActiveBans(): UseActiveBansResult { load(); }; + const doUnbanAll = async (): Promise => { + const result = await unbanAllBans(); + load(); + return result; + }; + return { bans, total, @@ -301,6 +311,7 @@ export function useActiveBans(): UseActiveBansResult { refresh: load, banIp: doBan, unbanIp: doUnban, + unbanAll: doUnbanAll, }; } diff --git a/frontend/src/pages/JailsPage.tsx b/frontend/src/pages/JailsPage.tsx index 04657dd..f568470 100644 --- a/frontend/src/pages/JailsPage.tsx +++ b/frontend/src/pages/JailsPage.tsx @@ -20,6 +20,12 @@ import { DataGridHeader, DataGridHeaderCell, DataGridRow, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, Field, Input, MessageBar, @@ -36,6 +42,7 @@ import { import { ArrowClockwiseRegular, ArrowSyncRegular, + DeleteRegular, DismissRegular, LockClosedRegular, LockOpenRegular, @@ -646,16 +653,38 @@ function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.J function ActiveBansSection(): React.JSX.Element { const styles = useStyles(); - const { bans, total, loading, error, refresh, unbanIp } = useActiveBans(); + const { bans, total, loading, error, refresh, unbanIp, unbanAll } = useActiveBans(); const [opError, setOpError] = useState(null); + const [opSuccess, setOpSuccess] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const [clearing, setClearing] = useState(false); const handleUnban = (ip: string, jail: string): void => { setOpError(null); + setOpSuccess(null); unbanIp(ip, jail).catch((err: unknown) => { setOpError(err instanceof Error ? err.message : String(err)); }); }; + const handleClearAll = (): void => { + setClearing(true); + setOpError(null); + setOpSuccess(null); + unbanAll() + .then((res) => { + setOpSuccess(res.message); + setConfirmOpen(false); + }) + .catch((err: unknown) => { + setOpError(err instanceof Error ? err.message : String(err)); + setConfirmOpen(false); + }) + .finally(() => { + setClearing(false); + }); + }; + const banColumns = buildBanColumns(handleUnban); return ( @@ -669,21 +698,81 @@ function ActiveBansSection(): React.JSX.Element { )} - +
+ + {total > 0 && ( + + )} +
+ {/* Confirmation dialog */} + { + if (!data.open) setConfirmOpen(false); + }} + > + + + Clear All Bans + + + This will immediately unban all {String(total)} IP + {total !== 1 ? "s" : ""} across every jail. This + action cannot be undone — fail2ban will no longer block any + of those addresses until they trigger the rate-limit again. + + + + + + + + + + {opError && ( {opError} )} + {opSuccess && ( + + {opSuccess} + + )} {error && ( Failed to load bans: {error} diff --git a/frontend/src/types/jail.ts b/frontend/src/types/jail.ts index 3c21d88..6d88e16 100644 --- a/frontend/src/types/jail.ts +++ b/frontend/src/types/jail.ts @@ -184,6 +184,16 @@ export interface ActiveBanListResponse { * * Mirrors `GeoDetail` from `backend/app/models/geo.py`. */ +/** + * Mirrors `UnbanAllResponse` from `backend/app/models/ban.py`. + */ +export interface UnbanAllResponse { + /** Human-readable summary of the operation. */ + message: string; + /** Number of IP addresses that were unbanned. */ + count: number; +} + export interface GeoDetail { /** ISO 3166-1 alpha-2 country code (e.g. `"DE"`), or `null`. */ country_code: string | null;