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

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