feature/ignore-self-toggle #1
@@ -320,3 +320,89 @@ When the most recent blocklist import run completed with errors, a warning indic
|
|||||||
| Frontend layout | `frontend/src/layouts/MainLayout.tsx` |
|
| Frontend layout | `frontend/src/layouts/MainLayout.tsx` |
|
||||||
| Tests | `backend/tests/test_services/test_blocklist_service.py` |
|
| Tests | `backend/tests/test_services/test_blocklist_service.py` |
|
||||||
| Tests | `backend/tests/test_routers/test_blocklist.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` |
|
||||||
|
|||||||
@@ -133,6 +133,15 @@ class ActiveBanListResponse(BaseModel):
|
|||||||
total: int = Field(..., ge=0)
|
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
|
# Dashboard ban-list view models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Manual ban and unban operations and the active-bans overview:
|
|||||||
* ``GET /api/bans/active`` — list all currently banned IPs
|
* ``GET /api/bans/active`` — list all currently banned IPs
|
||||||
* ``POST /api/bans`` — ban an IP in a specific jail
|
* ``POST /api/bans`` — ban an IP in a specific jail
|
||||||
* ``DELETE /api/bans`` — unban an IP from one or all jails
|
* ``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
|
from __future__ import annotations
|
||||||
@@ -17,7 +18,7 @@ if TYPE_CHECKING:
|
|||||||
from fastapi import APIRouter, HTTPException, Request, status
|
from fastapi import APIRouter, HTTPException, Request, status
|
||||||
|
|
||||||
from app.dependencies import AuthDep
|
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.models.jail import JailCommandResponse
|
||||||
from app.services import geo_service, jail_service
|
from app.services import geo_service, jail_service
|
||||||
from app.services.jail_service import JailNotFoundError, JailOperationError
|
from app.services.jail_service import JailNotFoundError, JailOperationError
|
||||||
@@ -193,3 +194,39 @@ async def unban_ip(
|
|||||||
) from exc
|
) from exc
|
||||||
except Fail2BanConnectionError as exc:
|
except Fail2BanConnectionError as exc:
|
||||||
raise _bad_gateway(exc) from 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
|
||||||
|
|||||||
@@ -1014,3 +1014,25 @@ async def lookup_ip(
|
|||||||
"currently_banned_in": currently_banned_in,
|
"currently_banned_in": currently_banned_in,
|
||||||
"geo": geo,
|
"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
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.config import Settings
|
|||||||
from app.db import init_db
|
from app.db import init_db
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
from app.models.ban import ActiveBan, ActiveBanListResponse
|
from app.models.ban import ActiveBan, ActiveBanListResponse
|
||||||
|
from app.utils.fail2ban_client import Fail2BanConnectionError
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Fixtures
|
# Fixtures
|
||||||
@@ -270,3 +271,61 @@ class TestUnbanIp:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status_code == 404
|
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
|
||||||
|
|||||||
@@ -554,3 +554,38 @@ class TestLookupIp:
|
|||||||
result = await jail_service.lookup_ip(_SOCKET, "9.9.9.9")
|
result = await jail_service.lookup_ip(_SOCKET, "9.9.9.9")
|
||||||
|
|
||||||
assert result["currently_banned_in"] == []
|
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)
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const ENDPOINTS = {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
bans: "/bans",
|
bans: "/bans",
|
||||||
bansActive: "/bans/active",
|
bansActive: "/bans/active",
|
||||||
|
bansAll: "/bans/all",
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Geo / IP lookup
|
// Geo / IP lookup
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
JailCommandResponse,
|
JailCommandResponse,
|
||||||
JailDetailResponse,
|
JailDetailResponse,
|
||||||
JailListResponse,
|
JailListResponse,
|
||||||
|
UnbanAllResponse,
|
||||||
} from "../types/jail";
|
} from "../types/jail";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -197,6 +198,18 @@ export async function fetchActiveBans(): Promise<ActiveBanListResponse> {
|
|||||||
return get<ActiveBanListResponse>(ENDPOINTS.bansActive);
|
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
|
// Geo / IP lookup
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
setJailIdle,
|
setJailIdle,
|
||||||
startJail,
|
startJail,
|
||||||
stopJail,
|
stopJail,
|
||||||
|
unbanAllBans,
|
||||||
unbanIp,
|
unbanIp,
|
||||||
} from "../api/jails";
|
} from "../api/jails";
|
||||||
import type {
|
import type {
|
||||||
@@ -27,6 +28,7 @@ import type {
|
|||||||
IpLookupResponse,
|
IpLookupResponse,
|
||||||
Jail,
|
Jail,
|
||||||
JailSummary,
|
JailSummary,
|
||||||
|
UnbanAllResponse,
|
||||||
} from "../types/jail";
|
} from "../types/jail";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -238,6 +240,8 @@ export interface UseActiveBansResult {
|
|||||||
banIp: (jail: string, ip: string) => Promise<void>;
|
banIp: (jail: string, ip: string) => Promise<void>;
|
||||||
/** Unban an IP from a jail (or all jails when `jail` is omitted). */
|
/** Unban an IP from a jail (or all jails when `jail` is omitted). */
|
||||||
unbanIp: (ip: string, jail?: string) => Promise<void>;
|
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();
|
load();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const doUnbanAll = async (): Promise<UnbanAllResponse> => {
|
||||||
|
const result = await unbanAllBans();
|
||||||
|
load();
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bans,
|
bans,
|
||||||
total,
|
total,
|
||||||
@@ -301,6 +311,7 @@ export function useActiveBans(): UseActiveBansResult {
|
|||||||
refresh: load,
|
refresh: load,
|
||||||
banIp: doBan,
|
banIp: doBan,
|
||||||
unbanIp: doUnban,
|
unbanIp: doUnban,
|
||||||
|
unbanAll: doUnbanAll,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ import {
|
|||||||
DataGridHeader,
|
DataGridHeader,
|
||||||
DataGridHeaderCell,
|
DataGridHeaderCell,
|
||||||
DataGridRow,
|
DataGridRow,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogSurface,
|
||||||
|
DialogTitle,
|
||||||
Field,
|
Field,
|
||||||
Input,
|
Input,
|
||||||
MessageBar,
|
MessageBar,
|
||||||
@@ -36,6 +42,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
ArrowClockwiseRegular,
|
ArrowClockwiseRegular,
|
||||||
ArrowSyncRegular,
|
ArrowSyncRegular,
|
||||||
|
DeleteRegular,
|
||||||
DismissRegular,
|
DismissRegular,
|
||||||
LockClosedRegular,
|
LockClosedRegular,
|
||||||
LockOpenRegular,
|
LockOpenRegular,
|
||||||
@@ -646,16 +653,38 @@ function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps): React.J
|
|||||||
|
|
||||||
function ActiveBansSection(): React.JSX.Element {
|
function ActiveBansSection(): React.JSX.Element {
|
||||||
const styles = useStyles();
|
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 [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 => {
|
const handleUnban = (ip: string, jail: string): void => {
|
||||||
setOpError(null);
|
setOpError(null);
|
||||||
|
setOpSuccess(null);
|
||||||
unbanIp(ip, jail).catch((err: unknown) => {
|
unbanIp(ip, jail).catch((err: unknown) => {
|
||||||
setOpError(err instanceof Error ? err.message : String(err));
|
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);
|
const banColumns = buildBanColumns(handleUnban);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -669,6 +698,7 @@ function ActiveBansSection(): React.JSX.Element {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
<div style={{ display: "flex", gap: tokens.spacingHorizontalS }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
appearance="subtle"
|
appearance="subtle"
|
||||||
@@ -677,13 +707,72 @@ function ActiveBansSection(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
|
{total > 0 && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
appearance="outline"
|
||||||
|
icon={<DeleteRegular />}
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear All Bans
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</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 && (
|
{opError && (
|
||||||
<MessageBar intent="error">
|
<MessageBar intent="error">
|
||||||
<MessageBarBody>{opError}</MessageBarBody>
|
<MessageBarBody>{opError}</MessageBarBody>
|
||||||
</MessageBar>
|
</MessageBar>
|
||||||
)}
|
)}
|
||||||
|
{opSuccess && (
|
||||||
|
<MessageBar intent="success">
|
||||||
|
<MessageBarBody>{opSuccess}</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<MessageBar intent="error">
|
<MessageBar intent="error">
|
||||||
<MessageBarBody>Failed to load bans: {error}</MessageBarBody>
|
<MessageBarBody>Failed to load bans: {error}</MessageBarBody>
|
||||||
|
|||||||
@@ -184,6 +184,16 @@ export interface ActiveBanListResponse {
|
|||||||
*
|
*
|
||||||
* Mirrors `GeoDetail` from `backend/app/models/geo.py`.
|
* 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 {
|
export interface GeoDetail {
|
||||||
/** ISO 3166-1 alpha-2 country code (e.g. `"DE"`), or `null`. */
|
/** ISO 3166-1 alpha-2 country code (e.g. `"DE"`), or `null`. */
|
||||||
country_code: string | null;
|
country_code: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user