feat(fleet-sync): anchor replicas to a control fingerprint (#968)

A replica now binds to the first control that pushes to it. Subsequent
pushes from a different control are rejected with 409
CONTROL_IDENTITY_MISMATCH until an admin explicitly reanchors. Closes
the cross-control hijack window where any node_proxy bearer signed
against the replica's secret could overwrite security policies.

Wire protocol:
- Sender includes a stable 16-hex-char `controlIdentity` derived by
  SHA-256-truncating `system_state.instance_id` (the local UUID written
  once by LicenseService.initialize on first boot). Hostname rotations
  do not flag drift; only a SQLite reset or explicit reanchor breaks
  the binding.
- Receiver caches the fingerprint inside the same transaction that
  applies the row replacement and watermark write. Three states:
  null (fresh install), '' (post-reanchor), '<fingerprint>' (anchored).
- Empty `controlIdentity` is treated as legacy and accepted, so older
  controls keep working during rollout.

New endpoint:
- POST /api/fleet/role/reanchor (admin, requires `{override: true}`):
  clears the cached fingerprint, both `received_pushed_at:*` watermarks,
  and replicated rows of both resources, all in one transaction. The
  static cached fingerprint is also flushed defensively.

Public surface additions:
- `FleetSyncService.getControlIdentity()`: stable fingerprint for the
  outgoing push body.
- `FleetSyncService.reanchor()`: admin-driven anchor reset.
- `ControlIdentityMismatchError`: typed sentinel the route translates
  to 409 with structured body `{error, code, expected, got}`.

Tests:
- 9 new vitest cases covering first-sync persistence, mismatch
  rejection, matching acceptance, empty-incoming back-compat,
  post-reanchor un-anchored state, fingerprint stability, missing
  instance_id fallback, route-level mismatch, route-level reanchor
  with override gating.
- One ordered route-level scenario instead of cross-dependent it()
  blocks so test reordering cannot silently break the suite.
- Full backend suite: 1769 pass / 5 skipped.
This commit is contained in:
Anso
2026-05-07 13:00:21 -04:00
committed by GitHub
parent 27660f622b
commit f3757b43c6
6 changed files with 341 additions and 14 deletions
+37 -1
View File
@@ -4,7 +4,7 @@ import semver from 'semver';
import si from 'systeminformation';
import type Dockerode from 'dockerode';
import { DatabaseService, type Node } from '../services/DatabaseService';
import { FleetSyncService, StaleSyncPushError } from '../services/FleetSyncService';
import { ControlIdentityMismatchError, FleetSyncService, StaleSyncPushError } from '../services/FleetSyncService';
import { MAX_SYNC_ROWS, SYNC_ERROR_CODES } from '../services/fleetSyncConstants';
import { FleetUpdateTrackerService } from '../services/FleetUpdateTrackerService';
import { NodeRegistry } from '../services/NodeRegistry';
@@ -321,6 +321,9 @@ fleetRouter.post('/sync/:resource', authMiddleware, (req: Request, res: Response
const pushedAt = typeof body.pushedAt === 'number' && Number.isFinite(body.pushedAt) && body.pushedAt > 0
? body.pushedAt
: null;
// controlIdentity is optional for back-compat. The receiver anchors to the
// first non-empty fingerprint it sees and rejects mismatches afterward.
const controlIdentity = typeof body.controlIdentity === 'string' ? body.controlIdentity : '';
if (!rows) {
res.status(400).json({ error: 'rows array is required' });
return;
@@ -343,6 +346,7 @@ fleetRouter.post('/sync/:resource', authMiddleware, (req: Request, res: Response
rows,
targetIdentity,
pushedAt ?? undefined,
controlIdentity || undefined,
);
res.json({ success: true, applied: rows.length });
} catch (error) {
@@ -353,11 +357,43 @@ fleetRouter.post('/sync/:resource', authMiddleware, (req: Request, res: Response
});
return;
}
if (error instanceof ControlIdentityMismatchError) {
res.status(409).json({
error: error.message,
code: SYNC_ERROR_CODES.controlIdentityMismatch,
expected: error.expected,
got: error.got,
});
return;
}
console.error('[FleetSync] Failed to apply incoming sync:', error);
res.status(500).json({ error: 'Failed to apply sync' });
}
});
// Reset the control anchor on this replica. An admin must opt in explicitly
// with `{override: true}` because reanchor wipes all replicated rows; the
// next push from a different control will re-populate them. Used when a
// control is permanently rebuilt or replaced and must be re-bound to its
// existing replicas.
fleetRouter.post('/role/reanchor', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
const body = req.body ?? {};
if (body.override !== true) {
res.status(400).json({
error: 'Reanchor requires explicit override. Send { "override": true } to confirm.',
});
return;
}
try {
FleetSyncService.getInstance().reanchor();
res.json({ success: true });
} catch (error) {
console.error('[FleetSync] Reanchor failed:', error);
res.status(500).json({ error: 'Failed to reset control anchor' });
}
});
fleetRouter.get('/sync-status', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;