Add ignore-self toggle to Jail Detail page

Implements the missing UI control for POST /api/jails/{name}/ignoreself:
- Add jailIgnoreSelf endpoint constant to endpoints.ts
- Add toggleIgnoreSelf(name, on) API function to jails.ts
- Expose toggleIgnoreSelf action from useJailDetail hook
- Replace read-only 'ignore self' badge with a Fluent Switch in
  IgnoreListSection to allow enabling/disabling the flag per jail
- Add 5 vitest tests for checked/unchecked state and toggle behaviour
This commit is contained in:
2026-03-14 20:24:49 +01:00
parent d3b2022ffb
commit 6bb38dbd8c
6 changed files with 370 additions and 8 deletions

View File

@@ -45,6 +45,7 @@ export const ENDPOINTS = {
jailReload: (name: string): string => `/jails/${encodeURIComponent(name)}/reload`,
jailsReloadAll: "/jails/reload-all",
jailIgnoreIp: (name: string): string => `/jails/${encodeURIComponent(name)}/ignoreip`,
jailIgnoreSelf: (name: string): string => `/jails/${encodeURIComponent(name)}/ignoreself`,
// -------------------------------------------------------------------------
// Bans

View File

@@ -149,6 +149,24 @@ export async function delIgnoreIp(
return del<JailCommandResponse>(ENDPOINTS.jailIgnoreIp(name), { ip });
}
/**
* Enable or disable the `ignoreself` flag for a jail.
*
* When enabled, fail2ban automatically adds the server's own IP addresses to
* the ignore list so the host can never ban itself.
*
* @param name - Jail name.
* @param on - `true` to enable, `false` to disable.
* @returns A {@link JailCommandResponse} confirming the change.
* @throws {ApiError} On non-2xx responses.
*/
export async function toggleIgnoreSelf(
name: string,
on: boolean,
): Promise<JailCommandResponse> {
return post<JailCommandResponse>(ENDPOINTS.jailIgnoreSelf(name), on);
}
// ---------------------------------------------------------------------------
// Ban / unban
// ---------------------------------------------------------------------------

View File

@@ -20,6 +20,7 @@ import {
setJailIdle,
startJail,
stopJail,
toggleIgnoreSelf as toggleIgnoreSelfApi,
unbanAllBans,
unbanIp,
} from "../api/jails";
@@ -150,6 +151,8 @@ export interface UseJailDetailResult {
addIp: (ip: string) => Promise<void>;
/** Remove an IP from the ignore list. */
removeIp: (ip: string) => Promise<void>;
/** Enable or disable the ignoreself option for this jail. */
toggleIgnoreSelf: (on: boolean) => Promise<void>;
}
/**
@@ -208,6 +211,11 @@ export function useJailDetail(name: string): UseJailDetailResult {
load();
};
const toggleIgnoreSelf = async (on: boolean): Promise<void> => {
await toggleIgnoreSelfApi(name, on);
load();
};
return {
jail,
ignoreList,
@@ -217,6 +225,7 @@ export function useJailDetail(name: string): UseJailDetailResult {
refresh: load,
addIp,
removeIp,
toggleIgnoreSelf,
};
}

View File

@@ -17,6 +17,7 @@ import {
MessageBar,
MessageBarBody,
Spinner,
Switch,
Text,
Tooltip,
makeStyles,
@@ -443,6 +444,7 @@ interface IgnoreListSectionProps {
ignoreSelf: boolean;
onAdd: (ip: string) => Promise<void>;
onRemove: (ip: string) => Promise<void>;
onToggleIgnoreSelf: (on: boolean) => Promise<void>;
}
function IgnoreListSection({
@@ -451,6 +453,7 @@ function IgnoreListSection({
ignoreSelf,
onAdd,
onRemove,
onToggleIgnoreSelf,
}: IgnoreListSectionProps): React.JSX.Element {
const styles = useStyles();
const [inputVal, setInputVal] = useState("");
@@ -494,17 +497,27 @@ function IgnoreListSection({
<Text as="h2" size={500} weight="semibold">
Ignore List (IP Whitelist)
</Text>
{ignoreSelf && (
<Tooltip content="This jail ignores the server's own IP addresses" relationship="label">
<Badge appearance="tint" color="informative">
ignore self
</Badge>
</Tooltip>
)}
</div>
<Badge appearance="tint">{String(ignoreList.length)}</Badge>
</div>
{/* Ignore-self toggle */}
<Switch
label="Ignore self — exclude this server's own IP addresses from banning"
checked={ignoreSelf}
onChange={(_e, data): void => {
onToggleIgnoreSelf(data.checked).catch((err: unknown) => {
const msg =
err instanceof ApiError
? `${String(err.status)}: ${err.body}`
: err instanceof Error
? err.message
: String(err);
setOpError(msg);
});
}}
/>
{opError && (
<MessageBar intent="error">
<MessageBarBody>{opError}</MessageBarBody>
@@ -579,7 +592,7 @@ function IgnoreListSection({
export function JailDetailPage(): React.JSX.Element {
const styles = useStyles();
const { name = "" } = useParams<{ name: string }>();
const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp } =
const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp, toggleIgnoreSelf } =
useJailDetail(name);
if (loading && !jail) {
@@ -634,6 +647,7 @@ export function JailDetailPage(): React.JSX.Element {
ignoreSelf={ignoreSelf}
onAdd={addIp}
onRemove={removeIp}
onToggleIgnoreSelf={toggleIgnoreSelf}
/>
</div>
);

View File

@@ -0,0 +1,188 @@
/**
* Tests for the "Ignore self" toggle in `JailDetailPage`.
*
* Verifies that:
* - The switch is checked when `ignoreSelf` is `true`.
* - The switch is unchecked when `ignoreSelf` is `false`.
* - Toggling the switch calls `toggleIgnoreSelf` with the correct boolean.
* - A failed toggle shows an error message bar.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { JailDetailPage } from "../JailDetailPage";
import type { Jail } from "../../types/jail";
import type { UseJailDetailResult } from "../../hooks/useJails";
// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------
/**
* Stable mock function refs created before vi.mock() is hoisted.
* We need `mockToggleIgnoreSelf` to be a vi.fn() that tests can inspect
* and the rest to be no-ops that prevent real network calls.
*/
const {
mockToggleIgnoreSelf,
mockAddIp,
mockRemoveIp,
mockRefresh,
} = vi.hoisted(() => ({
mockToggleIgnoreSelf: vi.fn<(on: boolean) => Promise<void>>(),
mockAddIp: vi.fn<(ip: string) => Promise<void>>().mockResolvedValue(undefined),
mockRemoveIp: vi.fn<(ip: string) => Promise<void>>().mockResolvedValue(undefined),
mockRefresh: vi.fn(),
}));
// Mock the jail detail hook — tests control the returned state directly.
vi.mock("../../hooks/useJails", () => ({
useJailDetail: vi.fn(),
}));
// Mock API functions used by JailInfoSection control buttons to avoid side effects.
vi.mock("../../api/jails", () => ({
startJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }),
stopJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }),
reloadJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }),
setJailIdle: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }),
toggleIgnoreSelf: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }),
}));
// Stub BannedIpsSection to prevent its own fetchJailBannedIps calls.
vi.mock("../../components/jail/BannedIpsSection", () => ({
BannedIpsSection: () => <div data-testid="banned-ips-stub" />,
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
import { useJailDetail } from "../../hooks/useJails";
/** Minimal `Jail` fixture. */
function makeJail(): Jail {
return {
name: "sshd",
running: true,
idle: false,
backend: "systemd",
log_paths: ["/var/log/auth.log"],
fail_regex: ["^Failed .+ from <HOST>"],
ignore_regex: [],
date_pattern: "",
log_encoding: "UTF-8",
actions: ["iptables-multiport"],
find_time: 600,
ban_time: 3600,
max_retry: 5,
status: {
currently_banned: 2,
total_banned: 10,
currently_failed: 0,
total_failed: 50,
},
bantime_escalation: null,
};
}
/** Wire `useJailDetail` to return the given `ignoreSelf` value. */
function mockHook(ignoreSelf: boolean): void {
const result: UseJailDetailResult = {
jail: makeJail(),
ignoreList: ["10.0.0.0/8"],
ignoreSelf,
loading: false,
error: null,
refresh: mockRefresh,
addIp: mockAddIp,
removeIp: mockRemoveIp,
toggleIgnoreSelf: mockToggleIgnoreSelf,
};
vi.mocked(useJailDetail).mockReturnValue(result);
}
/** Render the JailDetailPage with a fake `/jails/sshd` route. */
function renderPage() {
return render(
<MemoryRouter initialEntries={["/jails/sshd"]}>
<FluentProvider theme={webLightTheme}>
<Routes>
<Route path="/jails/:name" element={<JailDetailPage />} />
</Routes>
</FluentProvider>
</MemoryRouter>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("JailDetailPage — ignore self toggle", () => {
beforeEach(() => {
vi.clearAllMocks();
mockToggleIgnoreSelf.mockResolvedValue(undefined);
});
it("renders the switch checked when ignoreSelf is true", async () => {
mockHook(true);
renderPage();
const switchEl = await screen.findByRole("switch", { name: /ignore self/i });
expect(switchEl).toBeChecked();
});
it("renders the switch unchecked when ignoreSelf is false", async () => {
mockHook(false);
renderPage();
const switchEl = await screen.findByRole("switch", { name: /ignore self/i });
expect(switchEl).not.toBeChecked();
});
it("calls toggleIgnoreSelf(false) when switch is toggled off", async () => {
mockHook(true);
renderPage();
const user = userEvent.setup();
const switchEl = await screen.findByRole("switch", { name: /ignore self/i });
await user.click(switchEl);
await waitFor(() => {
expect(mockToggleIgnoreSelf).toHaveBeenCalledOnce();
expect(mockToggleIgnoreSelf).toHaveBeenCalledWith(false);
});
});
it("calls toggleIgnoreSelf(true) when switch is toggled on", async () => {
mockHook(false);
renderPage();
const user = userEvent.setup();
const switchEl = await screen.findByRole("switch", { name: /ignore self/i });
await user.click(switchEl);
await waitFor(() => {
expect(mockToggleIgnoreSelf).toHaveBeenCalledOnce();
expect(mockToggleIgnoreSelf).toHaveBeenCalledWith(true);
});
});
it("shows an error message bar when toggleIgnoreSelf rejects", async () => {
mockHook(false);
mockToggleIgnoreSelf.mockRejectedValue(new Error("Connection refused"));
renderPage();
const user = userEvent.setup();
const switchEl = await screen.findByRole("switch", { name: /ignore self/i });
await user.click(switchEl);
await waitFor(() => {
expect(screen.getByText(/Connection refused/i)).toBeInTheDocument();
});
});
});