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).
This commit is contained in:
2026-08-10 23:48:20 -04:00
parent bd2205f1a9
commit 4542c055bb
4 changed files with 118 additions and 5 deletions
+28 -3
View File
@@ -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); }
});