mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
fix(fleet-sync): version the wire protocol and serialize per-node pushes (#967)
Hardens the scan-policy and CVE-suppression replication channel as the foundation of a multi-PR fleet sync hardening track. No new endpoints, no new tables, no schema changes; receivers still tolerate legacy payloads (absent pushedAt and controlIdentity) for rollout safety. Wire protocol: - Sender stamps every push with a strictly-increasing pushedAt and a placeholder controlIdentity. Receiver rejects strictly-older pushedAt with 409 STALE_SYNC_PUSH so the next write retries. - pushedAt comparison plus row replacement plus watermark write run in a single SQLite transaction; a partial-write window cannot leave the watermark behind the row state. Concurrency and limits: - Per-node mutex on the sender so concurrent control writes serialize per remote and never apply older state on top of newer. - Sender-side row cap at MAX_SYNC_ROWS=5000 with a 6-hour throttled truncation alert so flapping configs cannot flood the operator. - Route-level body limit raised to 5MB on POST /api/fleet/sync/:resource only; the global 100KB cap is unchanged. Oversize bodies return a structured 413 SYNC_PAYLOAD_TOO_LARGE. Determinism and hygiene: - getMatchingPolicy gains an id-ASC tiebreaker so two replicas resolve the same winner when policies tie on scope class. - Inline comment documents why getMatchingPolicy filters node_id at SQL yet still relies on JS identity matching for replicated rows. - Comment on the receive endpoint documents why no requirePaid is enforced (control's tier authorizes; replica trusts the bearer). - STALE_SYNC_PUSH 409s no longer record a node failure; they are expected protocol outcomes, not health issues. Public surface additions: - DatabaseService.transaction(fn): generic SAVEPOINT-friendly wrapper. - DatabaseService.getLocalScanPolicies / getLocalCveSuppressions: SQL filter on replicated_from_control = 0. - StaleSyncPushError: typed sentinel the route translates to 409. - fleetSyncConstants: shared MAX_SYNC_ROWS, body limit, state-key and error-code maps so the wire protocol has one source of truth. Tests: - 24 new vitest cases across fleet-sync-service, fleet-sync-routes, and database-matching-policy covering monotonic pushedAt, per-node serialization, row truncation and throttle, stale-push suppression, receiver back-compat, oversize-body 413, deterministic matching. - Full backend suite: 1757 pass / 5 skipped.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import express, { type Request, type Response, type NextFunction, type RequestHandler } from 'express';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { isProxyExemptPath } from '../helpers/proxyExemptPaths';
|
||||
import { SYNC_BODY_LIMIT, SYNC_ERROR_CODES, SYNC_PATH_PREFIX } from '../services/fleetSyncConstants';
|
||||
|
||||
// JSON body parser that also captures the raw bytes for HMAC verification.
|
||||
// `rawBody` is part of the Express.Request augmentation (see types/express.ts);
|
||||
@@ -12,6 +13,16 @@ const jsonParser = express.json({
|
||||
},
|
||||
});
|
||||
|
||||
// Larger-limit parser for the fleet sync receive endpoint. A control instance
|
||||
// can push up to MAX_SYNC_ROWS rows in a single payload; the default 100 KB
|
||||
// limit is too tight for that.
|
||||
const fleetSyncJsonParser = express.json({
|
||||
limit: SYNC_BODY_LIMIT,
|
||||
verify: (req, _res, buf) => {
|
||||
(req as unknown as Request).rawBody = buf;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse JSON on local requests but preserve the raw stream for remote proxy
|
||||
* forwarding.
|
||||
@@ -33,5 +44,23 @@ export const conditionalJsonParser: RequestHandler = (req: Request, res: Respons
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fleet sync receive endpoint accepts larger payloads (up to MAX_SYNC_ROWS).
|
||||
// The 100 KB default would 413 long before the row-count check runs. Translate
|
||||
// body-parser's PayloadTooLargeError into a structured response with a
|
||||
// sync-specific code so the control's retry logic can distinguish it from
|
||||
// generic 413s.
|
||||
if (req.path.startsWith(SYNC_PATH_PREFIX)) {
|
||||
fleetSyncJsonParser(req, res, (err?: unknown) => {
|
||||
if (err && (err as { type?: string })?.type === 'entity.too.large') {
|
||||
res.status(413).json({
|
||||
error: 'Sync payload too large. Reduce policy or suppression count and retry.',
|
||||
code: SYNC_ERROR_CODES.payloadTooLarge,
|
||||
});
|
||||
return;
|
||||
}
|
||||
next(err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
jsonParser(req, res, next);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user