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:
2026-03-14 14:13:07 +01:00
parent ab11ece001
commit 0966f347c4
17 changed files with 1862 additions and 26 deletions

View File

@@ -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>

View File

@@ -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