feat: Task 3 — invalid jail config recovery (pre-validation, crash detection, rollback)
- 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
This commit is contained in:
@@ -4,9 +4,16 @@
|
||||
* 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.
|
||||
*
|
||||
* Task 3 additions:
|
||||
* - Runs pre-activation validation when the dialog opens and displays any
|
||||
* warnings or blocking errors before the user confirms.
|
||||
* - Extended spinner text during the post-reload probe phase.
|
||||
* - Calls `onCrashDetected` when the activation response signals that
|
||||
* fail2ban stopped responding after the reload.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -23,8 +30,12 @@ import {
|
||||
Text,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
import { activateJail } from "../../api/config";
|
||||
import type { ActivateJailRequest, InactiveJail } from "../../types/config";
|
||||
import { activateJail, validateJailConfig } from "../../api/config";
|
||||
import type {
|
||||
ActivateJailRequest,
|
||||
InactiveJail,
|
||||
JailValidationIssue,
|
||||
} from "../../types/config";
|
||||
import { ApiError } from "../../api/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -40,6 +51,11 @@ export interface ActivateJailDialogProps {
|
||||
onClose: () => void;
|
||||
/** Called after the jail has been successfully activated. */
|
||||
onActivated: () => void;
|
||||
/**
|
||||
* Called when fail2ban stopped responding after the jail was activated.
|
||||
* The recovery banner will surface this to the user.
|
||||
*/
|
||||
onCrashDetected?: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -60,6 +76,7 @@ export function ActivateJailDialog({
|
||||
open,
|
||||
onClose,
|
||||
onActivated,
|
||||
onCrashDetected,
|
||||
}: ActivateJailDialogProps): React.JSX.Element {
|
||||
const [bantime, setBantime] = useState("");
|
||||
const [findtime, setFindtime] = useState("");
|
||||
@@ -69,6 +86,11 @@ export function ActivateJailDialog({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | 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("");
|
||||
@@ -76,8 +98,31 @@ export function ActivateJailDialog({
|
||||
setPort("");
|
||||
setLogpath("");
|
||||
setError(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();
|
||||
@@ -106,8 +151,14 @@ export function ActivateJailDialog({
|
||||
setError(null);
|
||||
|
||||
activateJail(jail.name, overrides)
|
||||
.then(() => {
|
||||
.then((result) => {
|
||||
if (result.validation_warnings.length > 0) {
|
||||
setValidationWarnings(result.validation_warnings);
|
||||
}
|
||||
resetForm();
|
||||
if (!result.fail2ban_running) {
|
||||
onCrashDetected?.();
|
||||
}
|
||||
onActivated();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
@@ -126,6 +177,14 @@ export function ActivateJailDialog({
|
||||
|
||||
if (!jail) return <></>;
|
||||
|
||||
// Errors block activation; warnings are advisory only.
|
||||
const blockingIssues = validationIssues.filter(
|
||||
(i) => i.field !== "logpath",
|
||||
);
|
||||
const advisoryIssues = validationIssues.filter(
|
||||
(i) => i.field === "logpath",
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(_ev, data) => { if (!data.open) handleClose(); }}>
|
||||
<DialogSurface>
|
||||
@@ -137,6 +196,60 @@ export function ActivateJailDialog({
|
||||
<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>
|
||||
@@ -227,10 +340,10 @@ export function ActivateJailDialog({
|
||||
<Button
|
||||
appearance="primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={submitting}
|
||||
disabled={submitting || validating}
|
||||
icon={submitting ? <Spinner size="tiny" /> : undefined}
|
||||
>
|
||||
{submitting ? "Activating…" : "Activate"}
|
||||
{submitting ? "Activating and verifying…" : "Activate"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Select,
|
||||
Skeleton,
|
||||
SkeletonItem,
|
||||
Spinner,
|
||||
Switch,
|
||||
Text,
|
||||
tokens,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
fetchInactiveJails,
|
||||
fetchJailConfigFileContent,
|
||||
updateJailConfigFile,
|
||||
validateJailConfig,
|
||||
} from "../../api/config";
|
||||
import type {
|
||||
AddLogPathRequest,
|
||||
@@ -45,6 +47,8 @@ import type {
|
||||
InactiveJail,
|
||||
JailConfig,
|
||||
JailConfigUpdate,
|
||||
JailValidationIssue,
|
||||
JailValidationResult,
|
||||
} from "../../types/config";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import { useConfigActiveStatus } from "../../hooks/useConfigActiveStatus";
|
||||
@@ -99,6 +103,10 @@ interface JailConfigDetailProps {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +124,8 @@ function JailConfigDetail({
|
||||
onDeactivate,
|
||||
readOnly = false,
|
||||
onActivate,
|
||||
onValidate,
|
||||
validating = false,
|
||||
}: JailConfigDetailProps): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const [banTime, setBanTime] = useState(String(jail.ban_time));
|
||||
@@ -563,15 +573,27 @@ function JailConfigDetail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{readOnly && onActivate !== undefined && (
|
||||
<div style={{ marginTop: tokens.spacingVerticalM }}>
|
||||
<Button
|
||||
appearance="primary"
|
||||
icon={<Play24Regular />}
|
||||
onClick={onActivate}
|
||||
>
|
||||
Activate Jail
|
||||
</Button>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -596,6 +618,8 @@ function JailConfigDetail({
|
||||
interface InactiveJailDetailProps {
|
||||
jail: InactiveJail;
|
||||
onActivate: () => void;
|
||||
/** Whether to show and call onCrashDetected on activation crash. */
|
||||
onCrashDetected?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -614,6 +638,22 @@ function InactiveJailDetail({
|
||||
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>(
|
||||
() => ({
|
||||
@@ -648,11 +688,49 @@ function InactiveJailDetail({
|
||||
<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>
|
||||
);
|
||||
@@ -668,7 +746,12 @@ function InactiveJailDetail({
|
||||
*
|
||||
* @returns JSX element.
|
||||
*/
|
||||
export function JailsTab(): React.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();
|
||||
@@ -807,6 +890,7 @@ export function JailsTab(): React.JSX.Element {
|
||||
<InactiveJailDetail
|
||||
jail={selectedInactiveJail}
|
||||
onActivate={() => { setActivateTarget(selectedInactiveJail); }}
|
||||
onCrashDetected={onCrashDetected}
|
||||
/>
|
||||
) : null}
|
||||
</ConfigListDetail>
|
||||
@@ -817,6 +901,7 @@ export function JailsTab(): React.JSX.Element {
|
||||
open={activateTarget !== null}
|
||||
onClose={() => { setActivateTarget(null); }}
|
||||
onActivated={handleActivated}
|
||||
onCrashDetected={onCrashDetected}
|
||||
/>
|
||||
|
||||
<CreateJailDialog
|
||||
|
||||
Reference in New Issue
Block a user