From 49100c9b684fe5ff6eee693599e0f44555fd0402 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Wed, 5 Aug 2026 02:59:58 -0400 Subject: [PATCH] fix: agent REST router mounted before 404; promote opens pre-filled modal; Directory refresh; Vault OpenBao (v1.28.0) (#169) - api_agent: REST router mounted synchronously in app.js (was post-listen, behind the 404 catch-all -> /api/agent/* 404'd); WS init stays on onListen - directory.ejs: promote opens a pre-filled resource modal (Save confirms); addEdge/removeEdge call loadResources() (was undefined loadData -> stale table); addGroup/removeGroup refresh the Access column - vault.ejs: 'Powered by OpenBao' header badge --- CHANGELOG.md | 6 ++ nodejs/app.js | 13 ++++- nodejs/package-lock.json | 4 +- nodejs/package.json | 2 +- nodejs/routes/api_agent.js | 112 +++++++++++++++++++------------------ nodejs/views/directory.ejs | 70 +++++++++++++++++------ nodejs/views/vault.ejs | 3 +- 7 files changed, 133 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a87931e..968148e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# v1.28.0 +- fix: `/api/agent/nodes` no longer 404s — the previous "unconditional mount" was still inside the post-listen `onListen` hook, so the REST router landed *behind* app.js's terminal 404 catch-all and every `/api/agent/*` request 404'd. The router is now mounted synchronously in `app.js` before the 404 handler; only the agent WebSocket setup runs on `onListen`. +- feat: promoting a discovered inventory resource now opens the resource form pre-filled with the discovered data (name, kind, IP, subtype, …) for review; the modal's Save confirms the promote (creates the LDAP groups + marks it managed) instead of silently promoting. +- fix: Directory table no longer goes stale after add/remove edge — `addEdge`/`removeEdge` called an undefined `loadData()`, which threw and left the host/parent linkage stale until a manual refresh; they now call `loadResources()`. `addGroup`/`removeGroup` also refresh so the Access column stays accurate. +- feat: Vault page states it's powered by OpenBao (header badge linking to openbao.org). + # v1.27.0 - fix: Directory group names now match `docs/GROUPS.md` exactly — per-resource groups are `{site}_{kind}_{name}_{level}` (`site_local_host_theta-env_access`, `site_local_app_sso-manager_access`), with the kind always present and the resource name slug stripped of its kind prefix. Services map to the `app` kind. The access-request + resolver tests were updated to the documented convention. - fix: a site resource now carries only `god_admin` + the site-wide groups (`{site}_super_admin`, `{site}_everyone`); the kind-scoped aggregates are still created for nesting but are no longer surfaced on the site's modal. diff --git a/nodejs/app.js b/nodejs/app.js index 20c679e..f842bc7 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -44,9 +44,10 @@ app.onListen.push(function(){ }); }); - // Initialize Theta Agent WebSockets - require('./routes/api_agent')(app); -}); + // Initialize Theta Agent WebSockets. The REST router is already mounted + // synchronously above (see the /api/agent mount); this hook only wires the WS. + require('./routes/api_agent').initAgentWebSockets(app); +}); // Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate, // uncompressed vendor JS/CSS files on every full page navigation (a @@ -105,6 +106,12 @@ app.use('/api/conf', middleware.auth, require('./routes/api_conf')); // Self-service API tokens (PATs) — owner-scoped, no admin group required. app.use('/api/api-token', middleware.auth, require('./routes/api_token')); +// theta-agent REST API. Mounted SYNCHRONOUSLY (before the 404 catch-all below), +// not from an onListen hook — a router registered post-listen would sit behind +// the terminal 404 handler and make every /api/agent/* request 404. The agent +// WebSocket handler (routes/api_agent.initAgentWebSockets) still runs on onListen. +app.use('/api/agent', require('./routes/api_agent')); + // OAuth 2.0 / OpenID Connect app.use('/oauth', oauthRouter); app.use('/api/oauth', middleware.auth, oauthApiRouter); diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index bd4694b..c2adaad 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-sso-manager", - "version": "1.27.0", + "version": "1.28.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-sso-manager", - "version": "1.27.0", + "version": "1.28.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", diff --git a/nodejs/package.json b/nodejs/package.json index f80740a..d9fda0c 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.27.0", + "version": "1.28.0", "description": "A very simple LDAP management and SSO system", "author": [ { diff --git a/nodejs/routes/api_agent.js b/nodejs/routes/api_agent.js index bf6aaab..310d17f 100644 --- a/nodejs/routes/api_agent.js +++ b/nodejs/routes/api_agent.js @@ -7,12 +7,64 @@ const agentManager = require('../utils/agent_manager'); const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin']; -module.exports = function initAgentWebSockets(app) { - // Only the WebSocket handler needs the WS server. The REST routes mounted - // below (/api/agent/*) must work regardless of the WS server state -- gating - // them on `app.wss` made them 404 whenever it wasn't initialized. - if (app.wss) { - app.wss.on('connection', (ws, req) => { +// ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ── +// This is a plain Express Router exported directly so app.js can +// `app.use('/api/agent', require('./routes/api_agent'))` at require time. It +// must NOT be mounted from the onListen hook (which runs after the 404 +// catch-all is already on the stack): a router registered behind that terminal +// handler would make every /api/agent/* request 404, no matter the WS server +// state. The WebSocket handler is separate (initAgentWebSockets below) and is +// the only part that needs the post-listen onListen hook. +const router = express.Router(); + +// The agent WebSocket (/api/agent/ws) is handled by the raw `wss` upgrade server +// in bin/www with its own ?token= auth — unaffected by the express middleware +// here. These REST routes are admin-facing, so they're auth + admin gated. +router.use(middleware.auth); +router.use(async (req, res, next) => { + try { + await permission.byGroup(req.user, ADMIN_GROUPS); + next(); + } catch (err) { + if (err && (err.status === 401 || err.name === 'Insufficient Permission')) { + return res.status(403).json({ status: 'error', message: 'admin only' }); + } + next(err); + } +}); + +router.get('/nodes', (req, res) => { + res.json({ + status: 'ok', + agents: agentManager.getConnectedAgents(), + publicKey: agentManager.publicKeyPem + }); +}); + +router.post('/nodes/:token/command', (req, res) => { + const { token } = req.params; + const { command, payload, isHighRisk } = req.body; + + if (!command) { + return res.status(400).json({ status: 'error', message: 'Command type is required' }); + } + + try { + const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary']; + const requiresSigning = isHighRisk || HIGH_RISK_COMMANDS.includes(command); + + const msg = agentManager.sendCommand(token, command, payload || {}, requiresSigning); + res.json({ status: 'ok', sentMessage: msg }); + } catch (err) { + res.status(400).json({ status: 'error', message: err.message }); + } +}); + +module.exports = router; +module.exports.initAgentWebSockets = function initAgentWebSockets(app) { + // WebSocket handler only needs the WS server; runs from the onListen hook. + if (!app.wss) return; + app.wss.on('connection', (ws, req) => { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const token = url.searchParams.get('token') || req.headers['authorization']; @@ -73,52 +125,4 @@ module.exports = function initAgentWebSockets(app) { })); } catch (e) {} }); - } // end if (app.wss) - - // REST API routes for Agent Management (mounted under /api/agent). The agent - // WebSocket (/api/agent/ws) is handled by the raw `wss` upgrade server in - // bin/www with its own ?token= auth — unaffected by the express middleware - // here. These REST routes are admin-facing, so they're auth + admin gated. - const router = express.Router(); - router.use(middleware.auth); - router.use(async (req, res, next) => { - try { - await permission.byGroup(req.user, ADMIN_GROUPS); - next(); - } catch (err) { - if (err && (err.status === 401 || err.name === 'Insufficient Permission')) { - return res.status(403).json({ status: 'error', message: 'admin only' }); - } - next(err); - } - }); - - router.get('/nodes', (req, res) => { - res.json({ - status: 'ok', - agents: agentManager.getConnectedAgents(), - publicKey: agentManager.publicKeyPem - }); - }); - - router.post('/nodes/:token/command', (req, res) => { - const { token } = req.params; - const { command, payload, isHighRisk } = req.body; - - if (!command) { - return res.status(400).json({ status: 'error', message: 'Command type is required' }); - } - - try { - const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary']; - const requiresSigning = isHighRisk || HIGH_RISK_COMMANDS.includes(command); - - const msg = agentManager.sendCommand(token, command, payload || {}, requiresSigning); - res.json({ status: 'ok', sentMessage: msg }); - } catch (err) { - res.status(400).json({ status: 'error', message: err.message }); - } - }); - - app.use('/api/agent', router); }; diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 160bb9b..722f479 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -515,6 +515,9 @@ var rawResources = []; // resourceId -> { groups: [{cn, accessLevel, exists, memberCount}], memberCount } var accessSummary = {}; + // When set, the resource modal's Save promotes this discovered slug (review + // the pre-filled form, then confirm) instead of a normal resource save. + var promoteSlug = null; $(document).ready(async function() { await loadResources(); @@ -1179,6 +1182,29 @@ } async function saveResource() { + // Promote path: the modal was opened from a discovered inventory row, so + // Save confirms promotion (creates LDAP groups + marks managed) rather than + // a normal resource create/update. + if (promoteSlug) { + const slug = promoteSlug; + promoteSlug = null; + try { + const res = await new Promise((resolve, reject) => { + app.api.post('discovery/promote/' + slug, {}, function(err, r) { + if (err) reject(err); else resolve(r); + }); + }); + await loadResources(); + loadDiscoveryResources(); + app.modal.close(); + app.messages.toast('Promoted ' + slug + (res && res.groups ? ' — created groups: ' + res.groups.join(', ') : ''), 'success'); + } catch (err) { + promoteSlug = slug; + app.messages.action('Failed to promote: ' + (err.message || err), app.modal.body(), 'danger'); + } + return; + } + const id = $('#res-id').val(); const data = { name: $('#res-name').val(), @@ -1277,6 +1303,7 @@ allGroups.push(res.results); refreshGroupsUI(resourceId); $('#new-group-cn').val(''); + await loadResources(); // keep the Access column in sync } catch (err) { console.error(err); app.messages.action('Failed to add group', app.modal.body(), 'danger'); @@ -1288,6 +1315,7 @@ await app.api.delete('directory-admin/groups/' + id); allGroups = allGroups.filter(g => g.id !== id); refreshGroupsUI($('#res-id').val()); + await loadResources(); // keep the Access column in sync } catch (err) { console.error(err); app.messages.action('Failed to remove group', app.modal.body(), 'danger'); @@ -1316,7 +1344,7 @@ allEdges.push(res.results); refreshEdgesUI(resourceId); $('#new-edge-target').val(''); - await loadData(); + await loadResources(); } catch (err) { console.error(err); app.messages.action('Failed to add edge', app.modal.body(), 'danger'); @@ -1328,7 +1356,7 @@ await app.api.delete('directory-admin/edges/' + id); allEdges = allEdges.filter(e => e.id !== id); refreshEdgesUI($('#res-id').val()); - await loadData(); + await loadResources(); } catch (err) { console.error(err); app.messages.action('Failed to remove edge', app.modal.body(), 'danger'); @@ -1389,21 +1417,31 @@ } } + // Promoting a discovered resource opens the resource form pre-filled with the + // discovered data so it can be reviewed before the resource is marked managed + // (and its LDAP groups created). The modal's Save (saveResource) sees + // promoteSlug set and calls the promote endpoint instead of a normal save. function promoteResource(slug) { - app.api.post('discovery/promote/' + slug, {}, function(err, res) { - if(err) { - app.messages.toast("Error promoting resource: " + (err.message || err), 'danger'); - return; - } - const resource = allDiscoveryResources.find(r => r.slug === slug); - if(resource) { - resource.metadata = resource.metadata || {}; - resource.metadata.managed = true; - } - $('.actionMessage').html('
Successfully promoted! Created groups: ' + res.groups.join(', ') + '
').show(); - renderDiscoveryTable(); - loadResources(); // Also update directory tab - }); + const r = allDiscoveryResources.find(x => x.slug === slug); + if (!r) { app.messages.toast('Discovered resource not found', 'danger'); return; } + promoteSlug = slug; + openResourceModal('Promote Resource', null); // add-mode: groups/children tabs hidden + const m = r.metadata || {}; + $('#res-name').val(r.name || ''); + $('#res-slug').val(r.slug || ''); + $('#res-kind').val(r.kind || 'host'); + $('#res-description').val(r.description || ''); + $('#res-ip').val(m.ip || ''); + $('#res-address').val(m.address || ''); + $('#res-subtype').val(m.subType || ''); + $('#res-mac').val(m.macAddress || ''); + $('#res-port').val(m.port || ''); + $('#res-external-port').val(m.externalPort || ''); + $('#res-icon').val(m.icon || ''); + $('#res-tagline').val(m.tagline || ''); + updateIconPreview(); + toggleFormFields(); + loadLdapGroups(); } // --- THETA AGENT INSTALL MODAL & WIZARD --- diff --git a/nodejs/views/vault.ejs b/nodejs/views/vault.ejs index a06c6ed..fc74c57 100644 --- a/nodejs/views/vault.ejs +++ b/nodejs/views/vault.ejs @@ -4,12 +4,13 @@
-
+
+ Powered by OpenBao