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:
co-authored by
Claude Opus 4.8
parent
5895e905b7
commit
3a18cb1d91
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.17.7-beta",
|
||||
"version": "0.18.0-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.17.7 Beta';
|
||||
export const APP_VERSION = 'v0.18.0 Beta';
|
||||
|
||||
Reference in New Issue
Block a user