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
@@ -2715,16 +2715,40 @@ export class DeviceCollector {
|
||||
return policy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge-port attributes that must survive a port being re-created. PVID above
|
||||
* all: re-adding a port without it silently drops the port back to VLAN 1, which
|
||||
* breaks tagged management even though the port looks bridged again.
|
||||
*/
|
||||
private static readonly BRIDGE_PORT_CARRY = [
|
||||
'pvid', 'frame-types', 'ingress-filtering', 'edge', 'horizon', 'point-to-point',
|
||||
] as const;
|
||||
|
||||
private static carryBridgePortAttrs(port: Record<string, string> | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!port) return out;
|
||||
for (const key of DeviceCollector.BRIDGE_PORT_CARRY) {
|
||||
const value = port[key];
|
||||
if (value !== undefined && value !== '') out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async createBond(name: string, slaves: string[], mode: string, opts: {
|
||||
lacpRate?: string; hashPolicy?: string; mtu?: number; minLinks?: number;
|
||||
}): Promise<void> {
|
||||
// Remove each slave from any bridge, recording the first bridge found
|
||||
// Remove each slave from any bridge, recording the first bridge found along
|
||||
// with its port settings so the bond can inherit them.
|
||||
let originalBridge: string | null = null;
|
||||
let carry: Record<string, string> = {};
|
||||
for (const slave of slaves) {
|
||||
const bridgePorts = await this.client.execute('/interface/bridge/port/print', {}, [`?interface=${slave}`]).catch(() => []);
|
||||
for (const bp of bridgePorts) {
|
||||
if (bp['.id']) {
|
||||
if (!originalBridge) originalBridge = bp['bridge'] ?? null;
|
||||
if (!originalBridge) {
|
||||
originalBridge = bp['bridge'] ?? null;
|
||||
carry = DeviceCollector.carryBridgePortAttrs(bp);
|
||||
}
|
||||
await this.client.execute('/interface/bridge/port/remove', { '.id': bp['.id'] });
|
||||
}
|
||||
}
|
||||
@@ -2735,12 +2759,23 @@ export class DeviceCollector {
|
||||
if (opts.mtu) params['mtu'] = String(opts.mtu);
|
||||
if (opts.minLinks != null) params['min-links'] = String(opts.minLinks);
|
||||
await this.client.execute('/interface/bonding/add', params);
|
||||
// Add the new bond interface to the same bridge the slaves were removed from
|
||||
// Put the new bond into the bridge the slaves came out of. If this fails the
|
||||
// member ports are left unbridged — never swallow it, the device may be
|
||||
// unreachable and the caller must know.
|
||||
if (originalBridge) {
|
||||
await this.client.execute('/interface/bridge/port/add', {
|
||||
bridge: originalBridge,
|
||||
interface: name,
|
||||
}).catch(() => {});
|
||||
try {
|
||||
await this.client.execute('/interface/bridge/port/add', {
|
||||
bridge: originalBridge,
|
||||
interface: name,
|
||||
...carry,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Bond '${name}' was created, but adding it to bridge '${originalBridge}' failed: ` +
|
||||
`${(err as Error).message}. The member ports (${slaves.join(', ')}) are no longer bridged ` +
|
||||
`and may not forward traffic — check the device.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2763,22 +2798,43 @@ export class DeviceCollector {
|
||||
const id = list[0]?.['.id'];
|
||||
if (!id) throw new Error(`Bond '${name}' not found on device`);
|
||||
const slaves = (list[0]?.['slaves'] ?? '').split(',').map((s: string) => s.trim()).filter(Boolean);
|
||||
// Check if the bond is itself a bridge member
|
||||
// Capture the bond's bridge membership *and its port settings* before removing
|
||||
// it, so the freed slaves can inherit them (notably PVID).
|
||||
const bondBridgePorts = await this.client.execute('/interface/bridge/port/print', {}, [`?interface=${name}`]).catch(() => []);
|
||||
const bridgeName = bondBridgePorts[0]?.['bridge'] ?? null;
|
||||
if (bondBridgePorts[0]?.['.id']) {
|
||||
await this.client.execute('/interface/bridge/port/remove', { '.id': bondBridgePorts[0]['.id'] });
|
||||
const bondPort = bondBridgePorts[0];
|
||||
const bridgeName = bondPort?.['bridge'] ?? null;
|
||||
const carry = DeviceCollector.carryBridgePortAttrs(bondPort);
|
||||
|
||||
if (bondPort?.['.id']) {
|
||||
await this.client.execute('/interface/bridge/port/remove', { '.id': bondPort['.id'] });
|
||||
}
|
||||
await this.client.execute('/interface/bonding/remove', { '.id': id });
|
||||
// Re-add each slave to the bridge the bond was in
|
||||
|
||||
// Removing a bond releases its slaves as standalone interfaces. If the bond was
|
||||
// bridged, put each slave back in that bridge with the same port settings —
|
||||
// otherwise the physical ports stop forwarding and the device can drop off the
|
||||
// network. A failure here is exactly how a switch goes silently offline, so it
|
||||
// is reported rather than swallowed.
|
||||
const failures: string[] = [];
|
||||
if (bridgeName) {
|
||||
for (const slave of slaves) {
|
||||
await this.client.execute('/interface/bridge/port/add', {
|
||||
bridge: bridgeName,
|
||||
interface: slave,
|
||||
}).catch(() => {});
|
||||
try {
|
||||
await this.client.execute('/interface/bridge/port/add', {
|
||||
bridge: bridgeName,
|
||||
interface: slave,
|
||||
...carry,
|
||||
});
|
||||
} catch (err) {
|
||||
failures.push(`${slave} (${(err as Error).message})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(
|
||||
`Bond '${name}' was removed, but restoring bridge membership on '${bridgeName}' failed for: ` +
|
||||
`${failures.join('; ')}. Those ports may not forward traffic — check the device.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hardware Health ──────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user