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:
co-authored by
Claude Opus 5
parent
d67698bac9
commit
93ff1fbc53
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.19.2-beta",
|
||||
"version": "0.20.0-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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(','),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -176,6 +176,28 @@ export interface SecurityCheck {
|
||||
serviceId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A configuration RouterOS accepted but that does not work. Produced by the
|
||||
* standing Config Health audit, which encodes MikroTik's documented Layer2
|
||||
* misconfigurations plus the latent lockout cases.
|
||||
*/
|
||||
export interface ConfigFinding {
|
||||
rule: string;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
title: string;
|
||||
detail: string;
|
||||
remediation: string | null;
|
||||
doc_url: string | null;
|
||||
objects: string[];
|
||||
first_seen: string | null;
|
||||
last_seen: string | null;
|
||||
}
|
||||
|
||||
export interface ConfigHealthResponse {
|
||||
findings: ConfigFinding[];
|
||||
checked_at: string | null;
|
||||
}
|
||||
|
||||
export const devicesApi = {
|
||||
list: () => api.get<Device[]>('/devices'),
|
||||
discovered: () => api.get<DiscoveredDevice[]>('/devices/discovered'),
|
||||
@@ -320,6 +342,11 @@ export const devicesApi = {
|
||||
{ disabled, ...(confirmLockout ? { confirm_lockout: true } : {}) }),
|
||||
getSecurityPosture: (id: number) =>
|
||||
api.get<{ score: number; checks: SecurityCheck[] }>(`/devices/${id}/security-posture`),
|
||||
// Config Health — cached findings load instantly; the scan re-reads the device.
|
||||
getConfigHealth: (id: number) =>
|
||||
api.get<ConfigHealthResponse>(`/devices/${id}/config-health`),
|
||||
scanConfigHealth: (id: number) =>
|
||||
api.post<ConfigHealthResponse>(`/devices/${id}/config-health/scan`),
|
||||
patchLocation: (id: number, data: {
|
||||
location_address?: string | null;
|
||||
location_lat?: number | null;
|
||||
|
||||
@@ -103,6 +103,8 @@ export interface Vlan {
|
||||
vlan_id: number;
|
||||
name?: string;
|
||||
bridge?: string;
|
||||
/** Raw RouterOS spec this row came from; a range/list means the entry covers several VLANs. */
|
||||
vlan_ids?: string | null;
|
||||
tagged_ports?: string[];
|
||||
untagged_ports?: string[];
|
||||
}
|
||||
@@ -112,7 +114,11 @@ export interface BridgeVlanEntry {
|
||||
bridge: string;
|
||||
port: string;
|
||||
pvid?: number;
|
||||
/** Every VLAN this port belongs to, tagged or untagged. */
|
||||
vlan_ids?: string[];
|
||||
tagged_vlan_ids?: string[];
|
||||
untagged_vlan_ids?: string[];
|
||||
/** True when the port is a tagged member of at least one VLAN (i.e. a trunk). */
|
||||
tagged: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.19.2 Beta';
|
||||
export const APP_VERSION = 'v0.20.0 Beta';
|
||||
|
||||
Reference in New Issue
Block a user