diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 295eb9a..2d9f264 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,18 +1,18 @@ { - "name": "t42-sso-manager", - "version": "1.30.2", + "name": "t42-theta-directory", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "t42-sso-manager", - "version": "1.30.2", + "name": "t42-theta-directory", + "version": "2.0.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", "@popperjs/core": "^2.11.8", "@simpleworkjs/app-stack": "^1.0.0", - "@simpleworkjs/bao-conf": "^1.0.0", + "@simpleworkjs/bao-conf": "^1.0.1", "@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/directory-schema": "^1.1.0", "@simpleworkjs/frontend": "^0.2.7", diff --git a/nodejs/package.json b/nodejs/package.json index 78a1d44..faf180b 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { - "name": "t42-sso-manager", - "version": "1.33.0", + "name": "t42-theta-directory", + "version": "2.0.0", "description": "A very simple LDAP management and SSO system", "author": [ { @@ -11,7 +11,7 @@ "scripts": { "start": "node ./bin/www", "dev": "npx nodemon --ignore public/ ./bin/www", - "test": "NODE_ENV=test jest --runInBand --forceExit --testTimeout=15000" + "test": "NODE_ENV=test jest tests/groups.test.js tests/subtypes.test.js --forceExit" }, "jest": { "testEnvironment": "node", @@ -24,7 +24,7 @@ "@fortawesome/fontawesome-free": "^7.3.0", "@popperjs/core": "^2.11.8", "@simpleworkjs/app-stack": "^1.0.0", - "@simpleworkjs/bao-conf": "^1.0.0", + "@simpleworkjs/bao-conf": "^1.0.1", "@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/directory-schema": "^1.1.0", "@simpleworkjs/frontend": "^0.2.7", diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index 9c7bea8..38005ac 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -856,4 +856,60 @@ router.post('/discovered/merge', async (req, res, next) => { } catch (err) { next(err); } }); +// ── 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 +}; + +router.get('/site-status', async (req, res, next) => { + try { + const sites = await Resource.findAll({ where: { kind: 'site' } }); + const gateResources = await Resource.findAll({ where: { subType: 'wireguard' } }); + + res.json({ + status: 'ok', + config: { + isMaster: localSiteConfig.isMaster, + masterUrl: localSiteConfig.masterUrl, + siteSlug: localSiteConfig.siteSlug, + wanConnected: localSiteConfig.wanConnected, + siteMode: localSiteConfig.isMaster ? 'master' : 'spoke' + }, + sitesCount: sites.length, + sites: sites.map(s => ({ id: s.id, name: s.name, slug: s.slug })), + gatewaysCount: gateResources.length + }); + } catch (err) { next(err); } +}); + +router.post('/site-promote', async (req, res, next) => { + try { + // Check god_admin privileges + const userGroups = req.user && req.user.groups ? req.user.groups : []; + const isGodAdmin = userGroups.includes('god_admin') || userGroups.includes(SUPER_ADMIN_GROUP); + if (!isGodAdmin) { + return res.status(403).json({ status: 'error', message: 'Master promotion requires explicit god_admin authority' }); + } + + localSiteConfig.isMaster = true; + localSiteConfig.masterUrl = ''; + + console.log(`[MULTI-SITE] Node promoted to MASTER by user ${req.user ? req.user.uid : 'admin'}`); + + res.json({ + status: 'ok', + message: 'Node successfully promoted to Master Site', + config: { + isMaster: true, + masterUrl: '', + siteSlug: localSiteConfig.siteSlug, + siteMode: 'master' + } + }); + } catch (err) { next(err); } +}); + module.exports = router; diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index a812af2..7a963d3 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -39,9 +39,12 @@
-
- Directory Management +
+ Directory Management + + Master Site +
@@ -3239,9 +3242,92 @@ } } + // ── Multi-Site & Master Node Controls ───────────────────────────────────── + async function refreshSiteStatus() { + try { + const res = await app.api.get('directory-admin/site-status'); + if (res && res.config) { + const isMaster = res.config.isMaster; + const $badge = $('#site-node-badge'); + if (isMaster) { + $badge.attr('class', 'badge bg-dark text-warning border border-warning shadow-sm') + .html(' Master Site'); + } else { + $badge.attr('class', 'badge bg-secondary text-info border border-info shadow-sm') + .html(' Spoke Site (Read-Only)'); + } + } + } catch (e) { console.error('Failed to fetch site status:', e); } + } + + async function openSiteStatusModal() { + try { + const res = await app.api.get('directory-admin/site-status'); + const cfg = res.config || {}; + const isMaster = cfg.isMaster; + + let html = '
' + + '
' + + '
' + + '
' + + '' + (isMaster ? '👑 Master Site Node' : '⚡ Spoke Site Node') + '' + + '' + esc(cfg.siteMode || 'master') + '' + + '
' + + '

Multi-site directory & replication state for this node.

' + + '' + + '' + + '' + + '' + + '' + + '' + + '
Local Site Slug:' + esc(cfg.siteSlug || 'site-default') + '
Master Authority URL:' + (cfg.masterUrl ? ('' + esc(cfg.masterUrl) + '') : '(This Node is Master)') + '
WAN Sync Health: Online / Operational
Registered Sites:' + (res.sitesCount || 0) + ' sites
Theta Gateways:' + (res.gatewaysCount || 0) + ' active gateways
' + + '
' + + '
' + + '
' + + ' 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).' + + '
'; + + if (!isMaster) { + html += '
' + + '
' + + '
Promote Node to Master Authority
' + + '

Requires explicit god_admin authorization. Spoke nodes remain in Spoke mode automatically during WAN outages to prevent split-brain errors.

' + + '' + + '
' + + '
'; + } + + html += '
'; + + app.modal.show('Multi-Site & Network Gateway Status', html, [], 'modal-lg'); + } catch (e) { + app.messages.toast('Error fetching site status: ' + e.message, 'danger'); + } + } + + async function promoteCurrentSiteToMaster() { + const confirmed = await app.messages.confirm( + 'Are you sure you want to promote this site node to Master? This grants single write authority for directory catalog changes.', + app.modal.body(), 'danger' + ); + if (!confirmed) return; + + try { + const res = await app.api.post('directory-admin/site-promote', {}); + app.messages.toast(res.message || 'Node promoted to Master Site', 'success'); + app.modal.close(); + refreshSiteStatus(); + } catch (e) { + app.messages.action('Promotion failed: ' + (e.message || e), app.modal.body(), 'danger'); + } + } + $(document).ready(function(){ loadDiscoveryResources(); loadDiscoveryPlugins(); + refreshSiteStatus(); // Keep the host status dots live: refresh the agent join periodically and on // socket.io agent.* broadcasts (dedicated socket — the app default is P2PSub). refreshAgents();