d486fb946b
OpenLDAP N-way multi-master replication (docs/replication.md) required an operator to hand-set LDAP_SERVER_ID (unique per site) and LDAP_REPLICATION_HOSTS (every OTHER site's LDAP URL, kept in sync by hand across every node) -- real coordination work, and easy to get wrong or let drift as sites are added. Automates the coordination the master is already in a position to do: - SiteSpoke gets ldapServerId, auto-assigned (next free from 2 upward, 1 reserved for the master) at registration and reused across re-registrations -- same pattern as jump-host's mesh index. - ldapHost is derived from each site's already-known HTTP(S) endpoint (same hostname, port 636) rather than a separately-configured field that could drift from it. - New utils/ldap_replication.js (nextFreeLdapServerId, ldapHostFor), shared between the spoke-facing GET /api/site/ldap-peers (Bearer site join key, returns this caller's own ID + every peer) and the master-local GET /directory-admin/ldap-replication-config (computes its own config directly from SiteSpoke, no HTTP round-trip needed). Verified against real running containers (docker-compose.multisite-e2e.yml): after a real join, the master's computed config correctly includes the spoke as a peer with an assigned ID, and the spoke's own fetched config matches that ID and correctly excludes itself from its own peer list. Known limitation, documented in docs/replication.md: the master's own LDAP_REPLICATION_HOSTS only gets recomputed when ITS setup.sh is re-run (or an admin re-applies it directly) -- there's no live push to an already-running master when a new spoke joins. A spoke's own config is re-checked on every setup.sh run, which is the common/recurring event; the master side is a documented manual step for now rather than a live hot-reload (which would need OpenLDAP's dynamic cn=config backend -- a bigger change, deliberately out of scope here to avoid risking a live directory's LDAP replication on undertested config).
61 lines
2.6 KiB
JavaScript
61 lines
2.6 KiB
JavaScript
'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' },
|
|
// No-inbound relay (MULTI_SITE_SPEC.md): a spoke with no public IP of
|
|
// its own reports its WG mesh IP + the public hostname it wants
|
|
// reached at; the master then best-effort creates a matching relay
|
|
// route on its own theta-proxy (utils/proxy_client.js). relayNote
|
|
// records what happened for visibility in the UI -- this automation
|
|
// is optional/best-effort, never a join requirement.
|
|
noInbound: { type: 'boolean', default: false },
|
|
meshIp: { type: 'string' },
|
|
publicHost: { type: 'string' },
|
|
relayNote: { type: 'string' },
|
|
// OpenLDAP multi-master replication (docs/replication.md): a unique
|
|
// small integer this spoke's slapd.conf ServerID must use. Assigned
|
|
// once at registration (see api_site.js's nextFreeLdapServerId),
|
|
// reused on re-registration -- a spoke that re-registers after a
|
|
// restart must not get bumped to a new ID, same reasoning as
|
|
// jump-host's meshIndex. The master reserves 1 for itself, never
|
|
// assigned here.
|
|
ldapServerId: { type: 'integer' }
|
|
};
|
|
|
|
toPublic() {
|
|
const data = this.toJSON ? this.toJSON() : { ...this };
|
|
delete data.pushToken;
|
|
return data;
|
|
}
|
|
}
|
|
|
|
module.exports = { SiteSpoke };
|