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

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