mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 04:38:11 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user