sc-site-sync/lib/transport.js
2026-10-19 01:25:16 -05:00

95 lines
3.8 KiB
JavaScript

// Outbound signed peer requests -- site-sync's binding of the shared client
// (lib/common/peerClient.js, which documents the protocol: every response
// signature is verified BEFORE the body is parsed or used, bodies are byte-capped
// and every exchange is bounded by a timeout). The target side mutates itself
// from what a pull returns, so an unauthenticated response must never be acted on.
const { createPeerClient, parseJson } = require("./common/peerClient");
const { HEADERS, PEER_CONTENT_TYPE } = require("./constants");
const { peerSecret } = require("./peers");
// The machine API (routes.js registers these; index.js exempts the prefix from CSRF).
const API_PREFIX = "/site-sync/api/";
const API_PATHS = {
HEALTH: `${API_PREFIX}health`,
STATE: `${API_PREFIX}state`,
PLAN: `${API_PREFIX}plan`,
APPLY: `${API_PREFIX}apply`,
FILE_ROUTE: `${API_PREFIX}file/:hash`,
file: (hash) => `${API_PREFIX}file/${hash}`
};
// Wire limits shared by both directions of the peer protocol.
const PEER_LIMITS = {
// Whole-request timeout for an outbound peer call (headers + body).
TIMEOUT_MS: 2 * 60 * 1000,
// Largest signed request body a peer will accept (a pushed desired-state pack).
REQUEST_BODY_BYTES: 64 * 1024 * 1024,
// Largest JSON response a client will read (/api/state: pack + row manifest).
JSON_RESPONSE_BYTES: 128 * 1024 * 1024,
// Largest file served / fetched over GET /api/file/:hash.
FILE_BYTES: 256 * 1024 * 1024
};
const client = createPeerClient({ headers: HEADERS, contentType: PEER_CONTENT_TYPE });
// The one signed round trip (see lib/common/peerClient.js signedRequest). The
// timeout is read per call so it can be tuned at runtime (tests shorten it).
const signedRequest = async (opts) => {
return await client.signedRequest({ ...opts, timeoutMs: PEER_LIMITS.TIMEOUT_MS });
};
// Shared per-peer wiring: the paired secret + TLS flag come from the peer row.
const requestPeer = async (peerRow, sourceEnvId, method, path, body, maxBytes) => {
return await signedRequest({
baseUrl: peerRow.base_url,
method: method,
path: path,
body: body,
sourceEnvId: sourceEnvId,
secret: await peerSecret(peerRow.peer_id),
requireTls: peerRow.require_tls,
maxBytes: maxBytes
});
};
// Signed JSON call to a paired peer. Returns the parsed body of a 2xx response;
// throws on a non-2xx status (with the peer's signed error message), on a body
// that is not JSON, and when a response naming an env_id names a different one
// than the paired peer's (the responder is not who this pairing says it is).
const peerJson = async (peerRow, sourceEnvId, method, path, body) => {
const r = await requestPeer(peerRow, sourceEnvId, method, path, body, PEER_LIMITS.JSON_RESPONSE_BYTES);
const parsed = parseJson(r);
if (!r.ok) {
throw new Error(`peer ${path} returned status ${r.status}: ${parsed && parsed.error ? parsed.error : "(no error message)"}`);
}
if (parsed && parsed.env_id !== undefined && parsed.env_id !== peerRow.env_id) {
throw new Error(`peer ${path} answered as env_id ${parsed.env_id}, but this pairing is for ${peerRow.env_id}`);
}
return parsed;
};
// Signed binary GET from a paired peer (GET /site-sync/api/file/:hash). Returns the
// verified bytes of a 2xx response; throws otherwise.
const peerBytes = async (peerRow, sourceEnvId, path) => {
const r = await requestPeer(peerRow, sourceEnvId, "GET", path, null, PEER_LIMITS.FILE_BYTES);
if (!r.ok) {
throw new Error(`peer ${path} returned status ${r.status}`);
}
return r.bytes;
};
module.exports = {
API_PREFIX,
API_PATHS,
PEER_LIMITS,
signedRequest,
peerJson,
peerBytes
};