feat(multi-site): live catalog replication + identical-directory signing key

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).
This commit is contained in:
2026-08-10 16:34:38 -04:00
parent e5167729a8
commit d27763e556
9 changed files with 451 additions and 57 deletions
+2 -1
View File
@@ -22,6 +22,7 @@ const { SharedSecretGrant } = require('./shared_secret_grant');
const { VaultAppToken } = require('./vault_app_token'); const { VaultAppToken } = require('./vault_app_token');
const { Agent, AgentJoinKey } = require('./agent'); const { Agent, AgentJoinKey } = require('./agent');
const { SiteJoinKey } = require('./site_join_key'); const { SiteJoinKey } = require('./site_join_key');
const { SiteSpoke } = require('./site_spoke');
async function initORM() { async function initORM() {
const ormConf = conf.orm || { const ormConf = conf.orm || {
dialect: 'sqlite', dialect: 'sqlite',
@@ -36,7 +37,7 @@ async function initORM() {
conf: { orm: ormConf }, conf: { orm: ormConf },
models: [ models: [
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance, Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey, SiteJoinKey, SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey, SiteJoinKey, SiteSpoke,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
] ]
}); });
+42
View File
@@ -0,0 +1,42 @@
'use strict';
const crypto = require('crypto');
const { Model } = require('@simpleworkjs/orm');
// A spoke known to THIS node while it's acting as master — the registry that
// makes live replication possible. A spoke registers itself here (POST
// /api/site/spokes, authenticated by the same join key it used to join)
// right after adopting the master's export, handing over its own reachable
// endpoint. In return it's issued a `pushToken`: a shared secret the master
// then presents on every future POST <spoke endpoint>/api/site/resync call.
//
// This is a DIFFERENT credential direction than SiteJoinKey: a join key is
// presented TO the master and only ever needs to be verified (so it's stored
// hashed, like a password). pushToken is presented BY the master, repeatedly,
// so it has to be retrievable here -- there is no getting around storing it
// in plaintext on the master, the same way Webhook.secret is (see
// services/webhook_emitter.js) for the same reason (an HMAC/bearer credential
// the sender must keep re-presenting, not a one-time secret only ever
// verified).
class SiteSpoke extends Model {
static generatePushToken() {
return crypto.randomBytes(24).toString('base64url');
}
static fields = {
id: { type: 'uuid', primaryKey: true },
endpoint: { type: 'string', isRequired: true, unique: true },
siteSlug: { type: 'string' },
pushToken: { type: 'string', isRequired: true },
created_on: { type: 'integer' },
last_seen_on: { type: 'integer' }
};
toPublic() {
const data = this.toJSON ? this.toJSON() : { ...this };
delete data.pushToken;
return data;
}
}
module.exports = { SiteSpoke };
+1 -1
View File
@@ -11,7 +11,7 @@
"scripts": { "scripts": {
"start": "node ./bin/www", "start": "node ./bin/www",
"dev": "npx nodemon --ignore public/ ./bin/www", "dev": "npx nodemon --ignore public/ ./bin/www",
"test": "NODE_ENV=test jest tests/groups.test.js tests/subtypes.test.js tests/site_join.test.js tests/site_config.test.js --forceExit" "test": "NODE_ENV=test jest tests/groups.test.js tests/subtypes.test.js tests/site_join.test.js tests/site_config.test.js tests/site_replicate.test.js --forceExit"
}, },
"jest": { "jest": {
"testEnvironment": "node", "testEnvironment": "node",
+16 -2
View File
@@ -9,6 +9,7 @@ const { projectResources } = require('@simpleworkjs/directory-schema');
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP; const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
const groups = require('../utils/groups'); const groups = require('../utils/groups');
const meshReplicate = require('../utils/site_replicate');
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is // Make `childCn` a member of `parentCn`, i.e. everyone in the child is
// transitively in the parent. Idempotent and non-fatal: "already a member" is // transitively in the parent. Idempotent and non-fatal: "already a member" is
@@ -252,19 +253,32 @@ router.get('/resources', async (req, res, next) => {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// ── Spoke read-only enforcement ───────────────────────────────────────────── // ── Spoke read-only enforcement + live replication trigger ──────────────────
// On a joined spoke the catalog is a copy of the master's; directory writes // On a joined spoke the catalog is a copy of the master's; directory writes
// must go to the master (MULTI_SITE_SPEC.md — spoke = read-only catalog). Any // must go to the master (MULTI_SITE_SPEC.md — spoke = read-only catalog). Any
// mutating request below this point is rejected on a spoke with a pointer to // mutating request below this point is rejected on a spoke with a pointer to
// the master. (site-status / site-promote live AFTER this middleware and are // the master. (site-status / site-promote live AFTER this middleware and are
// not directory writes.) // not directory writes.)
//
// On the MASTER, a successful mutation here fires a fire-and-forget resync
// push (utils/site_replicate.js) at every registered spoke, so the shipped
// join flow's one-time snapshot doesn't go stale the moment the catalog
// changes. Fires on res.on('finish') (after the response is actually sent,
// status known) rather than before the handler runs, so a write that fails
// validation never triggers a pointless replication round-trip.
router.use((req, res, next) => { router.use((req, res, next) => {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) { const mutating = ['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method);
if (mutating) {
const cfg = siteConfig.get(); const cfg = siteConfig.get();
if (!cfg.isMaster) { if (!cfg.isMaster) {
const hint = cfg.masterUrl ? ' Directory writes must go to the master at ' + cfg.masterUrl + '.' : ''; const hint = cfg.masterUrl ? ' Directory writes must go to the master at ' + cfg.masterUrl + '.' : '';
return res.status(403).json({ status: 'error', message: 'This node is a spoke (read-only catalog).' + hint }); return res.status(403).json({ status: 'error', message: 'This node is a spoke (read-only catalog).' + hint });
} }
res.on('finish', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
meshReplicate.replicateToSpokes(`${req.method} ${req.path}`);
}
});
} }
next(); next();
}); });
+195 -50
View File
@@ -14,6 +14,7 @@
// calls it with a join key), so it is defined BEFORE the auth middleware. // calls it with a join key), so it is defined BEFORE the auth middleware.
const express = require('express'); const express = require('express');
const crypto = require('crypto');
const { execFile } = require('child_process'); const { execFile } = require('child_process');
const { promisify } = require('util'); const { promisify } = require('util');
const os = require('os'); const os = require('os');
@@ -25,10 +26,13 @@ const permission = require('../utils/permission');
const conf = require('@simpleworkjs/conf'); const conf = require('@simpleworkjs/conf');
const { Resource, ResourceEdge } = require('../models/resource'); const { Resource, ResourceEdge } = require('../models/resource');
const { SiteJoinKey } = require('../models/site_join_key'); const { SiteJoinKey } = require('../models/site_join_key');
const { SiteSpoke } = require('../models/site_spoke');
const { replicateToSpokes } = require('../utils/site_replicate');
const User = require('../models/user'); const User = require('../models/user');
const { Agent } = require('../models/agent'); const { Agent } = require('../models/agent');
const siteConfig = require('../utils/site_config'); const siteConfig = require('../utils/site_config');
const { importDirectory, ldapAddArgs, baseDnFrom, siteIsFresh } = require('../utils/site_join'); const { importDirectory, ldapAddArgs, baseDnFrom, siteIsFresh } = require('../utils/site_join');
const agentKeys = require('../utils/agent_keys');
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const router = express.Router(); const router = express.Router();
@@ -63,10 +67,16 @@ router.post('/export', async (req, res, next) => {
const key = await SiteJoinKey.authenticate(rawKey); const key = await SiteJoinKey.authenticate(rawKey);
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' }); if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
const [ldif, resources, edges] = await Promise.all([ const [ldif, resources, edges, signingKey] = await Promise.all([
slurpLdif(), slurpLdif(),
Resource.list(), Resource.list(),
ResourceEdge.list() ResourceEdge.list(),
// Best-effort: a master with no OpenBao reachable (or no key generated
// yet) still exports successfully -- signingKey is just omitted, and
// the spoke keeps whatever key (if any) it already has. Identical
// signing keys across sites is a nice-to-have on top of the join
// working at all, never a reason to fail the join.
agentKeys.load().then((k) => k && { privateKeyPem: k.privateKeyPem, publicKeyPem: k.publicKeyPem }).catch(() => null)
]); ]);
await key.update({ use_count: (key.use_count || 0) + 1, last_used_on: Math.floor(Date.now() / 1000) }).catch(() => {}); await key.update({ use_count: (key.use_count || 0) + 1, last_used_on: Math.floor(Date.now() / 1000) }).catch(() => {});
@@ -77,7 +87,8 @@ router.post('/export', async (req, res, next) => {
baseDn: baseDnFrom(conf), baseDn: baseDnFrom(conf),
ldif, ldif,
resources: (resources || []).map(r => (r.toJSON ? r.toJSON() : r)), resources: (resources || []).map(r => (r.toJSON ? r.toJSON() : r)),
edges: (edges || []).map(e => (e.toJSON ? e.toJSON() : e)) edges: (edges || []).map(e => (e.toJSON ? e.toJSON() : e)),
...(signingKey ? { signingKey } : {})
}); });
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
@@ -95,6 +106,70 @@ router.post('/ping', async (req, res, next) => {
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
// ── Spoke registration (MASTER side, Bearer site-join-key; no admin session)
// A spoke calls this right after adopting a join, handing over its own
// reachable endpoint so the master can push live-replication resync pings to
// it later (see utils/site_replicate.js). Idempotent on endpoint: calling it
// again (e.g. a spoke re-registering after its own restart) returns the same
// pushToken rather than minting a new one, so the spoke doesn't need to
// re-learn a credential it already has.
router.post('/spokes', async (req, res, next) => {
try {
const auth = req.headers.authorization || '';
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
const key = await SiteJoinKey.authenticate(rawKey);
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
const { endpoint, siteSlug } = req.body || {};
if (!endpoint || !/^https?:\/\//.test(endpoint)) {
return res.status(400).json({ status: 'error', message: 'a valid http(s) endpoint is required' });
}
const now = Math.floor(Date.now() / 1000);
let spoke = (await SiteSpoke.list({ where: { endpoint } }))[0];
if (spoke) {
await spoke.update({ siteSlug: siteSlug || spoke.siteSlug, last_seen_on: now });
} else {
spoke = await SiteSpoke.create({
id: crypto.randomUUID(),
endpoint,
siteSlug: siteSlug || null,
pushToken: SiteSpoke.generatePushToken(),
created_on: now,
last_seen_on: now
});
}
logAudit('spoke_registered', { endpoint, siteSlug: spoke.siteSlug });
res.json({ status: 'ok', pushToken: spoke.pushToken });
} catch (e) { next(e); }
});
// ── Resync (SPOKE side, Bearer pushToken; no admin session) ─────────────────
// The receiving end of utils/site_replicate.js's fire-and-forget push: the
// master pings this when its catalog changes. Deliberately just
// re-runs the same export-pull + import this node already did at join time
// (adoptFromMaster below) rather than applying a partial diff -- one tested
// code path for "make my catalog match the master's," not two.
router.post('/resync', async (req, res, next) => {
try {
const cfg = siteConfig.get();
if (cfg.isMaster) return res.status(400).json({ status: 'error', message: 'this node is master; resync is a spoke-only operation' });
const auth = req.headers.authorization || '';
const presented = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
if (!cfg.replicationPushToken || presented !== cfg.replicationPushToken) {
return res.status(401).json({ status: 'error', message: 'invalid resync push token' });
}
if (!cfg.masterUrl || !cfg.masterJoinKey) {
return res.status(409).json({ status: 'error', message: 'no master join credentials on file' });
}
const imp = await adoptFromMaster({ masterUrl: cfg.masterUrl, joinKey: cfg.masterJoinKey });
logAudit('resynced', { reason: (req.body && req.body.reason) || 'unspecified', resourcesCreated: imp.created, resourcesUpdated: imp.updated });
res.json({ status: 'ok', resources: { created: imp.created, updated: imp.updated, edges: imp.edgeCount } });
} catch (e) { next(e); }
});
// ── Everything below requires an admin session ────────────────────────────── // ── Everything below requires an admin session ──────────────────────────────
router.use(middleware.auth); router.use(middleware.auth);
router.use(async (req, res, next) => { router.use(async (req, res, next) => {
@@ -161,9 +236,77 @@ router.delete('/join-keys/:id', async (req, res, next) => {
// role. Only valid on a node that is currently the master (i.e. a fresh // role. Only valid on a node that is currently the master (i.e. a fresh
// bring-up that has not joined anything yet) — see setup.sh wiring for the // bring-up that has not joined anything yet) — see setup.sh wiring for the
// pre-seed timing (this pass is server endpoints only). // pre-seed timing (this pass is server endpoints only).
// Shared by /join (first adoption) and /resync (live-replication re-pull):
// fetch the master's export and apply it locally (catalog + LDAP). Throws on
// any failure that should surface as a 502 to the caller.
async function adoptFromMaster({ masterUrl, joinKey }) {
const base = String(masterUrl).replace(/\/+$/, '');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30000);
let resp;
try {
resp = await fetch(base + '/api/site/export', {
method: 'POST',
headers: { Authorization: 'Bearer ' + joinKey, 'Content-Type': 'application/json' },
body: '{}',
signal: controller.signal
});
} finally { clearTimeout(timer); }
if (!resp.ok) {
const text = (await resp.text().catch(() => '')).slice(0, 200);
const err = new Error('master export failed: HTTP ' + resp.status + ' ' + text);
err.httpStatus = 502;
throw err;
}
const exportData = await resp.json();
if (!exportData || exportData.status !== 'ok' || !exportData.ldif) {
const err = new Error('master export returned no directory');
err.httpStatus = 502;
throw err;
}
// 1. Adopt the resource catalog.
const imp = await importDirectory({ Resource, ResourceEdge, exportData });
// 2. Adopt the LDAP tree. The spoke keeps its own cn=admin / base DN;
// ldapadd -c skips existing entries, so users/groups come from master.
let ldapNote = 'imported';
try {
const adminDn = conf.ldap && conf.ldap.bindDN;
// The admin credential for the local slapd (read from config at runtime —
// never hardcoded; named without the literal "password" keyword so secret
// scanners don't false-positive on a variable assignment).
const ldapCred = conf.ldap && conf.ldap.bindPassword;
const ldifFile = path.join(os.tmpdir(), 'theta-site-join.ldif');
fs.writeFileSync(ldifFile, exportData.ldif, 'utf8');
const argv = ldapAddArgs({ bindDN: adminDn, ldapCred, ldifFile, ldapUrl: conf.ldap && conf.ldap.url });
await execFileAsync(argv[0], argv.slice(1), { maxBuffer: 4 * 1024 * 1024, timeout: 120000 });
fs.unlink(ldifFile).catch(() => {});
} catch (e) {
ldapNote = 'skipped/failed: ' + e.message;
}
// 3. Adopt the master's agent-signing key, if it sent one (MULTI_SITE_SPEC.md
// §2 -- identical directories). Best-effort: OpenBao being unreachable
// here shouldn't fail a join/resync any more than it would on a
// standalone install.
let signingKeyNote = 'not provided by master';
if (exportData.signingKey) {
try {
await agentKeys.adopt(exportData.signingKey);
signingKeyNote = 'adopted';
} catch (e) {
signingKeyNote = 'failed: ' + e.message;
}
}
return { imp, ldapNote, signingKeyNote, exportData, base };
}
router.post('/join', async (req, res, next) => { router.post('/join', async (req, res, next) => {
try { try {
const { masterUrl, joinKey } = req.body || {}; const { masterUrl, joinKey, selfUrl } = req.body || {};
if (!masterUrl || !joinKey) { if (!masterUrl || !joinKey) {
return res.status(400).json({ status: 'error', message: 'masterUrl and joinKey are required' }); return res.status(400).json({ status: 'error', message: 'masterUrl and joinKey are required' });
} }
@@ -181,54 +324,52 @@ router.post('/join', async (req, res, next) => {
}); });
} }
const base = String(masterUrl).replace(/\/+$/, ''); let adopted;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30000);
let resp;
try { try {
resp = await fetch(base + '/api/site/export', { adopted = await adoptFromMaster({ masterUrl, joinKey });
method: 'POST',
headers: { Authorization: 'Bearer ' + joinKey, 'Content-Type': 'application/json' },
body: '{}',
signal: controller.signal
});
} finally { clearTimeout(timer); }
if (!resp.ok) {
const text = (await resp.text().catch(() => '')).slice(0, 200);
return res.status(502).json({ status: 'error', message: 'master export failed: HTTP ' + resp.status + ' ' + text });
}
const exportData = await resp.json();
if (!exportData || exportData.status !== 'ok' || !exportData.ldif) {
return res.status(502).json({ status: 'error', message: 'master export returned no directory' });
}
// 1. Adopt the resource catalog.
const imp = await importDirectory({ Resource, ResourceEdge, exportData });
// 2. Adopt the LDAP tree. The spoke keeps its own cn=admin / base DN;
// ldapadd -c skips existing entries, so users/groups come from master.
let ldapNote = 'imported';
try {
const adminDn = conf.ldap && conf.ldap.bindDN;
// The admin credential for the local slapd (read from config at runtime —
// never hardcoded; named without the literal "password" keyword so secret
// scanners don't false-positive on a variable assignment).
const ldapCred = conf.ldap && conf.ldap.bindPassword;
const ldifFile = path.join(os.tmpdir(), 'theta-site-join.ldif');
fs.writeFileSync(ldifFile, exportData.ldif, 'utf8');
const argv = ldapAddArgs({ bindDN: adminDn, ldapCred, ldifFile, ldapUrl: conf.ldap && conf.ldap.url });
await execFileAsync(argv[0], argv.slice(1), { maxBuffer: 4 * 1024 * 1024, timeout: 120000 });
fs.unlink(ldifFile).catch(() => {});
} catch (e) { } catch (e) {
ldapNote = 'skipped/failed: ' + e.message; return res.status(e.httpStatus || 502).json({ status: 'error', message: e.message });
}
const { imp, ldapNote, signingKeyNote, exportData, base } = adopted;
// 3. Register with the master for live replication, if this node knows
// its own reachable endpoint (selfUrl -- see setup.env's
// CFG_SELF_DIRECTORY_URL). Best-effort: a spoke that can't/won't
// register still joins successfully, it just won't receive live
// resync pushes (falls back to being exactly today's one-time
// snapshot for that spoke, not a hard failure).
let replicationPushToken = null;
let replicationNote = 'not registered (no selfUrl given)';
if (selfUrl) {
try {
const regResp = await fetch(base + '/api/site/spokes', {
method: 'POST',
headers: { Authorization: 'Bearer ' + joinKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: selfUrl, siteSlug: exportData.siteSlug || cfg.siteSlug })
});
if (regResp.ok) {
const regBody = await regResp.json();
replicationPushToken = regBody.pushToken;
replicationNote = 'registered for live replication';
} else {
replicationNote = 'registration failed: HTTP ' + regResp.status;
}
} catch (e) {
replicationNote = 'registration failed: ' + e.message;
}
} }
// 3. Persist the spoke role (survives restarts). The join key is kept so // 4. Persist the spoke role (survives restarts). The join key is kept so
// the spoke can run WAN-health checks (and, in a later layer, proxy // the spoke can run WAN-health checks against the master; the push
// writes) against the master — it is a spoke-to-master credential, not // token (if registration succeeded) is what authenticates the
// a shared secret. // master's future resync pushes back to THIS node.
siteConfig.save({ isMaster: false, masterUrl: base, siteSlug: exportData.siteSlug || cfg.siteSlug, masterJoinKey: joinKey }); siteConfig.save({
isMaster: false,
masterUrl: base,
siteSlug: exportData.siteSlug || cfg.siteSlug,
masterJoinKey: joinKey,
...(replicationPushToken ? { replicationPushToken } : {})
});
logAudit('joined', { logAudit('joined', {
actor: req.user.uid, actor: req.user.uid,
@@ -237,7 +378,9 @@ router.post('/join', async (req, res, next) => {
resourcesCreated: imp.created, resourcesCreated: imp.created,
resourcesUpdated: imp.updated, resourcesUpdated: imp.updated,
edges: imp.edgeCount, edges: imp.edgeCount,
ldap: ldapNote ldap: ldapNote,
signingKey: signingKeyNote,
replication: replicationNote
}); });
res.json({ res.json({
@@ -245,7 +388,9 @@ router.post('/join', async (req, res, next) => {
message: 'Joined master site ' + base, message: 'Joined master site ' + base,
siteSlug: exportData.siteSlug || cfg.siteSlug, siteSlug: exportData.siteSlug || cfg.siteSlug,
resources: { created: imp.created, updated: imp.updated, edges: imp.edgeCount }, resources: { created: imp.created, updated: imp.updated, edges: imp.edgeCount },
ldap: { note: ldapNote } ldap: { note: ldapNote },
signingKey: { note: signingKeyNote },
replication: { note: replicationNote, live: !!replicationPushToken }
}); });
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
+90
View File
@@ -0,0 +1,90 @@
'use strict';
// In-memory stand-in for the SiteSpoke ORM model.
let spokeStore;
function makeSpokeMock() {
spokeStore = [];
return {
list: jest.fn(async () => [...spokeStore]),
_seed(rows) { spokeStore.push(...rows); }
};
}
let mockFetchCalls = [];
let mockFetchImpl = async () => ({ ok: true, status: 200 });
describe('site_replicate', () => {
let siteReplicate;
let SiteSpoke;
let originalFetch;
beforeEach(() => {
jest.resetModules();
mockFetchCalls = [];
mockFetchImpl = async () => ({ ok: true, status: 200 });
jest.doMock('../models/site_spoke', () => ({ SiteSpoke: makeSpokeMock() }));
siteReplicate = require('../utils/site_replicate');
SiteSpoke = require('../models/site_spoke').SiteSpoke;
// site_replicate.js uses the global fetch (Node 18+ built-in), not
// node-fetch -- stub that directly.
originalFetch = global.fetch;
global.fetch = (...args) => { mockFetchCalls.push(args); return mockFetchImpl(...args); };
});
afterEach(() => {
global.fetch = originalFetch;
});
test('pushes to every known spoke concurrently with its own pushToken', async () => {
SiteSpoke._seed([
{ endpoint: 'https://spoke-a.example.com', pushToken: 'token-a' },
{ endpoint: 'https://spoke-b.example.com', pushToken: 'token-b' }
]);
await siteReplicate.replicateToSpokes('catalog-changed');
await new Promise((r) => setImmediate(r));
expect(mockFetchCalls.length).toBe(2);
const urls = mockFetchCalls.map((c) => c[0]).sort();
expect(urls).toEqual(['https://spoke-a.example.com/api/site/resync', 'https://spoke-b.example.com/api/site/resync']);
const [, optsA] = mockFetchCalls.find((c) => c[0].includes('spoke-a'));
expect(optsA.headers.Authorization).toBe('Bearer token-a');
expect(JSON.parse(optsA.body).reason).toBe('catalog-changed');
});
test('no known spokes: resolves cleanly, no fetch calls', async () => {
await siteReplicate.replicateToSpokes('catalog-changed');
await new Promise((r) => setImmediate(r));
expect(mockFetchCalls.length).toBe(0);
});
test('one spoke failing does not prevent delivery to another', async () => {
SiteSpoke._seed([
{ endpoint: 'https://dead-spoke.example.com', pushToken: 'token-dead' },
{ endpoint: 'https://live-spoke.example.com', pushToken: 'token-live' }
]);
mockFetchImpl = async (url) => {
if (url.includes('dead-spoke')) throw new Error('connection refused');
return { ok: true, status: 200 };
};
await expect(siteReplicate.replicateToSpokes('event')).resolves.toBeUndefined();
await new Promise((r) => setImmediate(r));
expect(mockFetchCalls.length).toBe(2);
});
test('a non-2xx response from a spoke does not throw out of replicateToSpokes', async () => {
SiteSpoke._seed([{ endpoint: 'https://spoke-a.example.com', pushToken: 'token-a' }]);
mockFetchImpl = async () => ({ ok: false, status: 500 });
await expect(siteReplicate.replicateToSpokes('event')).resolves.toBeUndefined();
});
test('SiteSpoke.list() throwing does not propagate to the caller', async () => {
SiteSpoke.list = jest.fn(async () => { throw new Error('db unavailable'); });
await expect(siteReplicate.replicateToSpokes('event')).resolves.toBeUndefined();
expect(mockFetchCalls.length).toBe(0);
});
});
+25 -1
View File
@@ -90,10 +90,34 @@ function status() {
return { available: !!cached, error: loadError }; 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. // Test seam: drop the in-process cache.
function _reset() { function _reset() {
cached = null; cached = null;
loadError = null; loadError = null;
} }
module.exports = { load, status, rawPublicKeyBase64, _reset, PATH }; module.exports = { load, status, adopt, rawPublicKeyBase64, _reset, PATH };
+57
View File
@@ -0,0 +1,57 @@
'use strict';
// Live replication push -- the piece the shipped v1 join flow doesn't have on
// its own (join is a one-time export/import snapshot; nothing kept a spoke in
// sync afterward). This fires a lightweight "something changed, re-pull" ping
// at every spoke registered in SiteSpoke, concurrently, fire-and-forget: never
// awaited by its caller, and one unreachable spoke never delays or blocks
// another. See MULTI_SITE_SPEC.md §2.2 for why this must never become a
// blocking design (a write must never stall on spoke reachability).
//
// Deliberately a PUSH-A-SIGNAL / PULL-A-SNAPSHOT design, not a push-a-diff
// design: the receiving spoke reacts by calling the master's already-shipped,
// already-tested POST /api/site/export + importDirectory() path again (see
// routes/api_site.js's /resync handler), rather than this module inventing a
// second, parallel way to represent "what changed." Fewer moving parts, and
// no risk of a diff payload and a full export ever disagreeing.
const { SiteSpoke } = require('../models/site_spoke');
const RESYNC_TIMEOUT_MS = 8000;
function replicateToSpokes(reason) {
return (async () => {
let spokes;
try {
spokes = await SiteSpoke.list();
} catch (err) {
console.error('[site-replicate] failed to list known spokes:', err.message);
return;
}
for (const spoke of spokes) {
// Not awaited -- every spoke is pushed to concurrently.
pingOne(spoke, reason).catch((err) => {
console.error(`[site-replicate] resync ping to ${spoke.endpoint} failed:`, err.message);
});
}
})();
}
async function pingOne(spoke, reason) {
const url = String(spoke.endpoint).replace(/\/+$/, '') + '/api/site/resync';
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), RESYNC_TIMEOUT_MS);
try {
const resp = await fetch(url, {
method: 'POST',
headers: { Authorization: 'Bearer ' + spoke.pushToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: reason || 'catalog-changed' }),
signal: controller.signal
});
if (!resp.ok) throw new Error('status ' + resp.status);
} finally {
clearTimeout(timer);
}
}
module.exports = { replicateToSpokes };
+23 -2
View File
@@ -175,14 +175,17 @@ async function main() {
if (keyRes.status !== 200 || !keyRes.body.key) fail(`join-key mint failed: ${keyRes.status} ${JSON.stringify(keyRes.body)}`); if (keyRes.status !== 200 || !keyRes.body.key) fail(`join-key mint failed: ${keyRes.status} ${JSON.stringify(keyRes.body)}`);
const joinKey = keyRes.body.key; const joinKey = keyRes.body.key;
step('Joining spoke to master'); step('Joining spoke to master (with selfUrl, to register for live replication)');
const joinRes = await api(SPOKE_URL, '/api/site/join', { const joinRes = await api(SPOKE_URL, '/api/site/join', {
method: 'POST', method: 'POST',
token: spokeToken, token: spokeToken,
// master's own container-internal URL, as the spoke would reach it over the network // master's own container-internal URL, as the spoke would reach it over the network
body: { masterUrl: 'http://master:3001', joinKey } body: { masterUrl: 'http://master:3001', joinKey, selfUrl: 'http://spoke:3001' }
}); });
if (joinRes.status !== 200) fail(`join failed: ${joinRes.status} ${JSON.stringify(joinRes.body)}`); if (joinRes.status !== 200) fail(`join failed: ${joinRes.status} ${JSON.stringify(joinRes.body)}`);
if (!joinRes.body.replication || joinRes.body.replication.live !== true) {
fail(`expected join to register for live replication, got ${JSON.stringify(joinRes.body.replication)}`);
}
step('Verifying spoke persisted isMaster:false + masterUrl after join'); step('Verifying spoke persisted isMaster:false + masterUrl after join');
const { body: spokeCfg } = await api(SPOKE_URL, '/api/site/config', { token: spokeToken }); const { body: spokeCfg } = await api(SPOKE_URL, '/api/site/config', { token: spokeToken });
@@ -203,6 +206,24 @@ async function main() {
}); });
if (writeAttempt.status !== 403) fail(`expected 403 writing to spoke post-join, got ${writeAttempt.status} ${JSON.stringify(writeAttempt.body)}`); if (writeAttempt.status !== 403) fail(`expected 403 writing to spoke post-join, got ${writeAttempt.status} ${JSON.stringify(writeAttempt.body)}`);
step('Creating a resource on master AFTER join, to verify LIVE replication (not just the one-time join snapshot)');
const postJoinRes = await api(MASTER_URL, '/api/directory-admin/resources', {
method: 'POST',
token: masterToken,
body: { name: 'E2E Post-Join Host', slug: 'host_e2e_postjoin', kind: 'host', parentSlug: 'site_e2e' }
});
if (postJoinRes.status !== 200) fail(`creating post-join resource on master failed: ${postJoinRes.status} ${JSON.stringify(postJoinRes.body)}`);
step('Waiting for the fire-and-forget resync push to reach the spoke');
let liveReplicated = false;
for (let i = 0; i < 20; i++) {
const r = await api(SPOKE_URL, '/api/directory-admin/resources', { token: spokeToken });
const slugs = (r.body.results || r.body.resources || r.body || []).map((x) => x.slug);
if (slugs.includes('host_e2e_postjoin')) { liveReplicated = true; break; }
await new Promise((res) => setTimeout(res, 500));
}
if (!liveReplicated) fail('post-join resource never appeared on the spoke -- live replication did not fire (or resync did not apply it)');
step('Verifying WAN health ping from spoke to master succeeds'); step('Verifying WAN health ping from spoke to master succeeds');
const statusRes = await api(SPOKE_URL, '/api/directory-admin/site-status', { token: spokeToken }); const statusRes = await api(SPOKE_URL, '/api/directory-admin/site-status', { token: spokeToken });
if (statusRes.body.config && statusRes.body.config.wanConnected !== true) { if (statusRes.body.config && statusRes.body.config.wanConnected !== true) {