feat(multi-site): auto-assign LDAP ServerID + replication hosts at join time
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).
This commit is contained in:
@@ -962,6 +962,7 @@ const siteConfig = require('../utils/site_config');
|
||||
const { siteIsFresh } = require('../utils/site_join');
|
||||
const { Agent } = require('../models/agent');
|
||||
const { SiteSpoke } = require('../models/site_spoke');
|
||||
const { ldapHostFor } = require('../utils/ldap_replication');
|
||||
|
||||
// probeMasterHealth checks whether this (spoke) node can reach its master over
|
||||
// the site join key. The master's /api/site/ping is deliberately lightweight.
|
||||
@@ -1028,6 +1029,30 @@ router.get('/site-status', async (req, res, next) => {
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// OpenLDAP multi-master replication config for THIS node (docs/replication.md).
|
||||
// Master-only: the master already has every registered spoke's info locally
|
||||
// (SiteSpoke), so it can compute its own ServerID (always 1) + full peer
|
||||
// list without an HTTP round-trip. A spoke gets its config from the master
|
||||
// directly instead (GET /api/site/ldap-peers -- see bootstrap/
|
||||
// site-ldap-register.js in theta-suite, which calls whichever of the two
|
||||
// applies to this node's role).
|
||||
router.get('/ldap-replication-config', async (req, res, next) => {
|
||||
try {
|
||||
const cfg = siteConfig.get();
|
||||
if (!cfg.isMaster) {
|
||||
return res.status(400).json({ status: 'error', message: 'this node is a spoke -- fetch replication config from the master via GET /api/site/ldap-peers instead' });
|
||||
}
|
||||
const spokes = await SiteSpoke.list();
|
||||
const peers = [];
|
||||
for (const s of spokes) {
|
||||
if (!s.ldapServerId) continue;
|
||||
const host = ldapHostFor(s.endpoint);
|
||||
if (host) peers.push({ ldapServerId: s.ldapServerId, ldapHost: host });
|
||||
}
|
||||
res.json({ status: 'ok', ldapServerId: 1, peers });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/site-promote', async (req, res, next) => {
|
||||
try {
|
||||
// god_admin privilege check. This used to read req.user.groups, which
|
||||
|
||||
Reference in New Issue
Block a user