Add mass unban: DELETE /api/bans/all clears all active bans

- Send fail2ban's `unban --all` command via new `unban_all_ips()` service
  function; returns the count of unbanned IPs
- Add `UnbanAllResponse` Pydantic model (message + count)
- Add `DELETE /api/bans/all` router endpoint; handles 502 on socket error
- Frontend: `bansAll` endpoint constant, `unbanAllBans()` API call,
  `UnbanAllResponse` type, `unbanAll` action in `useActiveBans` hook
- JailsPage: "Clear All Bans" button (visible when bans > 0) with a
  Fluent UI confirmation Dialog before executing the operation
- 7 new tests (3 service, 4 router); 440 total pass, 82% coverage
This commit is contained in:
2026-03-07 21:16:49 +01:00
parent 207be94c42
commit 4773ae1c7a
11 changed files with 382 additions and 10 deletions

View File

@@ -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<UnbanAllResponse>` that calls `del<UnbanAllResponse>(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<UnbanAllResponse>` 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` |

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -48,6 +48,7 @@ export const ENDPOINTS = {
// -------------------------------------------------------------------------
bans: "/bans",
bansActive: "/bans/active",
bansAll: "/bans/all",
// -------------------------------------------------------------------------
// Geo / IP lookup

View File

@@ -13,6 +13,7 @@ import type {
JailCommandResponse,
JailDetailResponse,
JailListResponse,
UnbanAllResponse,
} from "../types/jail";
// ---------------------------------------------------------------------------
@@ -197,6 +198,18 @@ export async function fetchActiveBans(): Promise<ActiveBanListResponse> {
return get<ActiveBanListResponse>(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<UnbanAllResponse> {
return del<UnbanAllResponse>(ENDPOINTS.bansAll);
}
// ---------------------------------------------------------------------------
// Geo / IP lookup
// ---------------------------------------------------------------------------

View File

@@ -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<void>;
/** Unban an IP from a jail (or all jails when `jail` is omitted). */
unbanIp: (ip: string, jail?: string) => Promise<void>;
/** Unban every currently banned IP across all jails. */
unbanAll: () => Promise<UnbanAllResponse>;
}
/**
@@ -293,6 +297,12 @@ export function useActiveBans(): UseActiveBansResult {
load();
};
const doUnbanAll = async (): Promise<UnbanAllResponse> => {
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,
};
}

View File

@@ -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<string | null>(null);
const [opSuccess, setOpSuccess] = useState<string | null>(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 {
</Badge>
)}
</Text>
<Button
size="small"
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={refresh}
>
Refresh
</Button>
<div style={{ display: "flex", gap: tokens.spacingHorizontalS }}>
<Button
size="small"
appearance="subtle"
icon={<ArrowClockwiseRegular />}
onClick={refresh}
>
Refresh
</Button>
{total > 0 && (
<Button
size="small"
appearance="outline"
icon={<DeleteRegular />}
onClick={() => {
setConfirmOpen(true);
}}
>
Clear All Bans
</Button>
)}
</div>
</div>
{/* Confirmation dialog */}
<Dialog
open={confirmOpen}
onOpenChange={(_ev, data) => {
if (!data.open) setConfirmOpen(false);
}}
>
<DialogSurface>
<DialogBody>
<DialogTitle>Clear All Bans</DialogTitle>
<DialogContent>
<Text>
This will immediately unban <strong>all {String(total)} IP
{total !== 1 ? "s" : ""}</strong> across every jail. This
action cannot be undone fail2ban will no longer block any
of those addresses until they trigger the rate-limit again.
</Text>
</DialogContent>
<DialogActions>
<Button
appearance="secondary"
onClick={() => {
setConfirmOpen(false);
}}
disabled={clearing}
>
Cancel
</Button>
<Button
appearance="primary"
onClick={handleClearAll}
disabled={clearing}
icon={clearing ? <Spinner size="tiny" /> : <DeleteRegular />}
>
{clearing ? "Clearing…" : "Clear All Bans"}
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
{opError && (
<MessageBar intent="error">
<MessageBarBody>{opError}</MessageBarBody>
</MessageBar>
)}
{opSuccess && (
<MessageBar intent="success">
<MessageBarBody>{opSuccess}</MessageBarBody>
</MessageBar>
)}
{error && (
<MessageBar intent="error">
<MessageBarBody>Failed to load bans: {error}</MessageBarBody>

View File

@@ -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;