feat(safety): Config Health audit, verified auto-revert, VLAN cache fixes (v0.20.0-beta)

Completes the lockout-prevention work: a standing audit for configurations
RouterOS accepts but that do not work, an empirical answer on /system history
undo, and corrections to the VLAN cache the editors are built on.

Config Health (new)
  Eleven rules over a live snapshot, encoding MikroTik's documented Layer2
  misconfigurations plus the latent lockout cases: IP or VLAN interface on a
  bridge slave port, a bridged VLAN interface, a bond slave that is also a
  bridge port, MTU above L2MTU, a multi-VLAN entry with untagged ports,
  bridges competing for hardware offload, a port in no VLAN on a filtering
  bridge, a PVID that frame-type=admit-only-vlan-tagged makes inert, a VLAN
  interface whose VLAN the bridge is not tagged in, duplicate addresses, and
  management that survives only on a dynamic entry. Each finding names the
  consequence, the fix, how long it has been present, and the vendor doc.
  Runs hourly, surfaces on the Security tab and the dashboard.

  Calibrated against hardware: RouterOS 7 relocates an address off a bridge
  slave port onto the bridge (actual-interface), so the stock defconf layout
  works. Reporting that as critical would have fired on a default install; it
  is informational unless RouterOS did not relocate it.

Change Guard: severed-connection handling
  Proven on a CRS by deleting its own management address — the switch locked
  itself out and self-restored unattended in ~3 minutes, twice.

  That test exposed a real defect. withSafeApply treated any exception from
  the apply step as "the change failed", disarming the guard and rethrowing.
  But the most dangerous changes sever the connection carrying them, so the
  call times out even though the change applied. The guard was stripping the
  safety net off a device that had just gone silent, and reporting HTTP 500
  "Read timeout" instead of telling the user the device was restoring itself.
  Only luck saved it: the disarm needed the same dead connection.

  Reachability now decides, not the exception. Unreachable leaves the revert
  armed and returns auto_reverting; still reachable means a genuine failure,
  disarmed so nothing reboots for nothing. committed_at is no longer stamped
  on failed or reverted guards; resolved_at records completion instead.

/system history undo probe
  probeUndoCapability tests the device rather than assuming. On 7.23.x over
  the binary API: /system/history/print works, /system/history/undo does not
  exist, /undo numbers= rejects the parameter, bare /undo works. Undo is
  therefore untargeted, and cannot replace auto-revert since a dead-man switch
  must fire after contact is lost. Binary restore stays the default.

VLAN cache correctness
  - vlans keyed on (device_id, bridge, vlan_id); the old key collapsed the
    same VID on two bridges
  - vlan-ids ranges expanded instead of parseInt truncating "10-20" to 10
  - both VLAN tables pruned, but only when the device returned an
    authoritative list, so a failed read leaves the cache stale not empty
  - bridge_vlan_entries.vlan_ids written and .tagged derived rather than
    hardcoded false, which had left the port editor unable to see a trunk
  - removeBridgeVlan matched by expanded VIDs; the ?vlan-ids= server filter
    compares raw strings, so deleting one VID of a range silently no-opped
    while reporting success. Multi-VLAN entries are refused in the route,
    before the guard arms, since a mid-apply failure would cost a reboot
  - interfaces.vlan_filtering promoted to a column

  Membership is deliberately the STATIC tagged/untagged, never current-*.
  These tables back the editors and updateBridgeVlan is remove-then-add, so
  whatever is shown is written back; the effective view baked RouterOS's
  dynamic PVID entries into saved configuration on every save. Analysis that
  needs the effective view reads the device live. Extracted to pure functions
  with tests, because that path was previously untestable inline.

Verified on four devices; 195 backend tests; all CI gates green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-08-17 11:47:14 -07:00
co-authored by Claude Opus 5
parent d67698bac9
commit 93ff1fbc53
22 changed files with 2084 additions and 73 deletions
@@ -0,0 +1,205 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Activity, AlertTriangle, ChevronDown, ChevronRight, CircleCheck,
ExternalLink, RefreshCw, ShieldAlert, Wrench,
} from 'lucide-react';
import { devicesApi, type ConfigFinding } from '../../services/api';
import { useCanWrite } from '../../hooks/useCanWrite';
import clsx from 'clsx';
const SEV: Record<ConfigFinding['severity'], { chip: string; card: string; label: string }> = {
critical: {
chip: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400',
card: 'border-red-300 dark:border-red-800',
label: 'Broken',
},
warning: {
chip: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400',
card: 'border-amber-300 dark:border-amber-800',
label: 'Fragile',
},
info: {
chip: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
card: 'border-blue-300 dark:border-blue-800',
label: 'Note',
},
};
function since(iso: string | null): string {
if (!iso) return '';
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
if (days < 1) return 'first seen today';
if (days === 1) return 'present for 1 day';
return `present for ${days} days`;
}
function Finding({ f }: { f: ConfigFinding }) {
const [open, setOpen] = useState(f.severity === 'critical');
const style = SEV[f.severity];
return (
<div className={clsx('rounded-lg border', style.card)}>
<button
onClick={() => setOpen((o) => !o)}
className="w-full flex items-start gap-2.5 p-3 text-left"
>
{open
? <ChevronDown className="w-4 h-4 mt-0.5 flex-shrink-0 text-gray-400" />
: <ChevronRight className="w-4 h-4 mt-0.5 flex-shrink-0 text-gray-400" />}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={clsx('text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded', style.chip)}>
{style.label}
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white">{f.title}</span>
</div>
{!open && f.first_seen && (
<div className="mt-0.5 text-xs text-gray-400">{since(f.first_seen)}</div>
)}
</div>
</button>
{open && (
<div className="px-3 pb-3 pl-9 space-y-2.5">
<p className="text-sm text-gray-600 dark:text-slate-300">{f.detail}</p>
{f.remediation && (
<div className="flex items-start gap-2 rounded-md bg-gray-50 dark:bg-slate-800/60 p-2.5">
<Wrench className="w-3.5 h-3.5 mt-0.5 flex-shrink-0 text-gray-400" />
<p className="text-sm text-gray-700 dark:text-slate-200">{f.remediation}</p>
</div>
)}
<div className="flex items-center gap-3 flex-wrap text-xs text-gray-400">
{f.objects.length > 0 && (
<span className="mono">{f.objects.join(' · ')}</span>
)}
{f.first_seen && <span>{since(f.first_seen)}</span>}
{f.doc_url && (
<a
href={f.doc_url}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline"
>
MikroTik docs <ExternalLink className="w-3 h-3" />
</a>
)}
</div>
</div>
)}
</div>
);
}
/**
* Standing audit for configurations RouterOS accepted but that do not work.
*
* Distinct from the hardening score above it: nothing here is a security setting.
* These are contradictions the device will never report — an address on a slave
* port, a PVID that a frame-type makes inert — so this panel is the only place
* they become visible.
*/
export default function ConfigHealthCard({ deviceId }: { deviceId: number }) {
const qc = useQueryClient();
const canWrite = useCanWrite();
const [scanError, setScanError] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['config-health', deviceId],
queryFn: () => devicesApi.getConfigHealth(deviceId).then((r) => r.data),
});
const scan = useMutation({
mutationFn: () => devicesApi.scanConfigHealth(deviceId).then((r) => r.data),
onSuccess: (fresh) => {
qc.setQueryData(['config-health', deviceId], fresh);
setScanError('');
},
onError: (err: unknown) => {
const r = (err as { response?: { data?: { error?: string } } })?.response?.data;
setScanError(r?.error || 'Could not audit the device');
},
});
const findings = data?.findings ?? [];
const critical = findings.filter((f) => f.severity === 'critical').length;
const warnings = findings.filter((f) => f.severity === 'warning').length;
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<div className="w-7 h-7 bg-blue-50 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
<Activity className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
</div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Config Health</h3>
{critical > 0 && (
<span className={clsx('text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded', SEV.critical.chip)}>
{critical} broken
</span>
)}
{warnings > 0 && (
<span className={clsx('text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded', SEV.warning.chip)}>
{warnings} fragile
</span>
)}
</div>
{canWrite && (
<button
onClick={() => scan.mutate()}
disabled={scan.isPending}
className="btn-secondary flex items-center gap-1.5 text-xs py-1.5"
>
<RefreshCw className={clsx('w-3.5 h-3.5', scan.isPending && 'animate-spin')} /> Re-audit
</button>
)}
</div>
<p className="text-xs text-gray-500 dark:text-slate-400">
RouterOS applies each command on its own, with no check that the result is coherent — so a
configuration can be accepted, saved, and still not work. This audit looks for those cases.
</p>
{scanError && (
<div className="flex items-start gap-2 rounded-lg border border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-3">
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-500" />
<p className="text-sm text-red-800 dark:text-red-300">{scanError}</p>
</div>
)}
{isLoading ? (
<div className="text-center py-6 text-gray-400 text-sm">
<RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading findings…
</div>
) : findings.length === 0 ? (
<div className="card p-4 flex items-start gap-2.5">
<CircleCheck className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-600 dark:text-green-400" />
<div>
<div className="text-sm font-medium text-green-700 dark:text-green-400">
No configuration conflicts found
</div>
<div className="text-xs text-gray-400 mt-0.5">
{data?.checked_at
? `Last audited ${new Date(data.checked_at).toLocaleString()}.`
: 'This device has not been audited yet — run one with Re-audit.'}
</div>
</div>
</div>
) : (
<>
<div className="space-y-2">
{findings.map((f) => <Finding key={`${f.rule}:${f.objects.join(',')}`} f={f} />)}
</div>
{data?.checked_at && (
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<ShieldAlert className="w-3 h-3" />
Last audited {new Date(data.checked_at).toLocaleString()}
</div>
)}
</>
)}
</div>
);
}
@@ -5,6 +5,7 @@ import { devicesApi } from '../../services/api';
import type { SecurityCheck } from '../../services/api';
import { useCanWrite } from '../../hooks/useCanWrite';
import { LockoutVerdictDialog, lockoutVerdictOf, type LockoutVerdict } from '../ChangeGuardDialog';
import ConfigHealthCard from './ConfigHealthCard';
import clsx from 'clsx';
type Row = Record<string, string> & { '.id': string };
@@ -190,6 +191,12 @@ export default function SecurityTab({ deviceId, deviceName }: { deviceId: number
</div>
)}
{/* Correctness rather than hardening: configurations the device accepted and
will never complain about, but that do not do what they appear to. */}
<div className="pt-2 border-t border-gray-200 dark:border-slate-700">
<ConfigHealthCard deviceId={deviceId} />
</div>
{lockout && (
<LockoutVerdictDialog
verdict={lockout.verdict}
@@ -870,7 +870,9 @@ export default function SwitchPortDiagram({ deviceId, deviceName, autoOpenBridge
speed: '',
vlan_mode: bridgeTagged ? 'trunk' : bridgePvid > 1 ? 'access' : 'none',
pvid: bridgePvid,
tagged_vlans: '',
// Prefill from the port's actual tagged membership so opening the dialog and
// saving does not silently strip the trunk's VLANs.
tagged_vlans: (port.bridgeInfo?.tagged_vlan_ids ?? []).join(','),
});
};