feat(safety): Change Guard — device-side auto-revert for risky config changes (v0.18.0-beta)

A user change can sever the manager's own path to a device with no warning and no
way back. Most recently, deleting an *unused* bond on a switch took the whole
switch offline. This adds a safety net, and fixes the bug that caused that
specific incident.

Root cause of the bond incident (DeviceCollector.deleteBond):
- Slaves were re-added to the bridge ONLY when the bond itself was a bridge
  member. For an unbridged bond the restore loop never ran, so the member ports
  were released from the bond and left in no bridge at all — the ports stopped
  forwarding and the switch dropped off the network.
- The restore in both deleteBond and createBond was wrapped in `.catch(() => {})`,
  so a failed restore was swallowed and the route still returned 200 OK.
- Restores also dropped PVID, silently returning tagged ports to VLAN 1.
Now: port settings (PVID, frame-types, ingress-filtering, edge, horizon) are
captured and carried over, and restore failures are reported instead of hidden.

Change Guard (services/changeGuard/ChangeGuard.ts):
Before a risky change the device saves a restore point and arms a scheduler that
restores it after N seconds. The change is applied, then reachability is proven
on a NEW connection; success disarms the scheduler, failure leaves the device to
restore itself and come back. Because the restore point is created before the
scheduler, a binary restore rolls back to a config containing neither the
scheduler nor the change — self-cleaning, with no revert loop.

- Works entirely over the RouterOS API. This matters: every recovery artifact
  today requires SSH, so devices without SSH credentials had no rollback target
  at all (cf. #86).
- Modes: `binary` (exact restore incl. deleted interfaces, reboots) or `script`
  (/import, no reboot but additive). Configurable via change_guard_* settings.
- Per-device Redis lock, `device_change_guards` audit table, and startup
  reconciliation for guards left pending by a manager restart.
- Degrades gracefully: if protection can't be armed the change still runs and the
  response says it was unprotected.

Wired into bond create/delete, bridge vlan-filtering, and /ip/service. Disabling
the service the manager is connected through is now refused outright (409) — the
mgmt-service signal already existed but was confined to the read-only posture
endpoint.

Also: replaces the bare confirm() on "Destroy Bond" with a ChangeGuardDialog that
explains the risk and the protection, and reports the outcome (including "the
device is restoring itself"). Fixes getAppSettings omitting
config_snapshot_enabled/interval, which made the v0.17.4 snapshot toggle a no-op.

Verified on real hardware (hAP-class switch): capability probe confirmed
/system/backup/save and scheduler add/remove work over the API; a guarded change
armed, applied, verified and committed leaving zero schedulers and zero files on
the device; disabling the api service was refused with 409. 133 backend tests
pass, including 7 guard state-machine tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-08-03 11:52:07 -07:00
co-authored by Claude Opus 4.8
parent 5895e905b7
commit 3a18cb1d91
13 changed files with 886 additions and 72 deletions
@@ -0,0 +1,104 @@
import { AlertTriangle, RefreshCw, ShieldCheck } from 'lucide-react';
/**
* Confirmation for a change that could sever the manager's path to a device.
*
* Follows the existing LockoutDialog/ScanWarningModal pattern (amber = dangerous
* but intentional; red is reserved for delete), and explains the protection that
* will be applied so the user knows what happens if it goes wrong.
*/
export interface ChangeGuardDialogProps {
title: string;
/** What the user is about to do, in plain language. */
description: string;
/** Optional extra risk detail (e.g. why this object matters to reachability). */
reason?: string;
confirmLabel: string;
/** When false, the safety net is unavailable and the change is unprotected. */
protectedByGuard?: boolean;
pending?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export default function ChangeGuardDialog({
title, description, reason, confirmLabel,
protectedByGuard = true, pending = false, onConfirm, onCancel,
}: ChangeGuardDialogProps) {
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="card w-full max-w-md mx-4 p-6 space-y-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-6 h-6 text-amber-500 flex-shrink-0" />
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">{title}</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-slate-300">{description}</p>
</div>
</div>
{reason && (
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 p-3">
<p className="text-sm text-amber-800 dark:text-amber-300">{reason}</p>
</div>
)}
{protectedByGuard ? (
<div className="flex items-start gap-2 text-xs text-gray-500 dark:text-slate-400">
<ShieldCheck className="w-4 h-4 flex-shrink-0 text-green-600 dark:text-green-400" />
<span>
Protected: the device saves a restore point first. If it stops responding after
this change, it restores itself automatically and comes back.
</span>
</div>
) : (
<div className="flex items-start gap-2 text-xs text-amber-700 dark:text-amber-400">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
<span>
Auto-revert is not available for this device, so this change cannot be undone
automatically if it goes wrong.
</span>
</div>
)}
<div className="flex justify-end gap-2 pt-1">
<button onClick={onCancel} className="btn-secondary" disabled={pending}>Cancel</button>
<button
onClick={onConfirm}
disabled={pending}
className="px-4 py-1.5 bg-amber-600 hover:bg-amber-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg flex items-center gap-2"
>
{pending && <RefreshCw className="w-4 h-4 animate-spin" />}
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
/** Shape returned by guarded endpoints so callers can report the outcome. */
export interface GuardResult {
protected: boolean;
confirmed: boolean;
auto_reverting: boolean;
unprotected_reason: string | null;
}
/** Human-readable outcome for a guarded change, or null when nothing to say. */
export function guardOutcomeMessage(guard?: GuardResult): { tone: 'ok' | 'warn'; text: string } | null {
if (!guard) return null;
if (guard.auto_reverting) {
return {
tone: 'warn',
text: 'The device stopped responding after this change, so it is restoring itself to the '
+ 'previous configuration. It should come back shortly — the change was not kept.',
};
}
if (guard.confirmed) {
return { tone: 'ok', text: 'Applied — device confirmed still reachable.' };
}
if (guard.unprotected_reason) {
return { tone: 'warn', text: `Applied without auto-revert protection: ${guard.unprotected_reason}` };
}
return null;
}
@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, X, Check, AlertCircle, Activity, Cpu, Link2, Trash2, Plus, Network, Users, Wifi } from 'lucide-react';
import { RefreshCw, X, Check, AlertCircle, Activity, Cpu, Link2, Trash2, Plus, Network, Users, Wifi, ShieldCheck, AlertTriangle } from 'lucide-react';
import { devicesApi, metricsApi } from '../../services/api';
import { useCanWrite } from '../../hooks/useCanWrite';
import ChangeGuardDialog, { guardOutcomeMessage, type GuardResult } from '../ChangeGuardDialog';
import type { SwitchPort, Vlan, TrafficPoint, PortMonitorData, PortClient } from '../../types';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
@@ -596,6 +597,8 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
const [trafficRange, setTrafficRange] = useState('1h');
const [showCreateTrunkModal, setShowCreateTrunkModal] = useState(false);
const [editingBond, setEditingBond] = useState<SwitchPort | null>(null);
const [confirmDestroyBond, setConfirmDestroyBond] = useState<string | null>(null);
const [guardNotice, setGuardNotice] = useState<{ tone: 'ok' | 'warn'; text: string } | null>(null);
const [editForm, setEditForm] = useState<PortEditForm>({
disabled: false,
comment: '',
@@ -713,10 +716,17 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
const deleteBondMutation = useMutation({
mutationFn: (name: string) => devicesApi.deleteBond(deviceId, name),
onSuccess: () => {
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['ports', deviceId] });
setEditingBond(null);
setConfirmDestroyBond(null);
setBondError('');
// Report what the safety net did — especially "the device is restoring itself".
const notice = guardOutcomeMessage((res?.data as { guard?: GuardResult })?.guard);
if (notice) {
setGuardNotice(notice);
if (notice.tone === 'ok') setTimeout(() => setGuardNotice(null), 6000);
}
},
onError: (err: unknown) => {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
@@ -1792,11 +1802,7 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
<button
disabled={deleteBondMutation.isPending}
className="btn-danger flex items-center gap-2 text-sm"
onClick={() => {
if (confirm(`Destroy bond "${editingBond.name}"? Member ports will become individual interfaces.`)) {
deleteBondMutation.mutate(editingBond.name);
}
}}
onClick={() => setConfirmDestroyBond(editingBond.name)}
>
{deleteBondMutation.isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Destroy Bond
@@ -1906,6 +1912,40 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
</div>
);
})()}
{confirmDestroyBond && (
<ChangeGuardDialog
title="Destroy bond?"
description={`Bond "${confirmDestroyBond}" will be removed and its member ports released as individual interfaces.`}
reason={
'If this bond carries the link this device is managed over, removing it can take the '
+ 'device offline — even if the bond itself looks unused.'
}
confirmLabel="Destroy bond"
pending={deleteBondMutation.isPending}
onConfirm={() => deleteBondMutation.mutate(confirmDestroyBond)}
onCancel={() => setConfirmDestroyBond(null)}
/>
)}
{guardNotice && (
<div
className={clsx(
'fixed bottom-4 right-4 z-[70] max-w-md rounded-lg border p-3 text-sm shadow-lg',
guardNotice.tone === 'ok'
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-700 dark:text-green-400'
: 'bg-amber-50 dark:bg-amber-900/20 border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-300'
)}
>
<div className="flex items-start gap-2">
{guardNotice.tone === 'ok'
? <ShieldCheck className="w-4 h-4 flex-shrink-0 mt-0.5" />
: <AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />}
<span>{guardNotice.text}</span>
<button onClick={() => setGuardNotice(null)} className="ml-2 opacity-60 hover:opacity-100">✕</button>
</div>
</div>
)}
</div>
);
}