- Delete RecoveryBanner.tsx component and its test - Remove RecoveryBanner from MainLayout - Remove onCrashDetected prop from ActivateJailDialog, JailsTab - Remove fetchPendingRecovery, rollbackJail API functions - Remove configJailRollback, configPendingRecovery endpoints - Remove PendingRecovery type
382 lines
13 KiB
TypeScript
382 lines
13 KiB
TypeScript
/**
|
|
* 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.
|
|
*
|
|
* Runs pre-activation validation when the dialog opens and displays any
|
|
* warnings or blocking errors before the user confirms.
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import {
|
|
Button,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogBody,
|
|
DialogContent,
|
|
DialogSurface,
|
|
DialogTitle,
|
|
Field,
|
|
Input,
|
|
MessageBar,
|
|
MessageBarBody,
|
|
MessageBarTitle,
|
|
Spinner,
|
|
Text,
|
|
tokens,
|
|
} from "@fluentui/react-components";
|
|
import { activateJail, validateJailConfig } from "../../api/config";
|
|
import type {
|
|
ActivateJailRequest,
|
|
InactiveJail,
|
|
JailValidationIssue,
|
|
} 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 [recoveryStatus, setRecoveryStatus] = useState<"recovered" | "unrecovered" | null>(null);
|
|
|
|
// Pre-activation validation state
|
|
const [validating, setValidating] = useState(false);
|
|
const [validationIssues, setValidationIssues] = useState<JailValidationIssue[]>([]);
|
|
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
|
|
|
|
const resetForm = (): void => {
|
|
setBantime("");
|
|
setFindtime("");
|
|
setMaxretry("");
|
|
setPort("");
|
|
setLogpath("");
|
|
setError(null);
|
|
setRecoveryStatus(null);
|
|
setValidationIssues([]);
|
|
setValidationWarnings([]);
|
|
};
|
|
|
|
// Run pre-validation whenever the dialog opens for a jail.
|
|
useEffect(() => {
|
|
if (!open || !jail) return;
|
|
|
|
setValidating(true);
|
|
setValidationIssues([]);
|
|
setValidationWarnings([]);
|
|
|
|
validateJailConfig(jail.name)
|
|
.then((result) => {
|
|
setValidationIssues(result.issues);
|
|
})
|
|
.catch(() => {
|
|
// Validation failure is non-blocking — proceed to allow the user to
|
|
// attempt activation and let the server decide.
|
|
})
|
|
.finally(() => {
|
|
setValidating(false);
|
|
});
|
|
}, [open, jail]);
|
|
|
|
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((result) => {
|
|
if (!result.active) {
|
|
if (result.recovered === true) {
|
|
// Activation failed but the system rolled back automatically.
|
|
setRecoveryStatus("recovered");
|
|
} else if (result.recovered === false) {
|
|
// Activation failed and rollback also failed.
|
|
setRecoveryStatus("unrecovered");
|
|
} else {
|
|
// Backend rejected before writing (e.g. missing logpath or filter).
|
|
// Show the server's message and keep the dialog open.
|
|
setError(result.message);
|
|
}
|
|
return;
|
|
}
|
|
if (result.validation_warnings.length > 0) {
|
|
setValidationWarnings(result.validation_warnings);
|
|
}
|
|
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 <></>;
|
|
|
|
// All validation issues block activation — logpath errors are now critical.
|
|
const blockingIssues = validationIssues;
|
|
const advisoryIssues: JailValidationIssue[] = [];
|
|
|
|
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>
|
|
|
|
{/* Pre-validation results */}
|
|
{validating && (
|
|
<div style={{ marginBottom: tokens.spacingVerticalS, display: "flex", alignItems: "center", gap: tokens.spacingHorizontalS }}>
|
|
<Spinner size="tiny" />
|
|
<Text size={200}>Validating configuration…</Text>
|
|
</div>
|
|
)}
|
|
{!validating && blockingIssues.length > 0 && (
|
|
<MessageBar
|
|
intent="error"
|
|
style={{ marginBottom: tokens.spacingVerticalS }}
|
|
>
|
|
<MessageBarBody>
|
|
<strong>Configuration errors detected:</strong>
|
|
<ul style={{ margin: `${tokens.spacingVerticalXS} 0 0 0`, paddingLeft: "1.2em" }}>
|
|
{blockingIssues.map((issue, idx) => (
|
|
<li key={idx}><em>{issue.field}:</em> {issue.message}</li>
|
|
))}
|
|
</ul>
|
|
</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
{!validating && advisoryIssues.length > 0 && (
|
|
<MessageBar
|
|
intent="warning"
|
|
style={{ marginBottom: tokens.spacingVerticalS }}
|
|
>
|
|
<MessageBarBody>
|
|
<strong>Advisory warnings:</strong>
|
|
<ul style={{ margin: `${tokens.spacingVerticalXS} 0 0 0`, paddingLeft: "1.2em" }}>
|
|
{advisoryIssues.map((issue, idx) => (
|
|
<li key={idx}><em>{issue.field}:</em> {issue.message}</li>
|
|
))}
|
|
</ul>
|
|
</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
{validationWarnings.length > 0 && (
|
|
<MessageBar
|
|
intent="warning"
|
|
style={{ marginBottom: tokens.spacingVerticalS }}
|
|
>
|
|
<MessageBarBody>
|
|
<strong>Post-activation warnings:</strong>
|
|
<ul style={{ margin: `${tokens.spacingVerticalXS} 0 0 0`, paddingLeft: "1.2em" }}>
|
|
{validationWarnings.map((w, idx) => (
|
|
<li key={idx}>{w}</li>
|
|
))}
|
|
</ul>
|
|
</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
|
|
<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>
|
|
{recoveryStatus === "recovered" && (
|
|
<MessageBar
|
|
intent="warning"
|
|
style={{ marginTop: tokens.spacingVerticalS }}
|
|
>
|
|
<MessageBarBody>
|
|
<MessageBarTitle>Activation Failed — Configuration Rolled Back</MessageBarTitle>
|
|
The configuration for jail “{jail.name}” has been
|
|
rolled back to its previous state and fail2ban is running
|
|
normally. Review the configuration and try activating again.
|
|
</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
{recoveryStatus === "unrecovered" && (
|
|
<MessageBar
|
|
intent="error"
|
|
style={{ marginTop: tokens.spacingVerticalS }}
|
|
>
|
|
<MessageBarBody>
|
|
<MessageBarTitle>Activation Failed — Rollback Unsuccessful</MessageBarTitle>
|
|
Activation of jail “{jail.name}” failed and the
|
|
automatic rollback did not complete. The file{" "}
|
|
<code>jail.d/{jail.name}.local</code> may still contain{" "}
|
|
<code>enabled = true</code>. Check the fail2ban logs, correct
|
|
the file manually, and restart fail2ban.
|
|
</MessageBarBody>
|
|
</MessageBar>
|
|
)}
|
|
{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 || validating || blockingIssues.length > 0}
|
|
icon={submitting ? <Spinner size="tiny" /> : undefined}
|
|
>
|
|
{submitting ? "Activating and verifying…" : "Activate"}
|
|
</Button>
|
|
</DialogActions>
|
|
</DialogBody>
|
|
</DialogSurface>
|
|
</Dialog>
|
|
);
|
|
}
|