When useJailConfigs performs a background refresh, it may deliver an updated JailConfig object for an already-selected jail. Previously, JailConfigDetail would continue displaying stale locally-edited form values because the component only re-initialized on jail name changes (via the key prop), not on object identity changes. Added a useEffect that detects when the jail prop reference has changed (indicating a server refresh) and automatically resets all form fields to the new server state, but only if autoSave is idle and has no pending changes. This prevents accidentally overwriting external changes when the user saves, while still letting users continue editing unsaved changes without interruption. The implementation: - Tracks the last-synced jail object in a ref - Compares incoming jail reference to detect server updates - Checks autoSave status to ensure no pending saves - Verifies that current form state matches the old jail values - Resets all 20+ form fields when conditions are met Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1059 lines
35 KiB
TypeScript
1059 lines
35 KiB
TypeScript
/**
|
|
* JailsTab — list/detail layout for per-jail configuration editing.
|
|
*
|
|
* Left pane: jail names with Active/Inactive badges, sorted with active on
|
|
* top. Right pane: editable form for the selected jail plus a collapsible
|
|
* raw-config editor.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Combobox,
|
|
Field,
|
|
Input,
|
|
MessageBar,
|
|
MessageBarBody,
|
|
Option,
|
|
Select,
|
|
Skeleton,
|
|
SkeletonItem,
|
|
Spinner,
|
|
Switch,
|
|
Text,
|
|
tokens,
|
|
} from "@fluentui/react-components";
|
|
import {
|
|
Add24Regular,
|
|
Dismiss24Regular,
|
|
LockClosed24Regular,
|
|
LockOpen24Regular,
|
|
Play24Regular,
|
|
} from "@fluentui/react-icons";
|
|
import { ApiError } from "../../api/client";
|
|
import { handleFetchError } from "../../utils/fetchError";
|
|
import type {
|
|
AddLogPathRequest,
|
|
BackendType,
|
|
DNSMode,
|
|
InactiveJail,
|
|
JailConfig,
|
|
JailConfigUpdate,
|
|
JailValidationIssue,
|
|
JailValidationResult,
|
|
LogEncoding,
|
|
} from "../../types/config";
|
|
import { useAutoSave } from "../../hooks/useAutoSave";
|
|
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
|
|
import { useJailConfigs } from "../../hooks/useJailConfigs";
|
|
import { useJailAdmin } from "../../hooks/useJailAdmin";
|
|
import { useJailConfigOperations } from "../../hooks/useJailConfigOperations";
|
|
import { ActivateJailDialog } from "./ActivateJailDialog";
|
|
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
|
import { ConfigListDetail } from "./ConfigListDetail";
|
|
import { CreateJailDialog } from "./CreateJailDialog";
|
|
import { RawConfigSection } from "./RawConfigSection";
|
|
import { RegexList } from "./RegexList";
|
|
import { useConfigStyles } from "./configStyles";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const BACKENDS = [
|
|
{ value: "auto", label: "auto — pyinotify, then polling" },
|
|
{ value: "polling", label: "polling — standard polling algorithm" },
|
|
{ value: "pyinotify", label: "pyinotify — requires pyinotify library" },
|
|
{ value: "systemd", label: "systemd — uses systemd journal" },
|
|
{ value: "gamin", label: "gamin — legacy file alteration monitor" },
|
|
] as const;
|
|
|
|
const LOG_ENCODINGS = [
|
|
{ value: "auto", label: "auto — use system locale" },
|
|
{ value: "ascii", label: "ascii" },
|
|
{ value: "utf-8", label: "utf-8" },
|
|
{ value: "latin-1", label: "latin-1 (ISO 8859-1)" },
|
|
] as const;
|
|
|
|
const DATE_PATTERN_PRESETS = [
|
|
{ value: "", label: "auto-detect (leave blank)" },
|
|
{ value: "{^LN-BEG}", label: "{^LN-BEG} — line beginning" },
|
|
{ value: "%%Y-%%m-%%d %%H:%%M:%%S", label: "YYYY-MM-DD HH:MM:SS" },
|
|
{ value: "%%d/%%b/%%Y:%%H:%%M:%%S", label: "DD/Mon/YYYY:HH:MM:SS (Apache)" },
|
|
{ value: "%%b %%d %%H:%%M:%%S", label: "Mon DD HH:MM:SS (syslog)" },
|
|
{ value: "EPOCH", label: "EPOCH — Unix timestamp" },
|
|
{ value: "%%Y-%%m-%%dT%%H:%%M:%%S", label: "ISO 8601" },
|
|
{ value: "TAI64N", label: "TAI64N" },
|
|
] as const;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JailConfigDetail
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface JailConfigDetailProps {
|
|
jail: JailConfig;
|
|
onSave: (name: string, update: JailConfigUpdate) => Promise<void>;
|
|
onDeactivate?: () => void;
|
|
/** When true all fields are read-only and auto-save is suppressed. */
|
|
readOnly?: boolean;
|
|
/** When provided (and readOnly=true) shows an Activate Jail button. */
|
|
onActivate?: () => void;
|
|
/** When provided (and readOnly=true) shows a Validate Config button. */
|
|
onValidate?: () => void;
|
|
/** Whether validation is currently running (shows spinner on Validate button). */
|
|
validating?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Editable configuration form for a single fail2ban jail.
|
|
*
|
|
* Contains all config fields plus a collapsible raw-config editor at the
|
|
* bottom. Previously known as JailAccordionPanel.
|
|
*
|
|
* @param props - Component props.
|
|
* @returns JSX element.
|
|
*/
|
|
function JailConfigDetail({
|
|
jail,
|
|
onSave,
|
|
onDeactivate,
|
|
readOnly = false,
|
|
onActivate,
|
|
onValidate,
|
|
validating = false,
|
|
}: JailConfigDetailProps): React.JSX.Element {
|
|
const styles = useConfigStyles();
|
|
const [banTime, setBanTime] = useState(String(jail.ban_time));
|
|
const [findTime, setFindTime] = useState(String(jail.find_time));
|
|
const [maxRetry, setMaxRetry] = useState(String(jail.max_retry));
|
|
const [failRegex, setFailRegex] = useState<string[]>(jail.fail_regex);
|
|
const [ignoreRegex, setIgnoreRegex] = useState<string[]>(jail.ignore_regex);
|
|
const [logPaths, setLogPaths] = useState<string[]>(jail.log_paths);
|
|
const [datePattern, setDatePattern] = useState(jail.date_pattern ?? "");
|
|
const [dnsMode, setDnsMode] = useState<DNSMode>(jail.use_dns);
|
|
const [backend, setBackend] = useState<BackendType>(jail.backend);
|
|
const [logEncoding, setLogEncoding] = useState<LogEncoding>(jail.log_encoding);
|
|
const [prefRegex, setPrefRegex] = useState(jail.prefregex);
|
|
const [deletingPath, setDeletingPath] = useState<string | null>(null);
|
|
const [newLogPath, setNewLogPath] = useState("");
|
|
const [newLogPathTail, setNewLogPathTail] = useState(true);
|
|
const [addingLogPath, setAddingLogPath] = useState(false);
|
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
|
|
|
// Ban-time escalation state
|
|
const esc0 = jail.bantime_escalation;
|
|
const [escEnabled, setEscEnabled] = useState(esc0?.increment ?? false);
|
|
const [escFactor, setEscFactor] = useState(
|
|
esc0?.factor != null ? String(esc0.factor) : "",
|
|
);
|
|
const [escFormula, setEscFormula] = useState(esc0?.formula ?? "");
|
|
const [escMultipliers, setEscMultipliers] = useState(
|
|
esc0?.multipliers ?? "",
|
|
);
|
|
const [escMaxTime, setEscMaxTime] = useState(
|
|
esc0?.max_time != null ? String(esc0.max_time) : "",
|
|
);
|
|
const [escRndTime, setEscRndTime] = useState(
|
|
esc0?.rnd_time != null ? String(esc0.rnd_time) : "",
|
|
);
|
|
const [escOverallJails, setEscOverallJails] = useState(
|
|
esc0?.overall_jails ?? false,
|
|
);
|
|
|
|
const {
|
|
addLogPath: addLogPathToJail,
|
|
deleteLogPath: deleteJailLogPath,
|
|
fetchRawContent,
|
|
saveRawContent,
|
|
} = useJailConfigOperations(jail.name);
|
|
|
|
const handleDeleteLogPath = useCallback(
|
|
async (path: string) => {
|
|
setDeletingPath(path);
|
|
setMsg(null);
|
|
try {
|
|
await deleteJailLogPath(path);
|
|
setLogPaths((prev) => prev.filter((p) => p !== path));
|
|
setMsg({ text: `Removed log path: ${path}`, ok: true });
|
|
} catch (err: unknown) {
|
|
setMsg({
|
|
text: err instanceof ApiError ? err.message : "Delete failed.",
|
|
ok: false,
|
|
});
|
|
} finally {
|
|
setDeletingPath(null);
|
|
}
|
|
},
|
|
[deleteJailLogPath],
|
|
);
|
|
|
|
const handleAddLogPath = useCallback(async () => {
|
|
const trimmed = newLogPath.trim();
|
|
if (!trimmed) return;
|
|
setAddingLogPath(true);
|
|
setMsg(null);
|
|
try {
|
|
const req: AddLogPathRequest = { log_path: trimmed, tail: newLogPathTail };
|
|
await addLogPathToJail(req);
|
|
setLogPaths((prev) => [...prev, trimmed]);
|
|
setNewLogPath("");
|
|
setMsg({ text: `Added log path: ${trimmed}`, ok: true });
|
|
} catch (err: unknown) {
|
|
setMsg({
|
|
text: err instanceof ApiError ? err.message : "Failed to add log path.",
|
|
ok: false,
|
|
});
|
|
} finally {
|
|
setAddingLogPath(false);
|
|
}
|
|
}, [addLogPathToJail, newLogPath, newLogPathTail]);
|
|
|
|
const autoSavePayload = useMemo<JailConfigUpdate>(
|
|
() => ({
|
|
ban_time: banTime.trim() === "" || Number.isNaN(Number(banTime)) ? jail.ban_time : Number(banTime),
|
|
find_time: findTime.trim() === "" || Number.isNaN(Number(findTime)) ? jail.find_time : Number(findTime),
|
|
max_retry: maxRetry.trim() === "" || Number.isNaN(Number(maxRetry)) ? jail.max_retry : Number(maxRetry),
|
|
fail_regex: failRegex,
|
|
ignore_regex: ignoreRegex,
|
|
date_pattern: datePattern !== "" ? datePattern : null,
|
|
dns_mode: dnsMode,
|
|
log_encoding: logEncoding,
|
|
prefregex: prefRegex !== "" ? prefRegex : null,
|
|
bantime_escalation: {
|
|
increment: escEnabled,
|
|
factor: escFactor !== "" ? Number(escFactor) : null,
|
|
formula: escFormula !== "" ? escFormula : null,
|
|
multipliers: escMultipliers !== "" ? escMultipliers : null,
|
|
max_time: escMaxTime !== "" ? Number(escMaxTime) : null,
|
|
rnd_time: escRndTime !== "" ? Number(escRndTime) : null,
|
|
overall_jails: escOverallJails,
|
|
},
|
|
}),
|
|
[
|
|
banTime, findTime, maxRetry, failRegex, ignoreRegex, datePattern,
|
|
dnsMode, logEncoding, prefRegex, escEnabled, escFactor,
|
|
escFormula, escMultipliers, escMaxTime, escRndTime, escOverallJails,
|
|
jail.ban_time, jail.find_time, jail.max_retry,
|
|
],
|
|
);
|
|
|
|
const saveCurrent = useCallback(
|
|
async (update: JailConfigUpdate): Promise<void> => {
|
|
if (readOnly) return;
|
|
await onSave(jail.name, update);
|
|
},
|
|
[jail.name, onSave, readOnly],
|
|
);
|
|
|
|
const { status: saveStatus, errorText: saveErrorText, retry: retrySave } =
|
|
useAutoSave(autoSavePayload, saveCurrent);
|
|
|
|
// Ref to track the last jail object we synced from. When the incoming jail
|
|
// prop is a new object (indicating a server refresh), and autoSave is idle
|
|
// with no pending changes, we reset the form to the new server state.
|
|
const lastSyncedJailRef = useRef<JailConfig>(jail);
|
|
|
|
// Resync form fields when the jail prop changes identity (server refresh)
|
|
// but only if autoSave has completed and there are no pending changes.
|
|
useEffect(() => {
|
|
if (
|
|
lastSyncedJailRef.current !== jail &&
|
|
saveStatus === "idle" &&
|
|
!saveErrorText
|
|
) {
|
|
// Payload is equal to the server state, so we can safely reset.
|
|
if (
|
|
JSON.stringify(autoSavePayload) === JSON.stringify({
|
|
ban_time: lastSyncedJailRef.current.ban_time,
|
|
find_time: lastSyncedJailRef.current.find_time,
|
|
max_retry: lastSyncedJailRef.current.max_retry,
|
|
fail_regex: lastSyncedJailRef.current.fail_regex,
|
|
ignore_regex: lastSyncedJailRef.current.ignore_regex,
|
|
date_pattern: lastSyncedJailRef.current.date_pattern ?? null,
|
|
dns_mode: lastSyncedJailRef.current.use_dns,
|
|
log_encoding: lastSyncedJailRef.current.log_encoding,
|
|
prefregex: lastSyncedJailRef.current.prefregex,
|
|
bantime_escalation: {
|
|
increment: lastSyncedJailRef.current.bantime_escalation?.increment ?? false,
|
|
factor: lastSyncedJailRef.current.bantime_escalation?.factor ?? null,
|
|
formula: lastSyncedJailRef.current.bantime_escalation?.formula ?? null,
|
|
multipliers: lastSyncedJailRef.current.bantime_escalation?.multipliers ?? null,
|
|
max_time: lastSyncedJailRef.current.bantime_escalation?.max_time ?? null,
|
|
rnd_time: lastSyncedJailRef.current.bantime_escalation?.rnd_time ?? null,
|
|
overall_jails: lastSyncedJailRef.current.bantime_escalation?.overall_jails ?? false,
|
|
},
|
|
})
|
|
) {
|
|
// Reset all form state to new jail prop values.
|
|
setBanTime(String(jail.ban_time));
|
|
setFindTime(String(jail.find_time));
|
|
setMaxRetry(String(jail.max_retry));
|
|
setFailRegex(jail.fail_regex);
|
|
setIgnoreRegex(jail.ignore_regex);
|
|
setLogPaths(jail.log_paths);
|
|
setDatePattern(jail.date_pattern ?? "");
|
|
setDnsMode(jail.use_dns);
|
|
setBackend(jail.backend);
|
|
setLogEncoding(jail.log_encoding);
|
|
setPrefRegex(jail.prefregex);
|
|
setEscEnabled(jail.bantime_escalation?.increment ?? false);
|
|
setEscFactor(
|
|
jail.bantime_escalation?.factor != null
|
|
? String(jail.bantime_escalation.factor)
|
|
: "",
|
|
);
|
|
setEscFormula(jail.bantime_escalation?.formula ?? "");
|
|
setEscMultipliers(jail.bantime_escalation?.multipliers ?? "");
|
|
setEscMaxTime(
|
|
jail.bantime_escalation?.max_time != null
|
|
? String(jail.bantime_escalation.max_time)
|
|
: "",
|
|
);
|
|
setEscRndTime(
|
|
jail.bantime_escalation?.rnd_time != null
|
|
? String(jail.bantime_escalation.rnd_time)
|
|
: "",
|
|
);
|
|
setEscOverallJails(jail.bantime_escalation?.overall_jails ?? false);
|
|
}
|
|
lastSyncedJailRef.current = jail;
|
|
}
|
|
}, [jail, saveStatus, saveErrorText, autoSavePayload]);
|
|
|
|
// Raw config file fetch/save helpers — uses jail.d/<name>.conf convention.
|
|
const fetchRaw = useCallback(async (): Promise<string> => {
|
|
return await fetchRawContent();
|
|
}, [fetchRawContent]);
|
|
|
|
const saveRaw = useCallback(
|
|
async (content: string): Promise<void> => {
|
|
await saveRawContent(content);
|
|
},
|
|
[saveRawContent],
|
|
);
|
|
|
|
return (
|
|
<div>
|
|
{msg && (
|
|
<MessageBar intent={msg.ok ? "success" : "error"}>
|
|
<MessageBarBody>{msg.text}</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
<div className={styles.fieldRowThree}>
|
|
<Field label="Ban Time (s)">
|
|
<Input
|
|
type="number"
|
|
value={banTime}
|
|
readOnly={readOnly}
|
|
onChange={(_e, d) => {
|
|
setBanTime(d.value);
|
|
}}
|
|
/>
|
|
</Field>
|
|
<Field label="Find Time (s)">
|
|
<Input
|
|
type="number"
|
|
value={findTime}
|
|
readOnly={readOnly}
|
|
onChange={(_e, d) => {
|
|
setFindTime(d.value);
|
|
}}
|
|
/>
|
|
</Field>
|
|
<Field label="Max Retry">
|
|
<Input
|
|
type="number"
|
|
value={maxRetry}
|
|
readOnly={readOnly}
|
|
onChange={(_e, d) => {
|
|
setMaxRetry(d.value);
|
|
}}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<div className={styles.fieldRow}>
|
|
<Field label="Backend">
|
|
<Select
|
|
value={backend}
|
|
disabled={readOnly}
|
|
onChange={(_e, d) => {
|
|
setBackend(d.value as BackendType);
|
|
}}
|
|
>
|
|
{BACKENDS.map((b) => (
|
|
<option key={b.value} value={b.value}>{b.label}</option>
|
|
))}
|
|
</Select>
|
|
</Field>
|
|
<Field label="Log Encoding">
|
|
<Select
|
|
value={logEncoding.toLowerCase()}
|
|
disabled={readOnly}
|
|
onChange={(_e, d) => {
|
|
setLogEncoding(d.value as LogEncoding);
|
|
}}
|
|
>
|
|
{LOG_ENCODINGS.map((e) => (
|
|
<option key={e.value} value={e.value}>{e.label}</option>
|
|
))}
|
|
</Select>
|
|
</Field>
|
|
</div>
|
|
<div className={styles.fieldRow}>
|
|
<Field label="Date Pattern" hint="Leave blank for auto-detect.">
|
|
<Combobox
|
|
className={styles.codeFont}
|
|
placeholder="auto-detect"
|
|
value={datePattern}
|
|
selectedOptions={[datePattern]}
|
|
freeform
|
|
disabled={readOnly}
|
|
onOptionSelect={(_e, d) => {
|
|
setDatePattern(d.optionValue ?? "");
|
|
}}
|
|
onChange={(e) => {
|
|
setDatePattern(e.target.value);
|
|
}}
|
|
>
|
|
{DATE_PATTERN_PRESETS.map((p) => (
|
|
<Option key={p.value} value={p.value}>{p.label}</Option>
|
|
))}
|
|
</Combobox>
|
|
</Field>
|
|
<Field label="DNS Mode" hint={"\u00A0"}>
|
|
<Select
|
|
value={dnsMode}
|
|
disabled={readOnly}
|
|
onChange={(_e, d) => {
|
|
setDnsMode(d.value as DNSMode);
|
|
}}
|
|
>
|
|
<option value="yes">yes — resolve hostnames</option>
|
|
<option value="warn">warn — resolve and warn</option>
|
|
<option value="no">no — skip hostname resolution</option>
|
|
<option value="raw">raw — use value as-is</option>
|
|
</Select>
|
|
</Field>
|
|
</div>
|
|
<Field
|
|
label="Prefix Regex"
|
|
hint="Prepended to every failregex for pre-filtering. Leave blank to disable."
|
|
>
|
|
<Input
|
|
className={styles.codeFont}
|
|
placeholder="e.g. ^%(__prefix_line)s"
|
|
value={prefRegex}
|
|
readOnly={readOnly}
|
|
onChange={(_e, d) => {
|
|
setPrefRegex(d.value);
|
|
}}
|
|
/>
|
|
</Field>
|
|
<Field label="Log Paths">
|
|
{logPaths.length === 0 ? (
|
|
<Text className={styles.infoText} size={200}>
|
|
(none)
|
|
</Text>
|
|
) : (
|
|
logPaths.map((p, i) => (
|
|
<div key={p} className={styles.regexItem}>
|
|
<Input
|
|
className={styles.codeFont}
|
|
style={{ flexGrow: 1 }}
|
|
value={p}
|
|
readOnly={readOnly}
|
|
onChange={(_e, d) => {
|
|
setLogPaths((prev) => prev.map((v, j) => (j === i ? d.value : v)));
|
|
}}
|
|
/>
|
|
{!readOnly && (
|
|
<Button
|
|
appearance="subtle"
|
|
icon={<Dismiss24Regular />}
|
|
size="small"
|
|
disabled={deletingPath === p}
|
|
title="Remove log path"
|
|
onClick={() => void handleDeleteLogPath(p)}
|
|
/>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
{/* Add log path inline form — hidden in read-only mode */}
|
|
{!readOnly && (
|
|
<div className={styles.regexItem} style={{ marginTop: tokens.spacingVerticalXS }}>
|
|
<Input
|
|
className={styles.codeFont}
|
|
style={{ flexGrow: 1 }}
|
|
placeholder="/var/log/example.log"
|
|
value={newLogPath}
|
|
disabled={addingLogPath}
|
|
aria-label="New log path"
|
|
onChange={(_e, d) => {
|
|
setNewLogPath(d.value);
|
|
}}
|
|
/>
|
|
<Switch
|
|
label={newLogPathTail ? "tail" : "head"}
|
|
checked={newLogPathTail}
|
|
onChange={(_e, d) => {
|
|
setNewLogPathTail(d.checked);
|
|
}}
|
|
/>
|
|
<Button
|
|
appearance="primary"
|
|
size="small"
|
|
aria-label="Add log path"
|
|
disabled={addingLogPath || !newLogPath.trim()}
|
|
onClick={() => void handleAddLogPath()}
|
|
>
|
|
{addingLogPath ? "Adding…" : "Add"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</Field>
|
|
<div style={{ marginTop: tokens.spacingVerticalS }}>
|
|
<RegexList
|
|
label="Fail Regex"
|
|
patterns={failRegex}
|
|
onChange={setFailRegex}
|
|
readOnly={readOnly}
|
|
/>
|
|
</div>
|
|
<div style={{ marginTop: tokens.spacingVerticalS }}>
|
|
<RegexList
|
|
label="Ignore Regex"
|
|
patterns={ignoreRegex}
|
|
onChange={setIgnoreRegex}
|
|
readOnly={readOnly}
|
|
/>
|
|
</div>
|
|
{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>
|
|
)}
|
|
|
|
{/* Ban-time Escalation */}
|
|
<div style={{ marginTop: tokens.spacingVerticalM }}>
|
|
<Text weight="semibold" size={400} block>
|
|
Ban-time Escalation
|
|
</Text>
|
|
<Switch
|
|
label="Enable incremental banning"
|
|
checked={escEnabled}
|
|
disabled={readOnly}
|
|
onChange={(_e, d) => {
|
|
setEscEnabled(d.checked);
|
|
}}
|
|
/>
|
|
{escEnabled && (
|
|
<div>
|
|
<div className={styles.fieldRowThree}>
|
|
<Field label="Factor">
|
|
<Input
|
|
type="number"
|
|
value={escFactor}
|
|
readOnly={readOnly}
|
|
onChange={(_e, d) => {
|
|
setEscFactor(d.value);
|
|
}}
|
|
/>
|
|
</Field>
|
|
<Field label="Max Time (s)">
|
|
<Input
|
|
type="number"
|
|
value={escMaxTime}
|
|
readOnly={readOnly}
|
|
onChange={(_e, d) => {
|
|
setEscMaxTime(d.value);
|
|
}}
|
|
/>
|
|
</Field>
|
|
<Field label="Random Jitter (s)">
|
|
<Input
|
|
type="number"
|
|
value={escRndTime}
|
|
readOnly={readOnly}
|
|
onChange={(_e, d) => {
|
|
setEscRndTime(d.value);
|
|
}}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
<Field label="Formula">
|
|
<Input
|
|
value={escFormula}
|
|
readOnly={readOnly}
|
|
onChange={(_e, d) => {
|
|
setEscFormula(d.value);
|
|
}}
|
|
/>
|
|
</Field>
|
|
<Field label="Multipliers (space-separated)">
|
|
<Input
|
|
value={escMultipliers}
|
|
readOnly={readOnly}
|
|
onChange={(_e, d) => {
|
|
setEscMultipliers(d.value);
|
|
}}
|
|
/>
|
|
</Field>
|
|
<Switch
|
|
label="Count repeat offences across all jails"
|
|
checked={escOverallJails}
|
|
disabled={readOnly}
|
|
onChange={(_e, d) => {
|
|
setEscOverallJails(d.checked);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!readOnly && (
|
|
<div style={{ marginTop: tokens.spacingVerticalS }}>
|
|
<AutoSaveIndicator
|
|
status={saveStatus}
|
|
errorText={saveErrorText}
|
|
onRetry={retrySave}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{!readOnly && onDeactivate !== undefined && (
|
|
<div style={{ marginTop: tokens.spacingVerticalM }}>
|
|
<Button
|
|
appearance="secondary"
|
|
icon={<LockOpen24Regular />}
|
|
onClick={onDeactivate}
|
|
>
|
|
Deactivate Jail
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{readOnly && (onActivate !== undefined || onValidate !== undefined || onDeactivate !== undefined) && (
|
|
<div style={{ marginTop: tokens.spacingVerticalM, display: "flex", gap: tokens.spacingHorizontalS, flexWrap: "wrap" }}>
|
|
{onValidate !== undefined && (
|
|
<Button
|
|
appearance="secondary"
|
|
icon={validating ? <Spinner size="tiny" /> : undefined}
|
|
onClick={onValidate}
|
|
disabled={validating}
|
|
>
|
|
{validating ? "Validating…" : "Validate Config"}
|
|
</Button>
|
|
)}
|
|
{onDeactivate !== undefined && (
|
|
<Button
|
|
appearance="secondary"
|
|
icon={<LockOpen24Regular />}
|
|
onClick={onDeactivate}
|
|
>
|
|
Deactivate Jail
|
|
</Button>
|
|
)}
|
|
{onActivate !== undefined && (
|
|
<Button
|
|
appearance="primary"
|
|
icon={<Play24Regular />}
|
|
onClick={onActivate}
|
|
>
|
|
Activate Jail
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Raw Configuration — hidden in read-only (inactive jail) mode */}
|
|
{!readOnly && (
|
|
<div style={{ marginTop: tokens.spacingVerticalL }}>
|
|
<RawConfigSection
|
|
fetchContent={fetchRaw}
|
|
saveContent={saveRaw}
|
|
label="Raw Jail Configuration"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// InactiveJailDetail
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface InactiveJailDetailProps {
|
|
jail: InactiveJail;
|
|
onActivate: () => void;
|
|
/** Called when the user requests removal of the .local override file. */
|
|
onDeactivate?: () => void;
|
|
onValidate: () => Promise<JailValidationResult>;
|
|
}
|
|
|
|
/**
|
|
* Detail view for an inactive jail.
|
|
*
|
|
* Maps the parsed config fields to a JailConfig-compatible object and renders
|
|
* JailConfigDetail in read-only mode, so the UI is identical to the active
|
|
* jail view but with all fields disabled and an Activate button instead of
|
|
* a Deactivate button.
|
|
*
|
|
* @param props - Component props.
|
|
* @returns JSX element.
|
|
*/
|
|
function InactiveJailDetail({
|
|
jail,
|
|
onActivate,
|
|
onDeactivate,
|
|
onValidate,
|
|
}: InactiveJailDetailProps): React.JSX.Element {
|
|
const styles = useConfigStyles();
|
|
const [validating, setValidating] = useState(false);
|
|
const [validationResult, setValidationResult] = useState<JailValidationResult | null>(null);
|
|
const [validationError, setValidationError] = useState<string | null>(null);
|
|
|
|
const handleValidate = useCallback((): void => {
|
|
setValidating(true);
|
|
setValidationResult(null);
|
|
setValidationError(null);
|
|
onValidate()
|
|
.then((result) => { setValidationResult(result); })
|
|
.catch((err: unknown) => {
|
|
handleFetchError(err, setValidationError, "Validation request failed.");
|
|
})
|
|
.finally(() => { setValidating(false); });
|
|
}, [onValidate]);
|
|
|
|
const blockingIssues: JailValidationIssue[] =
|
|
validationResult?.issues.filter((i) => i.field !== "logpath") ?? [];
|
|
const advisoryIssues: JailValidationIssue[] =
|
|
validationResult?.issues.filter((i) => i.field === "logpath") ?? [];
|
|
|
|
const jailConfig = useMemo<JailConfig>(
|
|
() => ({
|
|
name: jail.name,
|
|
ban_time: jail.ban_time_seconds,
|
|
find_time: jail.find_time_seconds,
|
|
max_retry: jail.maxretry ?? 5,
|
|
fail_regex: jail.fail_regex,
|
|
ignore_regex: jail.ignore_regex,
|
|
log_paths: jail.logpath,
|
|
date_pattern: jail.date_pattern,
|
|
log_encoding: jail.log_encoding,
|
|
backend: jail.backend,
|
|
use_dns: jail.use_dns,
|
|
prefregex: jail.prefregex,
|
|
actions: jail.actions,
|
|
bantime_escalation: jail.bantime_escalation,
|
|
}),
|
|
[jail],
|
|
);
|
|
|
|
return (
|
|
<div>
|
|
<div className={styles.fieldRow} style={{ marginBottom: tokens.spacingVerticalS }}>
|
|
<Field label="Filter">
|
|
<Input readOnly value={jail.filter || "(none)"} className={styles.codeFont} />
|
|
</Field>
|
|
<Field label="Port">
|
|
<Input readOnly value={jail.port ?? "(auto)"} />
|
|
</Field>
|
|
</div>
|
|
<Field label="Source file" style={{ marginBottom: tokens.spacingVerticalM }}>
|
|
<Input readOnly value={jail.source_file} className={styles.codeFont} />
|
|
</Field>
|
|
|
|
{/* Validation error panel */}
|
|
{validationError !== null && (
|
|
<div style={{ marginBottom: tokens.spacingVerticalM }}>
|
|
<MessageBar intent="error">
|
|
<MessageBarBody>{validationError}</MessageBarBody>
|
|
</MessageBar>
|
|
</div>
|
|
)}
|
|
|
|
{/* Validation result panel */}
|
|
{validationResult !== null && validationError === null && (
|
|
<div style={{ marginBottom: tokens.spacingVerticalM }}>
|
|
{blockingIssues.length === 0 && advisoryIssues.length === 0 ? (
|
|
<MessageBar intent="success">
|
|
<MessageBarBody>Configuration is valid.</MessageBarBody>
|
|
</MessageBar>
|
|
) : null}
|
|
{blockingIssues.length > 0 && (
|
|
<MessageBar intent="error" style={{ marginBottom: tokens.spacingVerticalXS }}>
|
|
<MessageBarBody>
|
|
<strong>Errors:</strong>
|
|
<ul style={{ margin: `4px 0 0 0`, paddingLeft: "1.2em" }}>
|
|
{blockingIssues.map((issue, idx) => (
|
|
<li key={`${issue.field}-${String(idx)}`}><em>{issue.field}:</em> {issue.message}</li>
|
|
))}
|
|
</ul>
|
|
</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
{advisoryIssues.length > 0 && (
|
|
<MessageBar intent="warning">
|
|
<MessageBarBody>
|
|
<strong>Warnings:</strong>
|
|
<ul style={{ margin: `4px 0 0 0`, paddingLeft: "1.2em" }}>
|
|
{advisoryIssues.map((issue, idx) => (
|
|
<li key={`${issue.field}-${String(idx)}`}><em>{issue.field}:</em> {issue.message}</li>
|
|
))}
|
|
</ul>
|
|
</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<JailConfigDetail
|
|
jail={jailConfig}
|
|
onSave={async () => { /* read-only — never called */ }}
|
|
readOnly
|
|
onActivate={onActivate}
|
|
onDeactivate={jail.has_local_override ? onDeactivate : undefined}
|
|
onValidate={handleValidate}
|
|
validating={validating}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JailsTab
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Tab component showing all fail2ban jails in a list/detail layout with
|
|
* editable configuration forms.
|
|
*
|
|
* @returns JSX element.
|
|
*/
|
|
interface JailsTabProps {
|
|
/** Jail name to pre-select when the component mounts. */
|
|
initialJail?: string;
|
|
}
|
|
|
|
export function JailsTab({ initialJail }: JailsTabProps): React.JSX.Element {
|
|
const styles = useConfigStyles();
|
|
const { jails, loading, error, refresh, updateJail } =
|
|
useJailConfigs();
|
|
const { activeJails } = useConfigActiveStatus();
|
|
const {
|
|
inactiveJails,
|
|
inactiveLoading,
|
|
refreshInactiveJails,
|
|
deactivateJail,
|
|
deleteJailLocalOverride,
|
|
validateJailConfig,
|
|
activateJail,
|
|
createJailConfigFile,
|
|
} = useJailAdmin();
|
|
const [selectedName, setSelectedName] = useState<string | null>(null);
|
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
|
const [activateTarget, setActivateTarget] = useState<InactiveJail | null>(null);
|
|
|
|
const handleDeactivate = useCallback(
|
|
async (name: string): Promise<void> => {
|
|
try {
|
|
await deactivateJail(name);
|
|
setSelectedName(null);
|
|
refresh();
|
|
refreshInactiveJails();
|
|
} catch {
|
|
/* non-critical — list refreshes on next load */ }
|
|
},
|
|
[deactivateJail, refresh, refreshInactiveJails],
|
|
);
|
|
|
|
const handleDeactivateInactive = useCallback(
|
|
async (name: string): Promise<void> => {
|
|
try {
|
|
await deleteJailLocalOverride(name);
|
|
setSelectedName(null);
|
|
refreshInactiveJails();
|
|
} catch {
|
|
/* non-critical — list refreshes on next load */ }
|
|
},
|
|
[deleteJailLocalOverride, refreshInactiveJails],
|
|
);
|
|
|
|
const handleActivated = useCallback((): void => {
|
|
setActivateTarget(null);
|
|
setSelectedName(null);
|
|
refresh();
|
|
refreshInactiveJails();
|
|
}, [refresh, refreshInactiveJails]);
|
|
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (!initialJail || selectedName) return;
|
|
if (listItems.some((item) => item.name === initialJail)) {
|
|
setSelectedName(initialJail);
|
|
}
|
|
}, [initialJail, listItems, selectedName]);
|
|
|
|
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) => (
|
|
<SkeletonItem key={i} size={40} style={{ marginBottom: 4 }} />
|
|
))}
|
|
</Skeleton>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<MessageBar intent="error">
|
|
<MessageBarBody>{error}</MessageBarBody>
|
|
</MessageBar>
|
|
);
|
|
}
|
|
|
|
if (listItems.length === 0 && !loading && !inactiveLoading) {
|
|
return (
|
|
<div className={styles.emptyState}>
|
|
<LockClosed24Regular
|
|
style={{ fontSize: 48, color: tokens.colorNeutralForeground3 }}
|
|
aria-hidden
|
|
/>
|
|
<Text size={400} style={{ color: tokens.colorNeutralForeground3 }}>
|
|
No jails found.
|
|
</Text>
|
|
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
|
|
Ensure fail2ban is running and jails are configured.
|
|
</Text>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const listHeader = (
|
|
<Button
|
|
appearance="outline"
|
|
icon={<Add24Regular />}
|
|
size="small"
|
|
onClick={() => { setCreateDialogOpen(true); }}
|
|
>
|
|
Create Config
|
|
</Button>
|
|
);
|
|
|
|
return (
|
|
<div className={styles.tabContent}>
|
|
<div style={{ marginTop: tokens.spacingVerticalM }}>
|
|
<ConfigListDetail
|
|
items={listItems}
|
|
isActive={(item) => item.kind === "active" && activeJails.has(item.name)}
|
|
selectedName={selectedName}
|
|
onSelect={setSelectedName}
|
|
loading={false}
|
|
error={null}
|
|
listHeader={listHeader}
|
|
>
|
|
{selectedActiveJail !== undefined ? (
|
|
<JailConfigDetail
|
|
key={selectedActiveJail.name}
|
|
jail={selectedActiveJail}
|
|
onSave={updateJail}
|
|
onDeactivate={() => { void handleDeactivate(selectedActiveJail.name); }}
|
|
/>
|
|
) : selectedInactiveJail !== undefined ? (
|
|
<InactiveJailDetail
|
|
key={selectedInactiveJail.name}
|
|
jail={selectedInactiveJail}
|
|
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
|
|
onDeactivate={
|
|
selectedInactiveJail.has_local_override
|
|
? (): void => { void handleDeactivateInactive(selectedInactiveJail.name); }
|
|
: undefined
|
|
}
|
|
onValidate={async () => validateJailConfig(selectedInactiveJail.name)}
|
|
/>
|
|
) : null}
|
|
</ConfigListDetail>
|
|
</div>
|
|
|
|
<ActivateJailDialog
|
|
jail={activateTarget}
|
|
open={activateTarget !== null}
|
|
onClose={() => { setActivateTarget(null); }}
|
|
onActivated={handleActivated}
|
|
onValidate={async () => {
|
|
if (!activateTarget) {
|
|
return { jail_name: "", valid: false, issues: [] };
|
|
}
|
|
return await validateJailConfig(activateTarget.name);
|
|
}}
|
|
onActivate={async (payload) => {
|
|
if (!activateTarget) {
|
|
return {
|
|
name: "",
|
|
active: false,
|
|
message: "No jail selected.",
|
|
fail2ban_running: false,
|
|
validation_warnings: [],
|
|
};
|
|
}
|
|
return await activateJail(activateTarget.name, payload);
|
|
}}
|
|
/>
|
|
|
|
<CreateJailDialog
|
|
open={createDialogOpen}
|
|
onClose={() => { setCreateDialogOpen(false); }}
|
|
onCreateJail={async (payload) => {
|
|
await createJailConfigFile(payload);
|
|
}}
|
|
onCreated={() => {
|
|
setCreateDialogOpen(false);
|
|
refresh();
|
|
refreshInactiveJails();
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|