From 70b76c6ed51d4794de2e9f69a858ad8cbf1aa1dc Mon Sep 17 00:00:00 2001 From: William Mantly Date: Mon, 3 Aug 2026 13:54:53 -0400 Subject: [PATCH] fix(sso): Directory graph live refresh, discovery reconciler, conf layout, vault broker admin roles, and move plugins to directory/conf --- nodejs/config/inventory.sqlite | Bin 61440 -> 61440 bytes nodejs/package-lock.json | 2 +- nodejs/package.json | 2 +- nodejs/routes/discovery.js | 2 +- nodejs/routes/index.js | 9 +- nodejs/services/discovery_reconciler.js | 44 +- nodejs/utils/ui.js | 1 - nodejs/utils/vault_broker.js | 3 +- nodejs/views/conf.ejs | 571 +++++++++++++----------- nodejs/views/directory.ejs | 100 ++++- 10 files changed, 450 insertions(+), 284 deletions(-) diff --git a/nodejs/config/inventory.sqlite b/nodejs/config/inventory.sqlite index 9fe0fc79f641eecf4f0a52764865ad854fdc7e64..b01c0edb82751781e8286c2d29e9ccdde7ec172b 100644 GIT binary patch delta 907 zcma))&1w`u5XX021&P8${0OK6K?e+&ie25+Uwah~A|6CMuRSx}B81ICl7n8%&6A+? z1Bmzl@#B!QXS10X@UY+m=w)@ugul`m2t9C}6ol)ogjdt@d5NCZIC0Vq@kEb$u1I~{6a(5kr6T#FC)uUyntkz!o-HbRJ>n$=aYjJhkS%hZ}s za``S}UB)=$3YY=*Z#4ec&kI7Yovu6>t{e~#B^aCF6*|<$6Gvg=pt%~ChLl4CfZDHc;2n0@+YOYN`ZjP?gI2g#1hUJzUC8XyvP?>)S7;h=OJj1x0s| z7wJ#qH90`RlbfZj!3j=KVkLrAwG1^o>TmDhl%4hVUlxObROod0!rec>e4{QsZCC+>^BSJ|7whP_Zohw0^ z3y|Us__OHRwM}vZE&^UaZ^22WO|=-lndNy8&pGdVb|$TzN$dTMX8XgFw(F(|+o5FBM1s8Uf-<0%h34aQf~;_&i?8Ls~? z4UL&Y%hmL5Saz5S++h_!nIHj;^`M1AAUiUUXxDj(Ov+6=4zjmWhVDa?N|8}W;t9Y~A zdeW?MLF8O+|5PXZvsw7MPHwF%eDtu6-)FLNxsFp`CmY3sBd4Y}KArmQR(QG98{~_4 e(eF{Eg_1!?b~oH_sV;QGRGtNjf~?(WS1 diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 08af081..bc72476 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.20.0", + "version": "1.20.1", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/nodejs/package.json b/nodejs/package.json index 71f1626..4950cb6 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.20.0", + "version": "1.20.1", "description": "A very simple LDAP management and SSO system", "author": [ { diff --git a/nodejs/routes/discovery.js b/nodejs/routes/discovery.js index 6185aa2..ea57436 100644 --- a/nodejs/routes/discovery.js +++ b/nodejs/routes/discovery.js @@ -186,7 +186,7 @@ router.post('/promote/:slug', async (req, res, next) => { const meta = resource.metadata || {}; meta.managed = true; - await resource.update({ metadata: meta }); + await Resource.update(resource.id, { metadata: meta }); res.json(envelope({ success: true, groups: [accessGroup, adminGroup] })); } catch (err) { next(err); } diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index f021c89..144a4a3 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -84,14 +84,7 @@ router.get('/discovery', function(req, res, next) { }); router.get('/plugins', function(req, res, next) { - // Plugin instances page — loadable/unloadable, configurable plugin copies - // with per-instance secrets in OpenBao. Renders the shell for anyone; the - // client gates with app.auth.forceLogin(['app_sso_admin', - // 'app_sso_directory_admin','admin']) and the /api/plugins endpoints enforce - // the same server-side. Same header-vs-navigation auth model as /conf and - // /vault (auth-token is a client-set header, not a cookie). - const registry = require('../services/plugin_registry'); - res.render('plugins', {...values, pluginTypes: registry.types }); + res.redirect('/directory'); }); router.get('/vault', function(req, res) { diff --git a/nodejs/services/discovery_reconciler.js b/nodejs/services/discovery_reconciler.js index cfa98e6..c1a30a9 100644 --- a/nodejs/services/discovery_reconciler.js +++ b/nodejs/services/discovery_reconciler.js @@ -12,32 +12,39 @@ class DiscoveryReconciler { res._originalSlug = res.slug; // Keep track for edge mapping let existing = null; - - // Attempt matching by MAC if available (case-insensitive) + const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, ''); + const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim(); + + const allRes = await Resource.list(); + + // 1. Attempt matching by MAC (highest precision) if (res.metadata.interfaces && res.metadata.interfaces.length > 0) { - const macs = res.metadata.interfaces.map(i => i.mac ? i.mac.toLowerCase() : null).filter(m => !!m); + const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12); if (macs.length > 0) { - const allRes = await Resource.list(); existing = allRes.find(r => - r.metadata && r.metadata.interfaces && - r.metadata.interfaces.some(i => i.mac && macs.includes(i.mac.toLowerCase())) + r.metadata && ( + (r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) || + (r.metadata.interfaces && r.metadata.interfaces.some(i => macs.includes(normalizeMac(i.mac)))) + ) ); } } - - // Fallback matching by IP if no MAC match (weaker) + + // 2. Fallback matching by IP address let ipsToMatch = []; if (res.metadata.interfaces) { ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i); } + if (res.metadata.ip) ipsToMatch.push(res.metadata.ip); if (res.metadata.address) { res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim())); } - + ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))]; + if (!existing && ipsToMatch.length > 0) { - const allRes = await Resource.list(); existing = allRes.find(r => { if (!r.metadata) return false; + if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true; if (r.metadata.address) { const addrs = r.metadata.address.split(',').map(a => a.trim()); if (addrs.some(a => ipsToMatch.includes(a))) return true; @@ -46,14 +53,17 @@ class DiscoveryReconciler { return false; }); } - - // Fallback matching by Slug or Name + + // 3. Fallback matching by Slug, Name, or Base Hostname if (!existing && (res.slug || res.name)) { - const allRes = await Resource.list(); - existing = allRes.find(r => - (res.slug && r.slug === res.slug) || - (res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) - ); + const inputName = normalizeHost(res.name || res.slug); + existing = allRes.find(r => { + if (res.slug && r.slug === res.slug) return true; + if (res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) return true; + if (inputName && r.name && normalizeHost(r.name) === inputName) return true; + if (inputName && r.slug && normalizeHost(r.slug) === inputName) return true; + return false; + }); } if (existing) { diff --git a/nodejs/utils/ui.js b/nodejs/utils/ui.js index 6f27c75..4128e3d 100644 --- a/nodejs/utils/ui.js +++ b/nodejs/utils/ui.js @@ -44,7 +44,6 @@ module.exports = { {href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_admin']}, {href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']}, {href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']}, - {href: '/plugins', icon: 'fa-solid fa-plug', label: 'Plugins', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']}, // Vault requires login - per-user secrets at secret/users//*. {href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']}, {href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']}, diff --git a/nodejs/utils/vault_broker.js b/nodejs/utils/vault_broker.js index 927f41b..0ea03f2 100644 --- a/nodejs/utils/vault_broker.js +++ b/nodejs/utils/vault_broker.js @@ -156,11 +156,12 @@ async function mintAppToken(name) { // client's sso auth headers so OpenBao never sees them. const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200'; +const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin']; const ADMIN_GROUP = 'app_sso_admin'; async function isAdmin(user) { try { - await permission.byGroup(user, [ADMIN_GROUP]); + await permission.byGroup(user, ADMIN_GROUPS); return true; } catch (e) { return false; diff --git a/nodejs/views/conf.ejs b/nodejs/views/conf.ejs index a95939b..e0f78dd 100644 --- a/nodejs/views/conf.ejs +++ b/nodejs/views/conf.ejs @@ -2,10 +2,14 @@ -
-
-
-
-

System Configuration

-

- Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service - settings. These are stored securely in OpenBao and take effect immediately. - Secret fields (the SMTP password, OAuth JWT secret, and VoIP.ms API password) - are masked — leave them unchanged to keep the stored value. -

-
-
- - -
+
+ +
+
+

System Configuration

+

+ Manage stack configuration (SMTP, OAuth, VoIP.ms, Proxy, Terms of Service, and Messaging Plugins). Secrets are stored in OpenBao. +

+
+
+ +
- - -
- -
-
-
-
SMTP Settings
+
+ +
+
+ -
-
- - -
-
- - -
-
- - -
-
- -
- - +
+
+ + +
+
+ + +
+
+
+
OAuth 2.0 & JWT Settings
-
Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.
-
-
- -
- - -
-
Send a test SMS to verify your VoIP.ms configuration is working.
-
-
-
-
- -
- - -
-
Send a test SMS to verify your VoIP.ms configuration is working.
-
-
- - -
-
- - -
-
-
- -
- - +
+
+ + +
+
+ +
+ + +
+
Stored in OpenBao. Leave unchanged to preserve stored value.
+
+
+
+ + +
+
+ + +
-
Send a test email to verify your SMTP configuration is working.
-
-
-
-
- - -
-
-
-
OAuth & JWT Settings
-
-
-
- - -
-
- -
- - -
-
Leave unchanged to keep the current secret stored in OpenBao. Clear and type a new value to replace it.
-
-
- - -
-
- - -
-
-
-
- - -
-
-
-
SMS (VoIP.ms)
-
-
-

Used to deliver SMS 2FA login codes. The API password is stored in OpenBao and masked below.

-
- - -
-
- - -
-
- -
- - -
-
Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.
-
-
- -
- - -
-
Send a test SMS to verify your VoIP.ms configuration is working.
-
-
-
-
- - -
-
-
-
Proxy Secrets (OpenBao)
-
-
-

These secrets are stored directly in OpenBao (`secret/proxy/conf`) and read by the Proxy at boot.

- -
OAuth / OIDC Integration
-
- - -
-
- - -
-
- -
- -
+
-
LDAP Integration
-
- -
- - + +
+
+
+
SMTP Server Settings
-
Password for the Proxy's LDAP service account.
-
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ +
+ + +
+
+
+
+ + +
+
+ + +
- +
+
Send Test Email
+
+ + +
+
Saves current SMTP config and sends a test message.
+
+
+
-
-
- -
-
-
-
Terms of Service
- -
-
-
- - + +
+
+
+
VoIP.ms SMS Integration
+
+
+
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+ +
+
Send Test SMS
+
+ + +
+
+ +
+ +
+
Messaging Plugins & Webhooks
+ +
+
+
-
- - -
- -
+ + +
+
+
+
OpenBao Proxy Integration
+
+
+

Secrets stored directly in OpenBao (secret/proxy/conf) and consumed by Proxy at boot.

+ +
OAuth / OIDC Client
+
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+ +
LDAP Bind Account
+
+ +
+ + +
+
+ + +
+
+
+ + +
+
+
+
Terms of Service Editor
+ +
+
+
+ + +
+
+ + +
+ + +
+
+
+
diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 4afd120..a452748 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -13,7 +13,12 @@ + @@ -187,6 +192,20 @@
+ + +
+
+
+
+
Discovery Plugins
+

Manage background discovery agents (Nmap, Docker, Proxmox, UniFi). Per-instance secrets are stored in OpenBao.

+
+ +
+
+
+
@@ -1188,6 +1207,7 @@ allEdges.push(res.results); refreshEdgesUI(resourceId); $('#new-edge-target').val(''); + await loadData(); } catch (err) { console.error(err); app.messages.action('Failed to add edge', app.modal.body(), 'danger'); @@ -1199,6 +1219,7 @@ await app.api.delete('directory-admin/edges/' + id); allEdges = allEdges.filter(e => e.id !== id); refreshEdgesUI($('#res-id').val()); + await loadData(); } catch (err) { console.error(err); app.messages.action('Failed to remove edge', app.modal.body(), 'danger'); @@ -1493,7 +1514,7 @@ `; app.modal.open({ - title: ' Install Theta Agent', + title: 'Install Theta Agent', bodyHtml: bodyHtml, size: 'lg' }); @@ -1501,12 +1522,81 @@ updateAgentCommands(); } - // Plugin scheduling moved to the dedicated /plugins page (the Agents & - // Scheduler tab here was its old home). Discovery inventory + the discovery - // results table remain on this page. + var discoveryPlugins = []; + + function loadDiscoveryPlugins() { + app.api.get('plugins', function(err, res) { + if (err) return; + discoveryPlugins = (res.results || []).filter(p => p.category === 'discovery'); + renderDiscoveryPlugins(); + }); + } + + function renderDiscoveryPlugins() { + const $list = $('#discovery-plugins-list').empty(); + if (discoveryPlugins.length === 0) { + $list.append('

No discovery plugins configured.
'); + return; + } + discoveryPlugins.forEach(p => { + const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary'; + const statusText = p.enabled ? 'Loaded' : 'Unloaded'; + const card = ` +
+
+
+
${p.name} ${p.pluginType}
+
${p.slug} | Schedule: ${p.cron}
+
+
+ ${statusText} + + + +
+
+
+ `; + $list.append(card); + }); + } + + async function toggleDiscoveryPlugin(id, state) { + const endpoint = state ? 'load' : 'unload'; + try { + await app.api.post(`plugins/${id}/${endpoint}`, {}); + app.messages.toast(`Discovery plugin ${state ? 'loaded' : 'unloaded'}`, 'success'); + loadDiscoveryPlugins(); + } catch (e) { + app.messages.toast('Error toggling plugin: ' + e.message, 'danger'); + } + } + + async function runDiscoveryPluginNow(id) { + try { + await app.api.post(`plugins/${id}/run`, {}); + app.messages.toast('Enqueued discovery plugin run', 'success'); + loadDiscoveryPlugins(); + } catch (e) { + app.messages.toast('Error running plugin: ' + e.message, 'danger'); + } + } + + async function deleteDiscoveryPlugin(id) { + const ok = await app.messages.confirm('Are you sure you want to delete this discovery plugin?'); + if (!ok) return; + try { + await app.api.delete(`plugins/${id}`); + app.messages.toast('Discovery plugin deleted', 'success'); + loadDiscoveryPlugins(); + } catch (e) { + app.messages.toast('Error deleting plugin: ' + e.message, 'danger'); + } + } $(document).ready(function(){ loadDiscoveryResources(); + loadDiscoveryPlugins(); });