Merge pull request #209 from theta42/feat-ldap-status-ui
feat(directory): LDAP replication status + per-spoke detail on the Multi-Site modal
This commit is contained in:
@@ -365,6 +365,25 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
const ancestorSite = await Resource.findAncestorSiteSlug(r.id);
|
const ancestorSite = await Resource.findAncestorSiteSlug(r.id);
|
||||||
if (r.kind === 'site') {
|
if (r.kind === 'site') {
|
||||||
await ensureSiteGroups(r.slug, req.user.dn, r.name, r.id);
|
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) {
|
} else if (gKind && ancestorSite) {
|
||||||
await ensureSiteGroups(ancestorSite, req.user.dn, r.name); // backfill site tier if missing
|
await ensureSiteGroups(ancestorSite, req.user.dn, r.name); // backfill site tier if missing
|
||||||
await provisionResourceGroups(r, gKind, ancestorSite, req.user.dn);
|
await provisionResourceGroups(r, gKind, ancestorSite, req.user.dn);
|
||||||
@@ -962,7 +981,7 @@ const siteConfig = require('../utils/site_config');
|
|||||||
const { siteIsFresh } = require('../utils/site_join');
|
const { siteIsFresh } = require('../utils/site_join');
|
||||||
const { Agent } = require('../models/agent');
|
const { Agent } = require('../models/agent');
|
||||||
const { SiteSpoke } = require('../models/site_spoke');
|
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
|
// probeMasterHealth checks whether this (spoke) node can reach its master over
|
||||||
// the site join key. The master's /api/site/ping is deliberately lightweight.
|
// the site join key. The master's /api/site/ping is deliberately lightweight.
|
||||||
@@ -1002,13 +1021,29 @@ router.get('/site-status', async (req, res, next) => {
|
|||||||
// via an older bootstrap, or the UI form before it grew the field) is
|
// 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
|
// fully joined but silently stuck on the one-time snapshot, which was
|
||||||
// otherwise invisible anywhere in the UI.
|
// 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
|
// Real gateway-to-gateway mesh peer count from jump-host's own registry
|
||||||
// (utils/jump_client.js), not this app's unrelated WireGuard
|
// (utils/jump_client.js), not this app's unrelated WireGuard
|
||||||
// roaming-client Resources. count is null (not 0) when the query
|
// roaming-client Resources. count is null (not 0) when the query
|
||||||
// couldn't run at all -- the UI distinguishes "0 gateways" from "can't
|
// couldn't run at all -- the UI distinguishes "0 gateways" from "can't
|
||||||
// tell" instead of showing a misleading zero.
|
// tell" instead of showing a misleading zero.
|
||||||
const gateways = await jumpClient.getGatewayCount();
|
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({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
config: {
|
config: {
|
||||||
@@ -1024,7 +1059,16 @@ router.get('/site-status', async (req, res, next) => {
|
|||||||
sitesCount: sites.length,
|
sitesCount: sites.length,
|
||||||
sites: sites.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
|
sites: sites.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
|
||||||
gatewaysCount: gateways.count,
|
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); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
// OpenLDAP multi-master replication (docs/replication.md) config derivation,
|
// OpenLDAP multi-master replication (docs/replication.md) config derivation,
|
||||||
// shared between routes/api_site.js (the spoke-facing side: assigns a
|
// shared between routes/api_site.js (the spoke-facing side: assigns a
|
||||||
// ServerID at registration, serves GET /api/site/ldap-peers) and
|
// 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 <n>` 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 };
|
||||||
|
|||||||
@@ -3319,6 +3319,55 @@
|
|||||||
} catch (e) { console.error('Failed to fetch site status:', e); }
|
} 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() {
|
async function openSiteStatusModal() {
|
||||||
try {
|
try {
|
||||||
const res = await app.api.get('directory-admin/site-status');
|
const res = await app.api.get('directory-admin/site-status');
|
||||||
@@ -3351,12 +3400,14 @@
|
|||||||
'<tr><th>Theta Gateways:</th><td>' + (res.gatewaysCount == null
|
'<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-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>' +
|
: '<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>' +
|
'</table>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="alert alert-secondary small mb-3">' +
|
'<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 → 192.168.1.0/24</code>).' +
|
'<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 → 192.168.1.0/24</code>).' +
|
||||||
'</div>';
|
'</div>' +
|
||||||
|
(isMaster ? renderSpokesTable(res.spokes) : '');
|
||||||
|
|
||||||
// Fresh install (no users/resources yet): offer to JOIN an existing
|
// Fresh install (no users/resources yet): offer to JOIN an existing
|
||||||
// master site instead of seeding a new directory.
|
// master site instead of seeding a new directory.
|
||||||
|
|||||||
@@ -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)}`);
|
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', {
|
const seedRes = await api(MASTER_URL, '/api/directory-admin/resources', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: masterToken,
|
token: masterToken,
|
||||||
@@ -229,6 +235,19 @@ async function main() {
|
|||||||
const selfInOwnPeerList = (spokeLdapCfg.peers || []).some(p => p.ldapServerId === spokeLdapCfg.ldapServerId);
|
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)}`);
|
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');
|
step('Verifying the spoke adopted the master\'s pre-join catalog');
|
||||||
const spokeResources = await api(SPOKE_URL, '/api/directory-admin/resources', { token: spokeToken });
|
const spokeResources = await api(SPOKE_URL, '/api/directory-admin/resources', { token: spokeToken });
|
||||||
const adopted = (spokeResources.body.results || spokeResources.body.resources || spokeResources.body || []);
|
const adopted = (spokeResources.body.results || spokeResources.body.resources || spokeResources.body || []);
|
||||||
|
|||||||
Reference in New Issue
Block a user