// 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 };