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
+52 -1
View File
@@ -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 '<span class="text-muted">unknown</span>';
if (ldap.configuredServerId == null) {
return '<span class="badge bg-secondary"><i class="fa-solid fa-circle-minus me-1"></i> Not configured (standalone)</span>';
}
let html = '<span class="badge bg-dark">ServerID ' + esc(ldap.configuredServerId) + '</span>';
if (ldap.peersCount != null) {
html += ' <span class="badge bg-primary">' + ldap.peersCount + ' peer' + (ldap.peersCount === 1 ? '' : 's') + '</span>';
}
if (ldap.stale) {
html += ' <span class="badge bg-warning text-dark" title="This node now advertises ServerID ' + esc(ldap.advertisedServerId) +
' (e.g. after a promotion), but slapd is still running with ' + esc(ldap.configuredServerId) +
' -- re-run setup.sh here to apply it."><i class="fa-solid fa-triangle-exclamation me-1"></i> Needs setup.sh re-run</span>';
}
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 '<tr>' +
'<td><code>' + esc(s.siteSlug || '?') + '</code></td>' +
'<td class="small">' + esc(s.endpoint) + '</td>' +
'<td>' + (s.ldapServerId != null ? '<span class="badge bg-dark">' + esc(s.ldapServerId) + '</span>' : '<span class="text-muted small">unassigned</span>') + '</td>' +
'<td>' + (s.noInbound
? '<span class="badge bg-info text-dark" title="' + esc(s.relayNote || '') + '"><i class="fa-solid fa-diagram-project me-1"></i> Relayed</span>'
: '<span class="text-muted small">direct</span>') + '</td>' +
'</tr>';
}).join('');
return '<div class="card mt-3">' +
'<div class="card-header py-2 fw-bold small"><i class="fa-solid fa-diagram-project me-1"></i> Registered Spokes</div>' +
'<div class="card-body p-0">' +
'<table class="table table-sm table-hover mb-0">' +
'<thead><tr><th>Site</th><th>Endpoint</th><th>LDAP ServerID</th><th>Path</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'</div>' +
'</div>';
}
async function openSiteStatusModal() {
try {
const res = await app.api.get('directory-admin/site-status');
@@ -3351,12 +3400,14 @@
'<tr><th>Theta Gateways:</th><td>' + (res.gatewaysCount == null
? '<span class="badge bg-secondary" title="' + esc(res.gatewaysNote || 'not configured') + '"><i class="fa-solid fa-question me-1"></i> Unknown (jump-host integration not configured)</span>'
: '<span class="badge bg-dark">' + res.gatewaysCount + ' active gateway' + (res.gatewaysCount === 1 ? '' : 's') + '</span>') + '</td></tr>' +
'<tr><th>LDAP Replication (MMR):</th><td>' + renderLdapStatus(res.ldap) + '</td></tr>' +
'</table>' +
'</div>' +
'</div>' +
'<div class="alert alert-secondary small mb-3">' +
'<i class="fa-solid fa-network-wired me-1"></i> <strong>WireGuard Gateway Mesh & NETMAP</strong>: Inter-site routing operates via <code>theta-gateway</code> subnets (<code>10.x.0.0/16</code>) with default NETMAP shadow translations (<code>10.x.168.0/24 &rarr; 192.168.1.0/24</code>).' +
'</div>';
'</div>' +
(isMaster ? renderSpokesTable(res.spokes) : '');
// Fresh install (no users/resources yet): offer to JOIN an existing
// master site instead of seeding a new directory.