From 4542c055bbb22b3b04382bb169fe862983161e9b Mon Sep 17 00:00:00 2001 From: William Mantly Date: Mon, 10 Aug 2026 23:48:20 -0400 Subject: [PATCH 1/2] feat(directory): LDAP replication status + per-spoke detail on the Multi-Site modal The modal previously showed zero LDAP replication status -- no ServerID, no MMR active/inactive indicator, nothing -- and only aggregate counts, never per-spoke detail (no-inbound flag, relay note, assigned ldapServerId). An operator had no way to tell whether replication was actually configured/working without SSHing in. Added utils/ldap_replication.js's currentSlapdServerId(), which reads the ACTUAL running ServerID straight from this node's own slapd.conf -- distinct from what GET /ldap-peers / /ldap-replication-config currently ADVERTISE for it, which can genuinely disagree right after a promotion or a new spoke joining (OpenLDAP's static config only reloads at process start). GET /directory-admin/site-status now surfaces both plus a `stale` flag, and a full spokes list (not just a count) with each one's endpoint, LDAP ServerID, and relay path. directory.ejs renders this as an "LDAP Replication (MMR)" status row (ServerID, peer count, a "needs setup.sh re-run" warning when stale) and a Registered Spokes table. Verified two ways: real running containers via docker-compose.multisite-e2e.yml (new site-status assertions), and an actual browser session against the promoted node -- screenshotted the rendered modal showing the real registered spoke with its assigned ldapServerId and the correctly-surfaced "not configured (standalone)" MMR state (this test node never ran site-ldap-register.js against it, so the mismatch itself is the expected, documented behavior). --- nodejs/routes/api_directory_admin.js | 31 ++++++++++++++-- nodejs/utils/ldap_replication.js | 26 +++++++++++++- nodejs/views/directory.ejs | 53 +++++++++++++++++++++++++++- test/multisite_join_e2e.js | 13 +++++++ 4 files changed, 118 insertions(+), 5 deletions(-) diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index 8a99ec4..60049f7 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -962,7 +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'); +const { ldapHostFor, currentSlapdServerId } = 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. @@ -1002,13 +1002,29 @@ router.get('/site-status', async (req, res, next) => { // via an older bootstrap, or the UI form before it grew the field) is // fully joined but silently stuck on the one-time snapshot, which was // otherwise invisible anywhere in the UI. - const registeredSpokesCount = cfg.isMaster ? await SiteSpoke.list().then(l => l.length).catch(() => 0) : 0; + const allSpokes = cfg.isMaster ? await SiteSpoke.list().catch(() => []) : []; + const registeredSpokesCount = allSpokes.length; // Real gateway-to-gateway mesh peer count from jump-host's own registry // (utils/jump_client.js), not this app's unrelated WireGuard // roaming-client Resources. count is null (not 0) when the query // couldn't run at all -- the UI distinguishes "0 gateways" from "can't // tell" instead of showing a misleading zero. const gateways = await jumpClient.getGatewayCount(); + + // LDAP MMR status (docs/replication.md): configuredServerId is read + // straight from THIS node's own live slapd.conf; advertisedServerId is + // what GET /ldap-peers / /ldap-replication-config currently hand out for + // it. These can genuinely disagree -- a promotion or a newly-joined + // spoke changes the advertised value immediately, but OpenLDAP's static + // config only reloads at process start, so a mismatch means "re-run + // setup.sh here" rather than "something's broken". Only computed for the + // master (a spoke's advertised ID lives on the master, not locally, and + // querying it here would mean another WAN round-trip on every page load). + const configuredServerId = currentSlapdServerId(); + const ldap = cfg.isMaster + ? { configuredServerId, advertisedServerId: 1, stale: configuredServerId !== null && configuredServerId !== 1, peersCount: allSpokes.filter(s => s.ldapServerId).length } + : { configuredServerId, advertisedServerId: null, stale: null, peersCount: null }; + res.json({ status: 'ok', config: { @@ -1024,7 +1040,16 @@ router.get('/site-status', async (req, res, next) => { sitesCount: sites.length, sites: sites.map(s => ({ id: s.id, name: s.name, slug: s.slug })), gatewaysCount: gateways.count, - gatewaysNote: gateways.note + gatewaysNote: gateways.note, + ldap, + // Per-spoke detail (master only) -- endpoint/siteSlug/noInbound/ + // relayNote/ldapServerId, not just an aggregate count, so an operator + // can actually see what's registered instead of only "N spokes". + spokes: allSpokes.map(s => ({ + siteSlug: s.siteSlug, endpoint: s.endpoint, noInbound: !!s.noInbound, + relayNote: s.relayNote || null, ldapServerId: s.ldapServerId || null, + lastSeenOn: s.last_seen_on || null + })) }); } catch (err) { next(err); } }); diff --git a/nodejs/utils/ldap_replication.js b/nodejs/utils/ldap_replication.js index c0d1157..089b86f 100644 --- a/nodejs/utils/ldap_replication.js +++ b/nodejs/utils/ldap_replication.js @@ -1,5 +1,7 @@ 'use strict'; +const fs = require('fs'); + // OpenLDAP multi-master replication (docs/replication.md) config derivation, // shared between routes/api_site.js (the spoke-facing side: assigns a // ServerID at registration, serves GET /api/site/ldap-peers) and @@ -41,4 +43,26 @@ function ldapHostFor(endpoint) { } } -module.exports = { MAX_LDAP_SERVER_ID, nextFreeLdapServerId, ldapHostFor }; +const SLAPD_CONF_PATH = process.env.SLAPD_CONF_PATH || '/etc/openldap/slapd.conf'; + +// The ServerID this node's OpenLDAP is ACTUALLY running with right now, read +// straight from slapd.conf (the same file docker-entrypoint.sh writes +// `ServerID ` into). This can genuinely differ from what +// GET /ldap-peers / /ldap-replication-config currently ADVERTISE for this +// node -- OpenLDAP's static slapd.conf is only read at process start, so a +// promotion or a new spoke joining doesn't retroactively change what's +// already running until `setup.sh` restarts the container. Surfaced on the +// Multi-Site modal so an operator can see "configured X, but slapd is still +// running Y" instead of assuming replication is live because the API says so. +function currentSlapdServerId() { + let contents; + try { + contents = fs.readFileSync(SLAPD_CONF_PATH, 'utf8'); + } catch (e) { + return null; + } + const m = contents.match(/^ServerID\s+(\d+)/m); + return m ? Number(m[1]) : null; +} + +module.exports = { MAX_LDAP_SERVER_ID, nextFreeLdapServerId, ldapHostFor, currentSlapdServerId }; diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 384eaca..5badf5f 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -3319,6 +3319,55 @@ } catch (e) { console.error('Failed to fetch site status:', e); } } + // ldap: { configuredServerId, advertisedServerId, stale, peersCount } from + // GET /directory-admin/site-status (routes/api_directory_admin.js). + // configuredServerId is read from THIS node's live slapd.conf; + // advertisedServerId (master only) is what the API currently hands spokes. + // They can genuinely disagree right after a promotion or a new spoke + // joining -- OpenLDAP's static config only reloads at process start. + function renderLdapStatus(ldap) { + if (!ldap) return 'unknown'; + if (ldap.configuredServerId == null) { + return ' Not configured (standalone)'; + } + let html = 'ServerID ' + esc(ldap.configuredServerId) + ''; + if (ldap.peersCount != null) { + html += ' ' + ldap.peersCount + ' peer' + (ldap.peersCount === 1 ? '' : 's') + ''; + } + if (ldap.stale) { + html += ' Needs setup.sh re-run'; + } + return html; + } + + // spokes: [{siteSlug, endpoint, noInbound, relayNote, ldapServerId, lastSeenOn}] + // Per-spoke detail (master only) so an operator can see what's actually + // registered instead of only an aggregate count. + function renderSpokesTable(spokes) { + if (!spokes || !spokes.length) return ''; + const rows = spokes.map(function(s) { + return '' + + '' + esc(s.siteSlug || '?') + '' + + '' + esc(s.endpoint) + '' + + '' + (s.ldapServerId != null ? '' + esc(s.ldapServerId) + '' : 'unassigned') + '' + + '' + (s.noInbound + ? ' Relayed' + : 'direct') + '' + + ''; + }).join(''); + return '
' + + '
Registered Spokes
' + + '
' + + '' + + '' + + '' + rows + '' + + '
SiteEndpointLDAP ServerIDPath
' + + '
' + + '
'; + } + async function openSiteStatusModal() { try { const res = await app.api.get('directory-admin/site-status'); @@ -3351,12 +3400,14 @@ 'Theta Gateways:' + (res.gatewaysCount == null ? ' Unknown (jump-host integration not configured)' : '' + res.gatewaysCount + ' active gateway' + (res.gatewaysCount === 1 ? '' : 's') + '') + '' + + 'LDAP Replication (MMR):' + renderLdapStatus(res.ldap) + '' + '' + '' + '' + '
' + ' WireGuard Gateway Mesh & NETMAP: Inter-site routing operates via theta-gateway subnets (10.x.0.0/16) with default NETMAP shadow translations (10.x.168.0/24 → 192.168.1.0/24).' + - '
'; + '' + + (isMaster ? renderSpokesTable(res.spokes) : ''); // Fresh install (no users/resources yet): offer to JOIN an existing // master site instead of seeding a new directory. diff --git a/test/multisite_join_e2e.js b/test/multisite_join_e2e.js index 40b7fd9..c3a64b3 100644 --- a/test/multisite_join_e2e.js +++ b/test/multisite_join_e2e.js @@ -229,6 +229,19 @@ async function main() { const selfInOwnPeerList = (spokeLdapCfg.peers || []).some(p => p.ldapServerId === spokeLdapCfg.ldapServerId); if (selfInOwnPeerList) fail(`spoke's own peer list should not include itself, got ${JSON.stringify(spokeLdapCfg.peers)}`); + step('Verifying the master\'s own site-status surfaces LDAP status + per-spoke detail (Multi-Site modal data)'); + const { body: masterStatus } = await api(MASTER_URL, '/api/directory-admin/site-status', { token: masterToken }); + if (!masterStatus.ldap || masterStatus.ldap.advertisedServerId !== 1) { + fail(`master's site-status should report ldap.advertisedServerId 1, got ${JSON.stringify(masterStatus.ldap)}`); + } + if (masterStatus.ldap.peersCount !== 1) { + fail(`master's site-status should report exactly 1 LDAP peer (the spoke), got ${JSON.stringify(masterStatus.ldap)}`); + } + const statusSpokeEntry = (masterStatus.spokes || []).find(s => s.endpoint === 'http://spoke:3001'); + if (!statusSpokeEntry || typeof statusSpokeEntry.ldapServerId !== 'number') { + fail(`master's site-status spokes list should include the spoke with an ldapServerId, got ${JSON.stringify(masterStatus.spokes)}`); + } + step('Verifying the spoke adopted the master\'s pre-join catalog'); const spokeResources = await api(SPOKE_URL, '/api/directory-admin/resources', { token: spokeToken }); const adopted = (spokeResources.body.results || spokeResources.body.resources || spokeResources.body || []); From 6861a113d2faa9f0b9fb7304741428d8baab2583 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Mon, 10 Aug 2026 23:54:05 -0400 Subject: [PATCH 2/2] fix(directory): unify the Directory's site slug with the multi-site replication identity Two previously-unrelated "site slug" concepts existed: the Directory catalog's site Resource (site.slug, what group names and the resource tree actually use -- e.g. "E2E Site" / site_e2e) vs. site_config.js's siteSlug (the multi-site replication identity shown on the Multi-Site modal's "Local Site Slug" row, sourced only from a separately-set SITE_SLUG env var). Nothing ever kept them in sync -- a real deployment could show a real site name in the Directory tree and the literal "site-default" fallback on the Multi-Site modal for the exact same node, which is exactly what a live demo of the modal surfaced. Synced at the source: POST /resources now sets site_config's siteSlug to match, the moment this node's own site Resource is first created (bootstrap.js's initial call). Only for a still-default master -- never overwrites a real multi-site identity a join/promote has already established, and never touches a spoke's identity (the master's to assign via registration, not this node's own resource creation to decide). Verified against a real running container via docker-compose.multisite-e2e.yml's existing site-creation step. --- nodejs/routes/api_directory_admin.js | 19 +++++++++++++++++++ test/multisite_join_e2e.js | 6 ++++++ 2 files changed, 25 insertions(+) diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index 60049f7..55ae8b8 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -365,6 +365,25 @@ router.post('/resources', async (req, res, next) => { const ancestorSite = await Resource.findAncestorSiteSlug(r.id); if (r.kind === 'site') { await ensureSiteGroups(r.slug, req.user.dn, r.name, r.id); + // Two previously-unrelated "site slug" concepts: this Resource's own + // slug (the Directory catalog's site container -- what every group + // name and the resource tree actually use) vs. site_config.js's + // siteSlug (the multi-site replication identity shown on the + // Multi-Site modal, sourced only from a separately-set SITE_SLUG env + // var). They coincidentally share the name "site slug" but nothing + // ever kept them in sync -- a real deployment could show "E2E Site" + // in the Directory tree and "site-default" on the Multi-Site modal + // for the exact same node. Sync them here, the moment this node's own + // site Resource is created (bootstrap.js's first call), so there's + // one real identity instead of two that can drift apart. Only for a + // still-default master: never overwrite a real multi-site identity a + // join/promote has already established, and a spoke's replication + // identity is the master's to assign, not this node's own resource + // creation to decide. + const cfg = siteConfig.get(); + if (cfg.isMaster && cfg.siteSlug === 'site-default') { + siteConfig.save({ siteSlug: r.slug }); + } } else if (gKind && ancestorSite) { await ensureSiteGroups(ancestorSite, req.user.dn, r.name); // backfill site tier if missing await provisionResourceGroups(r, gKind, ancestorSite, req.user.dn); diff --git a/test/multisite_join_e2e.js b/test/multisite_join_e2e.js index c3a64b3..f6062f3 100644 --- a/test/multisite_join_e2e.js +++ b/test/multisite_join_e2e.js @@ -172,6 +172,12 @@ async function main() { }); if (siteRes.status !== 200) fail(`seeding pre-join site on master failed: ${siteRes.status} ${JSON.stringify(siteRes.body)}`); + step('Verifying the Directory site Resource\'s slug synced into the multi-site replication identity'); + const { body: masterCfgAfterSite } = await api(MASTER_URL, '/api/site/config', { token: masterToken }); + if (masterCfgAfterSite.config.siteSlug !== 'site_e2e') { + fail(`expected site_config's siteSlug to sync to the new site Resource's slug (site_e2e), got ${JSON.stringify(masterCfgAfterSite.config.siteSlug)}`); + } + const seedRes = await api(MASTER_URL, '/api/directory-admin/resources', { method: 'POST', token: masterToken,