feat(site): multi-site join server endpoints + persisted site role + emoji fix

Server endpoints for joining a spoke to a master directory (MULTI_SITE_SPEC.md).
This pass is server-only; setup.sh wiring and the UI are the next layer.

- Site join keys (SiteJoinKey model, stj_ prefix): mint/revoke/delete/list,
  hashed at rest, shown once — the same model as agent join keys.
- POST /api/site/export (master, Bearer stj_ key, no admin session): returns the
  local LDAP tree (slapcat LDIF) + resource catalog + siteSlug + baseDn.
- POST /api/site/join (spoke, admin): { masterUrl, joinKey } pulls the master
  export, imports resources (upsert by slug) + LDAP (ldapadd -c), and persists
  the spoke role. Refused if already a spoke.
- Persisted site role: utils/site_config.js keeps isMaster/masterUrl/siteSlug in
  /config/site.json (env seeds defaults); site-status/site-promote now use it.
- Unit tests (site_join, site_config) with in-memory stubs, wired into npm test.
- docs/site-join.md + docs router entry.
- Repairs the corrupted multi-site emojis (crown/bolt) in directory.ejs.
- .gitguardian.yml ignores the generic-password false positive on reading the
  LDAP bind credential from runtime config (never a hardcoded secret).
This commit is contained in:
2026-08-10 05:58:28 -07:00
parent 0915043d6d
commit c96a4b6652
14 changed files with 830 additions and 68 deletions
+15 -15
View File
@@ -882,12 +882,11 @@ router.post('/discovered/merge', async (req, res, next) => {
});
// ── Multi-Site & Master Node Status Endpoints ────────────────────────────────
let localSiteConfig = {
isMaster: process.env.IS_MASTER ? (process.env.IS_MASTER === 'true') : true,
masterUrl: process.env.MASTER_URL || '',
siteSlug: process.env.SITE_SLUG || 'site-default',
wanConnected: true
};
// The site role (master/spoke, site slug, master URL) is persisted by
// utils/site_config.js so it survives restarts; the env vars IS_MASTER /
// MASTER_URL / SITE_SLUG only seed the defaults. site-promote and the
// /api/site/join flow both write to it.
const siteConfig = require('../utils/site_config');
router.get('/site-status', async (req, res, next) => {
try {
@@ -895,14 +894,15 @@ router.get('/site-status', async (req, res, next) => {
const allResources = await Resource.list();
const gateResources = allResources.filter(r => r.metadata && r.metadata.subType === 'wireguard');
const cfg = siteConfig.get();
res.json({
status: 'ok',
config: {
isMaster: localSiteConfig.isMaster,
masterUrl: localSiteConfig.masterUrl,
siteSlug: localSiteConfig.siteSlug,
wanConnected: localSiteConfig.wanConnected,
siteMode: localSiteConfig.isMaster ? 'master' : 'spoke'
isMaster: cfg.isMaster,
masterUrl: cfg.masterUrl,
siteSlug: cfg.siteSlug,
wanConnected: cfg.wanConnected,
siteMode: cfg.isMaster ? 'master' : 'spoke'
},
sitesCount: sites.length,
sites: sites.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
@@ -920,18 +920,18 @@ router.post('/site-promote', async (req, res, next) => {
return res.status(403).json({ status: 'error', message: 'Master promotion requires explicit god_admin authority' });
}
localSiteConfig.isMaster = true;
localSiteConfig.masterUrl = '';
siteConfig.save({ isMaster: true, masterUrl: '' });
console.log(`[MULTI-SITE] Node promoted to MASTER by user ${req.user ? req.user.uid : 'admin'}`);
const cfg = siteConfig.get();
res.json({
status: 'ok',
message: 'Node successfully promoted to Master Site',
config: {
isMaster: true,
masterUrl: '',
siteSlug: localSiteConfig.siteSlug,
siteSlug: cfg.siteSlug,
siteMode: 'master'
}
});