- Backend: extend activate_jail() with pre-validation and 4-attempt post-reload
health probe; add validate_jail_config() and rollback_jail() service functions
- Backend: new endpoints POST /api/config/jails/{name}/validate,
GET /api/config/pending-recovery, POST /api/config/jails/{name}/rollback
- Backend: extend JailActivationResponse with fail2ban_running + validation_warnings;
add JailValidationIssue, JailValidationResult, PendingRecovery, RollbackResponse models
- Backend: health_check task tracks last_activation and creates PendingRecovery
record when fail2ban goes offline within 60 s of an activation
- Backend: add fail2ban_start_command setting (configurable start cmd for rollback)
- Frontend: ActivateJailDialog — pre-validation on open, crash-detected callback,
extended spinner text during activation+verify
- Frontend: JailsTab — Validate Config button for inactive jails, validation
result panels (blocking errors + advisory warnings)
- Frontend: RecoveryBanner component — polls pending-recovery, shows full-width
alert with Disable & Restart / View Logs buttons
- Frontend: MainLayout — mount RecoveryBanner at layout level
- Tests: 19 new backend service tests (validate, rollback, filter/action parsing)
+ 6 health_check crash-detection tests + 11 router tests; 5 RecoveryBanner
frontend tests; fix mock setup in existing activate_jail tests
919 lines
29 KiB
TypeScript
919 lines
29 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, 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 {
|
|
addLogPath,
|
|
deactivateJail,
|
|
deleteLogPath,
|
|
fetchInactiveJails,
|
|
fetchJailConfigFileContent,
|
|
updateJailConfigFile,
|
|
validateJailConfig,
|
|
} from "../../api/config";
|
|
import type {
|
|
AddLogPathRequest,
|
|
ConfFileUpdateRequest,
|
|
InactiveJail,
|
|
JailConfig,
|
|
JailConfigUpdate,
|
|
JailValidationIssue,
|
|
JailValidationResult,
|
|
} 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 { 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(jail.use_dns);
|
|
const [backend, setBackend] = useState(jail.backend);
|
|
const [logEncoding, setLogEncoding] = useState(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 handleDeleteLogPath = useCallback(
|
|
async (path: string) => {
|
|
setDeletingPath(path);
|
|
setMsg(null);
|
|
try {
|
|
await deleteLogPath(jail.name, 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);
|
|
}
|
|
},
|
|
[jail.name],
|
|
);
|
|
|
|
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 addLogPath(jail.name, 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);
|
|
}
|
|
}, [jail.name, newLogPath, newLogPathTail]);
|
|
|
|
const autoSavePayload = useMemo<JailConfigUpdate>(
|
|
() => ({
|
|
ban_time: Number(banTime) || jail.ban_time,
|
|
find_time: Number(findTime) || jail.find_time,
|
|
max_retry: Number(maxRetry) || jail.max_retry,
|
|
fail_regex: failRegex,
|
|
ignore_regex: ignoreRegex,
|
|
date_pattern: datePattern !== "" ? datePattern : null,
|
|
dns_mode: dnsMode,
|
|
backend,
|
|
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, backend, 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);
|
|
|
|
// Raw config file fetch/save helpers — uses jail.d/<name>.conf convention.
|
|
const fetchRaw = useCallback(async (): Promise<string> => {
|
|
const result = await fetchJailConfigFileContent(`${jail.name}.conf`);
|
|
return result.content;
|
|
}, [jail.name]);
|
|
|
|
const saveRaw = useCallback(
|
|
async (content: string): Promise<void> => {
|
|
const req: ConfFileUpdateRequest = { content };
|
|
await updateJailConfigFile(`${jail.name}.conf`, req);
|
|
},
|
|
[jail.name],
|
|
);
|
|
|
|
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);
|
|
}}
|
|
>
|
|
{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);
|
|
}}
|
|
>
|
|
{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);
|
|
}}
|
|
>
|
|
<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={i} 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) && (
|
|
<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>
|
|
)}
|
|
{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;
|
|
/** Whether to show and call onCrashDetected on activation crash. */
|
|
onCrashDetected?: () => void;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
}: InactiveJailDetailProps): React.JSX.Element {
|
|
const styles = useConfigStyles();
|
|
const [validating, setValidating] = useState(false);
|
|
const [validationResult, setValidationResult] = useState<JailValidationResult | null>(null);
|
|
|
|
const handleValidate = useCallback((): void => {
|
|
setValidating(true);
|
|
setValidationResult(null);
|
|
validateJailConfig(jail.name)
|
|
.then((result) => { setValidationResult(result); })
|
|
.catch(() => { /* validation call failed — ignore */ })
|
|
.finally(() => { setValidating(false); });
|
|
}, [jail.name]);
|
|
|
|
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 result panel */}
|
|
{validationResult !== 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={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={idx}><em>{issue.field}:</em> {issue.message}</li>
|
|
))}
|
|
</ul>
|
|
</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<JailConfigDetail
|
|
jail={jailConfig}
|
|
onSave={async () => { /* read-only — never called */ }}
|
|
readOnly
|
|
onActivate={onActivate}
|
|
onValidate={handleValidate}
|
|
validating={validating}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JailsTab
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Tab component showing all fail2ban jails in a list/detail layout with
|
|
* editable configuration forms.
|
|
*
|
|
* @returns JSX element.
|
|
*/
|
|
export interface JailsTabProps {
|
|
/** Called when fail2ban stopped responding after a jail was activated. */
|
|
onCrashDetected?: () => void;
|
|
}
|
|
|
|
export function JailsTab({ onCrashDetected }: JailsTabProps = {}): React.JSX.Element {
|
|
const styles = useConfigStyles();
|
|
const { jails, loading, error, refresh, updateJail } =
|
|
useJailConfigs();
|
|
const { activeJails } = useConfigActiveStatus();
|
|
const [selectedName, setSelectedName] = useState<string | null>(null);
|
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
|
|
|
// Inactive jails
|
|
const [inactiveJails, setInactiveJails] = useState<InactiveJail[]>([]);
|
|
const [inactiveLoading, setInactiveLoading] = useState(false);
|
|
const [activateTarget, setActivateTarget] = useState<InactiveJail | null>(null);
|
|
|
|
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 handleDeactivate = useCallback((name: string): void => {
|
|
deactivateJail(name)
|
|
.then(() => {
|
|
setSelectedName(null);
|
|
refresh();
|
|
loadInactive();
|
|
})
|
|
.catch(() => { /* non-critical — list refreshes on next load */ });
|
|
}, [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) => (
|
|
<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
|
|
jail={selectedActiveJail}
|
|
onSave={updateJail}
|
|
onDeactivate={() => { handleDeactivate(selectedActiveJail.name); }}
|
|
/>
|
|
) : selectedInactiveJail !== undefined ? (
|
|
<InactiveJailDetail
|
|
jail={selectedInactiveJail}
|
|
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
|
|
onCrashDetected={onCrashDetected}
|
|
/>
|
|
) : null}
|
|
</ConfigListDetail>
|
|
</div>
|
|
|
|
<ActivateJailDialog
|
|
jail={activateTarget}
|
|
open={activateTarget !== null}
|
|
onClose={() => { setActivateTarget(null); }}
|
|
onActivated={handleActivated}
|
|
onCrashDetected={onCrashDetected}
|
|
/>
|
|
|
|
<CreateJailDialog
|
|
open={createDialogOpen}
|
|
onClose={() => { setCreateDialogOpen(false); }}
|
|
onCreated={() => {
|
|
setCreateDialogOpen(false);
|
|
refresh();
|
|
loadInactive();
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|