feat(stage-1): inactive jail discovery and activation
- Backend: config_file_service.py parses jail.conf/jail.local/jail.d/*
following fail2ban merge order; discovers jails not running in fail2ban
- Backend: 3 new API endpoints (GET /jails/inactive, POST /jails/{name}/activate,
POST /jails/{name}/deactivate); moved /jails/inactive before /jails/{name}
to fix route-ordering conflict
- Frontend: ActivateJailDialog component with optional parameter overrides
- Frontend: JailsTab extended with inactive jail list and InactiveJailDetail pane
- Frontend: JailsPage JailOverviewSection shows inactive jails with toggle
- Tests: 57 service tests + 16 router tests for all new endpoints (all pass)
- Docs: Features.md, Architekture.md, Tasks.md updated; Tasks 1.1-1.5 marked done
This commit is contained in:
@@ -7,6 +7,7 @@ import { ENDPOINTS } from "./endpoints";
|
||||
import type {
|
||||
ActionConfig,
|
||||
ActionConfigUpdate,
|
||||
ActivateJailRequest,
|
||||
AddLogPathRequest,
|
||||
ConfFileContent,
|
||||
ConfFileCreateRequest,
|
||||
@@ -16,6 +17,8 @@ import type {
|
||||
FilterConfigUpdate,
|
||||
GlobalConfig,
|
||||
GlobalConfigUpdate,
|
||||
InactiveJailListResponse,
|
||||
JailActivationResponse,
|
||||
JailConfigFileContent,
|
||||
JailConfigFileEnabledUpdate,
|
||||
JailConfigFilesResponse,
|
||||
@@ -290,3 +293,38 @@ export async function updateParsedJailFile(
|
||||
): Promise<void> {
|
||||
await put<undefined>(ENDPOINTS.configJailFileParsed(filename), update);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inactive jails (Stage 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Fetch all inactive jails from config files. */
|
||||
export async function fetchInactiveJails(): Promise<InactiveJailListResponse> {
|
||||
return get<InactiveJailListResponse>(ENDPOINTS.configJailsInactive);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate an inactive jail, optionally providing override values.
|
||||
*
|
||||
* @param name - The jail name.
|
||||
* @param overrides - Optional parameter overrides (bantime, findtime, etc.).
|
||||
*/
|
||||
export async function activateJail(
|
||||
name: string,
|
||||
overrides?: ActivateJailRequest
|
||||
): Promise<JailActivationResponse> {
|
||||
return post<JailActivationResponse>(
|
||||
ENDPOINTS.configJailActivate(name),
|
||||
overrides ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
/** Deactivate an active jail. */
|
||||
export async function deactivateJail(
|
||||
name: string
|
||||
): Promise<JailActivationResponse> {
|
||||
return post<JailActivationResponse>(
|
||||
ENDPOINTS.configJailDeactivate(name),
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,9 +61,14 @@ export const ENDPOINTS = {
|
||||
// Configuration
|
||||
// -------------------------------------------------------------------------
|
||||
configJails: "/config/jails",
|
||||
configJailsInactive: "/config/jails/inactive",
|
||||
configJail: (name: string): string => `/config/jails/${encodeURIComponent(name)}`,
|
||||
configJailLogPath: (name: string): string =>
|
||||
`/config/jails/${encodeURIComponent(name)}/logpath`,
|
||||
configJailActivate: (name: string): string =>
|
||||
`/config/jails/${encodeURIComponent(name)}/activate`,
|
||||
configJailDeactivate: (name: string): string =>
|
||||
`/config/jails/${encodeURIComponent(name)}/deactivate`,
|
||||
configGlobal: "/config/global",
|
||||
configReload: "/config/reload",
|
||||
configRegexTest: "/config/regex-test",
|
||||
|
||||
240
frontend/src/components/config/ActivateJailDialog.tsx
Normal file
240
frontend/src/components/config/ActivateJailDialog.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* ActivateJailDialog — confirmation dialog for activating an inactive jail.
|
||||
*
|
||||
* Displays the jail name and provides optional override fields for bantime,
|
||||
* findtime, maxretry, port and logpath. Calls the activate endpoint on
|
||||
* confirmation and propagates the result via callbacks.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogSurface,
|
||||
DialogTitle,
|
||||
Field,
|
||||
Input,
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Spinner,
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { activateJail } from "../../api/config";
|
||||
import type { ActivateJailRequest, InactiveJail } from "../../types/config";
|
||||
import { ApiError } from "../../api/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ActivateJailDialogProps {
|
||||
/** The inactive jail to activate, or null when the dialog is closed. */
|
||||
jail: InactiveJail | null;
|
||||
/** Whether the dialog is currently open. */
|
||||
open: boolean;
|
||||
/** Called when the dialog should be closed without taking action. */
|
||||
onClose: () => void;
|
||||
/** Called after the jail has been successfully activated. */
|
||||
onActivated: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Confirmation dialog for activating an inactive jail.
|
||||
*
|
||||
* All override fields are optional — leaving them blank uses the values
|
||||
* already in the config files.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function ActivateJailDialog({
|
||||
jail,
|
||||
open,
|
||||
onClose,
|
||||
onActivated,
|
||||
}: ActivateJailDialogProps): React.JSX.Element {
|
||||
const [bantime, setBantime] = useState("");
|
||||
const [findtime, setFindtime] = useState("");
|
||||
const [maxretry, setMaxretry] = useState("");
|
||||
const [port, setPort] = useState("");
|
||||
const [logpath, setLogpath] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const resetForm = (): void => {
|
||||
setBantime("");
|
||||
setFindtime("");
|
||||
setMaxretry("");
|
||||
setPort("");
|
||||
setLogpath("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
if (submitting) return;
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleConfirm = (): void => {
|
||||
if (!jail || submitting) return;
|
||||
|
||||
const overrides: ActivateJailRequest = {};
|
||||
if (bantime.trim()) overrides.bantime = bantime.trim();
|
||||
if (findtime.trim()) overrides.findtime = findtime.trim();
|
||||
if (maxretry.trim()) {
|
||||
const n = parseInt(maxretry.trim(), 10);
|
||||
if (!isNaN(n)) overrides.maxretry = n;
|
||||
}
|
||||
if (port.trim()) overrides.port = port.trim();
|
||||
if (logpath.trim()) {
|
||||
overrides.logpath = logpath
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
activateJail(jail.name, overrides)
|
||||
.then(() => {
|
||||
resetForm();
|
||||
onActivated();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setError(msg);
|
||||
})
|
||||
.finally(() => {
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (!jail) return <></>;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(_ev, data) => { if (!data.open) handleClose(); }}>
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
<DialogTitle>Activate jail “{jail.name}”</DialogTitle>
|
||||
<DialogContent>
|
||||
<Text block style={{ marginBottom: tokens.spacingVerticalM }}>
|
||||
This will write <code>enabled = true</code> to{" "}
|
||||
<code>jail.d/{jail.name}.local</code> and reload fail2ban. The
|
||||
jail will start monitoring immediately.
|
||||
</Text>
|
||||
<Text block weight="semibold" style={{ marginBottom: tokens.spacingVerticalS }}>
|
||||
Override values (leave blank to use config defaults)
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: tokens.spacingVerticalS,
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
label="Ban time"
|
||||
hint={jail.bantime ? `Current: ${jail.bantime}` : undefined}
|
||||
>
|
||||
<Input
|
||||
placeholder={jail.bantime ?? "e.g. 10m"}
|
||||
value={bantime}
|
||||
disabled={submitting}
|
||||
onChange={(_e, d) => { setBantime(d.value); }}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Find time"
|
||||
hint={jail.findtime ? `Current: ${jail.findtime}` : undefined}
|
||||
>
|
||||
<Input
|
||||
placeholder={jail.findtime ?? "e.g. 10m"}
|
||||
value={findtime}
|
||||
disabled={submitting}
|
||||
onChange={(_e, d) => { setFindtime(d.value); }}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Max retry"
|
||||
hint={jail.maxretry != null ? `Current: ${String(jail.maxretry)}` : undefined}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={jail.maxretry != null ? String(jail.maxretry) : "e.g. 5"}
|
||||
value={maxretry}
|
||||
disabled={submitting}
|
||||
onChange={(_e, d) => { setMaxretry(d.value); }}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Port"
|
||||
hint={jail.port ? `Current: ${jail.port}` : undefined}
|
||||
>
|
||||
<Input
|
||||
placeholder={jail.port ?? "e.g. ssh"}
|
||||
value={port}
|
||||
disabled={submitting}
|
||||
onChange={(_e, d) => { setPort(d.value); }}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field
|
||||
label="Log path(s)"
|
||||
hint="One path per line; leave blank to use config defaults."
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
>
|
||||
<Input
|
||||
placeholder={
|
||||
jail.logpath.length > 0 ? jail.logpath[0] : "/var/log/example.log"
|
||||
}
|
||||
value={logpath}
|
||||
disabled={submitting}
|
||||
onChange={(_e, d) => { setLogpath(d.value); }}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<MessageBar
|
||||
intent="error"
|
||||
style={{ marginTop: tokens.spacingVerticalS }}
|
||||
>
|
||||
<MessageBarBody>{error}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
appearance="primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={submitting}
|
||||
icon={submitting ? <Spinner size="tiny" /> : undefined}
|
||||
>
|
||||
{submitting ? "Activating…" : "Activate"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* raw-config editor.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -25,23 +25,29 @@ import {
|
||||
ArrowClockwise24Regular,
|
||||
Dismiss24Regular,
|
||||
LockClosed24Regular,
|
||||
LockOpen24Regular,
|
||||
Play24Regular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { ApiError } from "../../api/client";
|
||||
import {
|
||||
addLogPath,
|
||||
deactivateJail,
|
||||
deleteLogPath,
|
||||
fetchInactiveJails,
|
||||
fetchJailConfigFileContent,
|
||||
updateJailConfigFile,
|
||||
} from "../../api/config";
|
||||
import type {
|
||||
AddLogPathRequest,
|
||||
ConfFileUpdateRequest,
|
||||
InactiveJail,
|
||||
JailConfig,
|
||||
JailConfigUpdate,
|
||||
} from "../../types/config";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
|
||||
import { useJailConfigs } from "../../hooks/useConfig";
|
||||
import { ActivateJailDialog } from "./ActivateJailDialog";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { ConfigListDetail } from "./ConfigListDetail";
|
||||
import { RawConfigSection } from "./RawConfigSection";
|
||||
@@ -55,6 +61,7 @@ import { useConfigStyles } from "./configStyles";
|
||||
interface JailConfigDetailProps {
|
||||
jail: JailConfig;
|
||||
onSave: (name: string, update: JailConfigUpdate) => Promise<void>;
|
||||
onDeactivate?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,6 +76,7 @@ interface JailConfigDetailProps {
|
||||
function JailConfigDetail({
|
||||
jail,
|
||||
onSave,
|
||||
onDeactivate,
|
||||
}: JailConfigDetailProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [banTime, setBanTime] = useState(String(jail.ban_time));
|
||||
@@ -443,6 +451,18 @@ function JailConfigDetail({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{onDeactivate !== undefined && (
|
||||
<div style={{ marginTop: tokens.spacingVerticalM }}>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<LockOpen24Regular />}
|
||||
onClick={onDeactivate}
|
||||
>
|
||||
Deactivate Jail
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw Configuration */}
|
||||
<div style={{ marginTop: tokens.spacingVerticalL }}>
|
||||
<RawConfigSection
|
||||
@@ -455,6 +475,102 @@ function JailConfigDetail({
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InactiveJailDetail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface InactiveJailDetailProps {
|
||||
jail: InactiveJail;
|
||||
onActivate: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only detail view for an inactive jail, with an Activate button.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns JSX element.
|
||||
*/
|
||||
function InactiveJailDetail({
|
||||
jail,
|
||||
onActivate,
|
||||
}: InactiveJailDetailProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
|
||||
return (
|
||||
<div className={styles.detailPane}>
|
||||
<Text weight="semibold" size={500} block style={{ marginBottom: tokens.spacingVerticalM }}>
|
||||
{jail.name}
|
||||
</Text>
|
||||
|
||||
<div className={styles.fieldRow}>
|
||||
<Field label="Filter">
|
||||
<Input readOnly value={jail.filter || "(none)"} className={styles.codeFont} />
|
||||
</Field>
|
||||
<Field label="Port">
|
||||
<Input readOnly value={jail.port ?? "(auto)"} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldRowThree}>
|
||||
<Field label="Ban time">
|
||||
<Input readOnly value={jail.bantime ?? "(default)"} />
|
||||
</Field>
|
||||
<Field label="Find time">
|
||||
<Input readOnly value={jail.findtime ?? "(default)"} />
|
||||
</Field>
|
||||
<Field label="Max retry">
|
||||
<Input readOnly value={jail.maxretry != null ? String(jail.maxretry) : "(default)"} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Log path(s)">
|
||||
{jail.logpath.length === 0 ? (
|
||||
<Text size={200} className={styles.infoText}>(none)</Text>
|
||||
) : (
|
||||
<div>
|
||||
{jail.logpath.map((p) => (
|
||||
<Text key={p} block size={200} className={styles.codeFont}>
|
||||
{p}
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{jail.actions.length > 0 && (
|
||||
<Field label="Actions">
|
||||
<div>
|
||||
{jail.actions.map((a) => (
|
||||
<Badge
|
||||
key={a}
|
||||
appearance="tint"
|
||||
color="informative"
|
||||
style={{ marginRight: tokens.spacingHorizontalXS }}
|
||||
>
|
||||
{a}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Source file">
|
||||
<Input readOnly value={jail.source_file} className={styles.codeFont} />
|
||||
</Field>
|
||||
|
||||
<div style={{ marginTop: tokens.spacingVerticalL }}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<Play24Regular />}
|
||||
onClick={onActivate}
|
||||
>
|
||||
Activate Jail
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JailsTab
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -474,20 +590,99 @@ export function JailsTab(): React.JSX.Element {
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const [reloadMsg, setReloadMsg] = useState<string | null>(null);
|
||||
|
||||
// Inactive jails
|
||||
const [inactiveJails, setInactiveJails] = useState<InactiveJail[]>([]);
|
||||
const [inactiveLoading, setInactiveLoading] = useState(false);
|
||||
const [activateTarget, setActivateTarget] = useState<InactiveJail | null>(null);
|
||||
const [deactivating, setDeactivating] = useState(false);
|
||||
|
||||
const loadInactive = useCallback((): void => {
|
||||
setInactiveLoading(true);
|
||||
fetchInactiveJails()
|
||||
.then((res) => { setInactiveJails(res.jails); })
|
||||
.catch(() => { /* non-critical — active-only view still works */ })
|
||||
.finally(() => { setInactiveLoading(false); });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadInactive();
|
||||
}, [loadInactive]);
|
||||
|
||||
const handleRefresh = useCallback((): void => {
|
||||
refresh();
|
||||
loadInactive();
|
||||
}, [refresh, loadInactive]);
|
||||
|
||||
const handleReload = useCallback(async () => {
|
||||
setReloading(true);
|
||||
setReloadMsg(null);
|
||||
try {
|
||||
await reloadAll();
|
||||
setReloadMsg("fail2ban reloaded.");
|
||||
refresh();
|
||||
loadInactive();
|
||||
} catch (err: unknown) {
|
||||
setReloadMsg(err instanceof ApiError ? err.message : "Reload failed.");
|
||||
} finally {
|
||||
setReloading(false);
|
||||
}
|
||||
}, [reloadAll]);
|
||||
}, [reloadAll, refresh, loadInactive]);
|
||||
|
||||
if (loading) {
|
||||
const handleDeactivate = useCallback((name: string): void => {
|
||||
setDeactivating(true);
|
||||
setReloadMsg(null);
|
||||
deactivateJail(name)
|
||||
.then(() => {
|
||||
setReloadMsg(`Jail "${name}" deactivated.`);
|
||||
setSelectedName(null);
|
||||
refresh();
|
||||
loadInactive();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setReloadMsg(
|
||||
err instanceof ApiError ? err.message : "Deactivation failed."
|
||||
);
|
||||
})
|
||||
.finally(() => { setDeactivating(false); });
|
||||
}, [refresh, loadInactive]);
|
||||
|
||||
const handleActivated = useCallback((): void => {
|
||||
setActivateTarget(null);
|
||||
setSelectedName(null);
|
||||
refresh();
|
||||
loadInactive();
|
||||
}, [refresh, loadInactive]);
|
||||
|
||||
/** Unified list items: active jails first (from useJailConfigs), then inactive. */
|
||||
const listItems = useMemo<Array<{ name: string; kind: "active" | "inactive" }>>(() => {
|
||||
const activeItems = jails.map((j) => ({ name: j.name, kind: "active" as const }));
|
||||
const activeNames = new Set(jails.map((j) => j.name));
|
||||
const inactiveItems = inactiveJails
|
||||
.filter((j) => !activeNames.has(j.name))
|
||||
.map((j) => ({ name: j.name, kind: "inactive" as const }));
|
||||
return [...activeItems, ...inactiveItems];
|
||||
}, [jails, inactiveJails]);
|
||||
|
||||
const activeJailMap = useMemo(
|
||||
() => new Map(jails.map((j) => [j.name, j])),
|
||||
[jails],
|
||||
);
|
||||
const inactiveJailMap = useMemo(
|
||||
() => new Map(inactiveJails.map((j) => [j.name, j])),
|
||||
[inactiveJails],
|
||||
);
|
||||
|
||||
const selectedListItem = listItems.find((item) => item.name === selectedName);
|
||||
const selectedActiveJail =
|
||||
selectedListItem?.kind === "active"
|
||||
? activeJailMap.get(selectedListItem.name)
|
||||
: undefined;
|
||||
const selectedInactiveJail =
|
||||
selectedListItem?.kind === "inactive"
|
||||
? inactiveJailMap.get(selectedListItem.name)
|
||||
: undefined;
|
||||
|
||||
if (loading && listItems.length === 0) {
|
||||
return (
|
||||
<Skeleton aria-label="Loading jail configs…">
|
||||
{[0, 1, 2].map((i) => (
|
||||
@@ -505,7 +700,7 @@ export function JailsTab(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
if (jails.length === 0) {
|
||||
if (listItems.length === 0 && !loading && !inactiveLoading) {
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<LockClosed24Regular
|
||||
@@ -513,7 +708,7 @@ export function JailsTab(): React.JSX.Element {
|
||||
aria-hidden
|
||||
/>
|
||||
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
No active jails found.
|
||||
No jails found.
|
||||
</Text>
|
||||
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
||||
Ensure fail2ban is running and jails are configured.
|
||||
@@ -522,24 +717,20 @@ export function JailsTab(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const selectedJail: JailConfig | undefined = jails.find(
|
||||
(j) => j.name === selectedName,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.tabContent}>
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<ArrowClockwise24Regular />}
|
||||
onClick={refresh}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
icon={<ArrowClockwise24Regular />}
|
||||
disabled={reloading}
|
||||
disabled={reloading || deactivating}
|
||||
onClick={() => void handleReload()}
|
||||
>
|
||||
{reloading ? "Reloading…" : "Reload fail2ban"}
|
||||
@@ -556,18 +747,34 @@ export function JailsTab(): React.JSX.Element {
|
||||
|
||||
<div style={{ marginTop: tokens.spacingVerticalM }}>
|
||||
<ConfigListDetail
|
||||
items={jails}
|
||||
isActive={(jail) => activeJails.has(jail.name)}
|
||||
items={listItems}
|
||||
isActive={(item) => item.kind === "active" && activeJails.has(item.name)}
|
||||
selectedName={selectedName}
|
||||
onSelect={setSelectedName}
|
||||
loading={false}
|
||||
error={null}
|
||||
>
|
||||
{selectedJail !== undefined ? (
|
||||
<JailConfigDetail jail={selectedJail} onSave={updateJail} />
|
||||
{selectedActiveJail !== undefined ? (
|
||||
<JailConfigDetail
|
||||
jail={selectedActiveJail}
|
||||
onSave={updateJail}
|
||||
onDeactivate={() => { handleDeactivate(selectedActiveJail.name); }}
|
||||
/>
|
||||
) : selectedInactiveJail !== undefined ? (
|
||||
<InactiveJailDetail
|
||||
jail={selectedInactiveJail}
|
||||
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
|
||||
/>
|
||||
) : null}
|
||||
</ConfigListDetail>
|
||||
</div>
|
||||
|
||||
<ActivateJailDialog
|
||||
jail={activateTarget}
|
||||
open={activateTarget !== null}
|
||||
onClose={() => { setActivateTarget(null); }}
|
||||
onActivated={handleActivated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
export { ActionsTab } from "./ActionsTab";
|
||||
export { ActionForm } from "./ActionForm";
|
||||
export type { ActionFormProps } from "./ActionForm";
|
||||
export { ActivateJailDialog } from "./ActivateJailDialog";
|
||||
export type { ActivateJailDialogProps } from "./ActivateJailDialog";
|
||||
export { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
export type { AutoSaveStatus, AutoSaveIndicatorProps } from "./AutoSaveIndicator";
|
||||
export { ConfFilesTab } from "./ConfFilesTab";
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* geo-location details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
MessageBarBody,
|
||||
Select,
|
||||
Spinner,
|
||||
Switch,
|
||||
Text,
|
||||
Tooltip,
|
||||
makeStyles,
|
||||
@@ -52,8 +53,11 @@ import {
|
||||
StopRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { Link } from "react-router-dom";
|
||||
import { fetchInactiveJails } from "../api/config";
|
||||
import { ActivateJailDialog } from "../components/config";
|
||||
import { useActiveBans, useIpLookup, useJails } from "../hooks/useJails";
|
||||
import type { ActiveBan, JailSummary } from "../types/jail";
|
||||
import type { InactiveJail } from "../types/config";
|
||||
import { ApiError } from "../api/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -319,6 +323,25 @@ function JailOverviewSection(): React.JSX.Element {
|
||||
const { jails, total, loading, error, refresh, startJail, stopJail, setIdle, reloadJail, reloadAll } =
|
||||
useJails();
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
const [showInactive, setShowInactive] = useState(true);
|
||||
const [inactiveJails, setInactiveJails] = useState<InactiveJail[]>([]);
|
||||
const [activateTarget, setActivateTarget] = useState<InactiveJail | null>(null);
|
||||
|
||||
const loadInactive = useCallback((): void => {
|
||||
fetchInactiveJails()
|
||||
.then((res) => { setInactiveJails(res.jails); })
|
||||
.catch(() => { /* non-critical */ });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadInactive();
|
||||
}, [loadInactive]);
|
||||
|
||||
const handleActivated = useCallback((): void => {
|
||||
setActivateTarget(null);
|
||||
refresh();
|
||||
loadInactive();
|
||||
}, [refresh, loadInactive]);
|
||||
|
||||
const handle = (fn: () => Promise<void>): void => {
|
||||
setOpError(null);
|
||||
@@ -327,6 +350,9 @@ function JailOverviewSection(): React.JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
const activeNameSet = new Set(jails.map((j) => j.name));
|
||||
const inactiveToShow = inactiveJails.filter((j) => !activeNameSet.has(j.name));
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
@@ -339,13 +365,16 @@ function JailOverviewSection(): React.JSX.Element {
|
||||
)}
|
||||
</Text>
|
||||
<div className={styles.actionRow}>
|
||||
<Switch
|
||||
label="Show inactive"
|
||||
checked={showInactive}
|
||||
onChange={(_e, d) => { setShowInactive(d.checked); }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowSyncRegular />}
|
||||
onClick={() => {
|
||||
handle(reloadAll);
|
||||
}}
|
||||
onClick={() => { handle(reloadAll); }}
|
||||
>
|
||||
Reload All
|
||||
</Button>
|
||||
@@ -451,6 +480,86 @@ function JailOverviewSection(): React.JSX.Element {
|
||||
</DataGrid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inactive jails table */}
|
||||
{showInactive && inactiveToShow.length > 0 && (
|
||||
<div style={{ marginTop: tokens.spacingVerticalM }}>
|
||||
<Text
|
||||
size={300}
|
||||
weight="semibold"
|
||||
style={{ color: tokens.colorNeutralForeground3, marginBottom: tokens.spacingVerticalXS }}
|
||||
block
|
||||
>
|
||||
Inactive jails ({String(inactiveToShow.length)})
|
||||
</Text>
|
||||
<div className={styles.tableWrapper}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: tokens.fontSizeBase200 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: `1px solid ${tokens.colorNeutralStroke2}` }}>
|
||||
<th style={{ textAlign: "left", padding: "6px 8px", fontWeight: tokens.fontWeightSemibold }}>Jail</th>
|
||||
<th style={{ textAlign: "left", padding: "6px 8px", fontWeight: tokens.fontWeightSemibold }}>Status</th>
|
||||
<th style={{ textAlign: "left", padding: "6px 8px", fontWeight: tokens.fontWeightSemibold }}>Filter</th>
|
||||
<th style={{ textAlign: "left", padding: "6px 8px", fontWeight: tokens.fontWeightSemibold }}>Port</th>
|
||||
<th style={{ textAlign: "left", padding: "6px 8px" }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inactiveToShow.map((j) => (
|
||||
<tr
|
||||
key={j.name}
|
||||
style={{
|
||||
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: "6px 8px" }}>
|
||||
<Link
|
||||
to="/config"
|
||||
style={{
|
||||
fontFamily: "Consolas, 'Courier New', monospace",
|
||||
fontSize: "0.85rem",
|
||||
textDecoration: "none",
|
||||
color: tokens.colorBrandForeground1,
|
||||
}}
|
||||
>
|
||||
{j.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ padding: "6px 8px" }}>
|
||||
<Badge appearance="filled" color="subtle">inactive</Badge>
|
||||
</td>
|
||||
<td style={{ padding: "6px 8px" }}>
|
||||
<Text size={200} style={{ fontFamily: "Consolas, 'Courier New', monospace" }}>
|
||||
{j.filter || "—"}
|
||||
</Text>
|
||||
</td>
|
||||
<td style={{ padding: "6px 8px" }}>
|
||||
<Text size={200}>{j.port ?? "—"}</Text>
|
||||
</td>
|
||||
<td style={{ padding: "6px 8px" }}>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="primary"
|
||||
icon={<PlayRegular />}
|
||||
onClick={() => { setActivateTarget(j); }}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ActivateJailDialog
|
||||
jail={activateTarget}
|
||||
open={activateTarget !== null}
|
||||
onClose={() => { setActivateTarget(null); }}
|
||||
onActivated={handleActivated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -380,3 +380,60 @@ export interface JailFileConfig {
|
||||
export interface JailFileConfigUpdate {
|
||||
jails?: Record<string, JailSectionConfig> | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inactive jail models (Stage 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A jail discovered in fail2ban config files that is not currently active.
|
||||
*
|
||||
* Maps 1-to-1 with the backend ``InactiveJail`` Pydantic model.
|
||||
*/
|
||||
export interface InactiveJail {
|
||||
/** Jail name from the config section header. */
|
||||
name: string;
|
||||
/** Filter name (may include mode suffix, e.g. ``sshd[mode=normal]``). */
|
||||
filter: string;
|
||||
/** Action references listed in the config (raw strings). */
|
||||
actions: string[];
|
||||
/** Port(s) to monitor, or null. */
|
||||
port: string | null;
|
||||
/** Log file paths to monitor. */
|
||||
logpath: string[];
|
||||
/** Ban duration as a raw config string (e.g. ``"10m"``), or null. */
|
||||
bantime: string | null;
|
||||
/** Failure-counting window as a raw config string, or null. */
|
||||
findtime: string | null;
|
||||
/** Number of failures before a ban is issued, or null. */
|
||||
maxretry: number | null;
|
||||
/** Absolute path to the config file where this jail is defined. */
|
||||
source_file: string;
|
||||
/** Effective ``enabled`` value — always ``false`` for inactive jails. */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface InactiveJailListResponse {
|
||||
jails: InactiveJail[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional override values when activating an inactive jail.
|
||||
*/
|
||||
export interface ActivateJailRequest {
|
||||
bantime?: string | null;
|
||||
findtime?: string | null;
|
||||
maxretry?: number | null;
|
||||
port?: string | null;
|
||||
logpath?: string[] | null;
|
||||
}
|
||||
|
||||
export interface JailActivationResponse {
|
||||
/** Name of the affected jail. */
|
||||
name: string;
|
||||
/** New activation state: true after activate, false after deactivate. */
|
||||
active: boolean;
|
||||
/** Human-readable result message. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user