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
This commit is contained in:
@@ -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.
|
||||
|
||||
+9
-2
@@ -44,8 +44,9 @@ 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,
|
||||
@@ -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);
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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": [
|
||||
{
|
||||
|
||||
+58
-54
@@ -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);
|
||||
};
|
||||
|
||||
+54
-16
@@ -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('<div class="alert alert-success alert-dismissible"><button type="button" class="btn-close" data-bs-dismiss="alert"></button>Successfully promoted! Created groups: ' + res.groups.join(', ') + '</div>').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 ---
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card shadow">
|
||||
<div class="card-header">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<ul class="nav nav-tabs card-header-tabs" id="vault-tabs" role="tablist">
|
||||
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-secrets" type="button"><i class="fa-solid fa-lock"></i> Secrets</button></li>
|
||||
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-apps" type="button"><i class="fa-solid fa-key"></i> Apps</button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-shared" type="button"><i class="fa-solid fa-share-nodes"></i> Shared</button></li>
|
||||
</ul>
|
||||
<span class="small text-muted"><i class="fa-solid fa-database me-1"></i>Powered by <a href="https://openbao.org" target="_blank" rel="noopener">OpenBao</a></span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="tab-content">
|
||||
|
||||
Reference in New Issue
Block a user