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:
@@ -48,6 +48,7 @@ export const ENDPOINTS = {
|
||||
// -------------------------------------------------------------------------
|
||||
bans: "/bans",
|
||||
bansActive: "/bans/active",
|
||||
bansAll: "/bans/all",
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Geo / IP lookup
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user