d27763e556
The shipped join flow (v2.2.0-v2.3.0) was a one-time snapshot: a spoke's catalog never updated after joining. This adds the two pieces that were explicitly designed but missing: - Live replication: a spoke registers its own endpoint with the master right after joining (POST /api/site/spokes, Bearer join-key), receiving a pushToken. Every successful catalog write on the master now fires a fire-and-forget resync ping (utils/site_replicate.js) at every known spoke, concurrently -- one unreachable spoke never blocks or delays another (wired into the existing write-gate middleware in api_directory_admin.js). The spoke's POST /api/site/resync handler reuses the already-tested export+import path rather than applying a partial diff. - Identical directories: POST /api/site/export now best-effort includes the master's agent-signing key; a spoke adopts it via agent_keys.adopt() on both join and every resync, so every site's sso-manager can validly sign a command for any agent enrolled anywhere -- the accepted tradeoff discussed for this deployment's scale (blast radius for simplicity). New SiteSpoke model tracks registered spokes (endpoint + pushToken); registered it in models/index.js (a real bug the e2e test below caught -- SiteSpoke.list() 500'd with "Cannot read properties of null (reading 'adapter')" until the model was added to initORM's model list). Verified end-to-end against docker-compose.multisite-e2e.yml: mint join key -> join with selfUrl -> write a NEW resource on master post-join -> poll the spoke -> it shows up within a few seconds via the resync push, no manual re-join needed. MULTISITE E2E PASS. Unit tests: nodejs/tests/site_replicate.test.js (concurrent fan-out, one failing spoke doesn't block another, empty-registry and list()-throws edge cases).
124 lines
4.8 KiB
JavaScript
124 lines
4.8 KiB
JavaScript
'use strict';
|
|
|
|
// The Ed25519 key pair the SSO signs high-risk agent commands with, stored in
|
|
// OpenBao at `secret/agent/signing-key`.
|
|
//
|
|
// This used to be generated in the AgentManager constructor and kept only in
|
|
// memory, which made the whole signing scheme decorative: every SSO restart
|
|
// produced a new key, so the `public_key` pinned in an agent's agent.yml stopped
|
|
// matching and the agent either rejected everything or (because it skips
|
|
// verification when no key is configured) executed everything unverified. A
|
|
// trust anchor that changes on restart is not a trust anchor.
|
|
//
|
|
// Requires the sso-broker OpenBao policy to grant `secret/agent/*`
|
|
// (theta-suite setup.sh). Without it the load fails and signing is reported as
|
|
// unavailable -- we deliberately do NOT fall back to an ephemeral key, because
|
|
// signing with a key no agent has ever seen is worse than refusing: it looks
|
|
// like it worked.
|
|
|
|
const crypto = require('crypto');
|
|
const baoConf = require('@simpleworkjs/bao-conf');
|
|
|
|
const PATH = 'agent/signing-key'; // baoConf adds the secret/data prefix
|
|
|
|
let cached = null; // { privateKeyPem, publicKeyPem, publicKeyBase64 }
|
|
let loadError = null;
|
|
|
|
// Agents pin the raw 32-byte Ed25519 public key, base64-encoded (see the Go
|
|
// client's verifySignature, which base64-decodes cfg.public_key and expects
|
|
// ed25519.PublicKeySize bytes). Node hands us SPKI PEM, so strip the 12-byte
|
|
// DER prefix to get the raw key the agent actually wants.
|
|
function rawPublicKeyBase64(publicKeyPem) {
|
|
const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
|
|
return Buffer.from(der.subarray(der.length - 32)).toString('base64');
|
|
}
|
|
|
|
function generate() {
|
|
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
|
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
publicKeyEncoding: { type: 'spki', format: 'pem' }
|
|
});
|
|
return { privateKeyPem: privateKey, publicKeyPem: publicKey };
|
|
}
|
|
|
|
// Load the stored key pair, generating and persisting one on first run.
|
|
// Idempotent and safe to call repeatedly; the result is cached in-process.
|
|
async function load() {
|
|
if (cached) return cached;
|
|
|
|
let stored = null;
|
|
try {
|
|
stored = await baoConf.get(PATH);
|
|
} catch (err) {
|
|
loadError = `could not read ${PATH} from OpenBao: ${err.message}`;
|
|
console.error(`[agent_keys] ${loadError}`);
|
|
return null;
|
|
}
|
|
|
|
if (stored && stored.privateKeyPem && stored.publicKeyPem) {
|
|
cached = {
|
|
privateKeyPem: stored.privateKeyPem,
|
|
publicKeyPem: stored.publicKeyPem,
|
|
publicKeyBase64: rawPublicKeyBase64(stored.publicKeyPem)
|
|
};
|
|
loadError = null;
|
|
return cached;
|
|
}
|
|
|
|
// First run: mint one and persist it before use, so a crash between
|
|
// generating and storing can't leave agents pinned to a key we forgot.
|
|
const fresh = generate();
|
|
try {
|
|
await baoConf.set(PATH, fresh);
|
|
} catch (err) {
|
|
loadError = `could not persist a signing key to ${PATH}: ${err.message}. `
|
|
+ 'Re-run ./setup.sh so the sso-broker policy grants secret/agent/*.';
|
|
console.error(`[agent_keys] ${loadError}`);
|
|
return null;
|
|
}
|
|
|
|
cached = {
|
|
...fresh,
|
|
publicKeyBase64: rawPublicKeyBase64(fresh.publicKeyPem)
|
|
};
|
|
loadError = null;
|
|
console.log('[agent_keys] generated and stored a new agent signing key');
|
|
return cached;
|
|
}
|
|
|
|
function status() {
|
|
return { available: !!cached, error: loadError };
|
|
}
|
|
|
|
// Overwrite the stored key with material handed over by a master (multi-site
|
|
// "identical directories" — MULTI_SITE_SPEC.md §2). Every site sharing one
|
|
// signing key is what lets any site's sso-manager validly sign a command for
|
|
// an agent enrolled at any other site, at the accepted cost that compromising
|
|
// ANY one site's OpenBao is equivalent to compromising all of them for agent
|
|
// command authority. That tradeoff was deliberately accepted for this
|
|
// deployment's scale (a handful of trusted sites) -- do not call this to sync
|
|
// keys across a boundary where sites don't trust each other equally.
|
|
//
|
|
// Idempotent: adopting the same key material twice (e.g. on every resync
|
|
// ping) is a no-op past the first call.
|
|
async function adopt({ privateKeyPem, publicKeyPem }) {
|
|
if (!privateKeyPem || !publicKeyPem) throw new Error('adopt() requires both privateKeyPem and publicKeyPem');
|
|
if (cached && cached.privateKeyPem === privateKeyPem && cached.publicKeyPem === publicKeyPem) {
|
|
return cached; // already holding this exact key -- nothing to do
|
|
}
|
|
const material = { privateKeyPem, publicKeyPem };
|
|
await baoConf.set(PATH, material);
|
|
cached = { ...material, publicKeyBase64: rawPublicKeyBase64(publicKeyPem) };
|
|
loadError = null;
|
|
console.log('[agent_keys] adopted signing key from master (multi-site identical-directory sync)');
|
|
return cached;
|
|
}
|
|
|
|
// Test seam: drop the in-process cache.
|
|
function _reset() {
|
|
cached = null;
|
|
loadError = null;
|
|
}
|
|
|
|
module.exports = { load, status, adopt, rawPublicKeyBase64, _reset, PATH };
|