Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a66c0e09cb |
@@ -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
|
# 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: 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.
|
- 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
|
// Initialize Theta Agent WebSockets. The REST router is already mounted
|
||||||
require('./routes/api_agent')(app);
|
// 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,
|
// 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.
|
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
||||||
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
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
|
// OAuth 2.0 / OpenID Connect
|
||||||
app.use('/oauth', oauthRouter);
|
app.use('/oauth', oauthRouter);
|
||||||
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.27.0",
|
"version": "1.28.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.27.0",
|
"version": "1.28.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.27.0",
|
"version": "1.28.0",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
|
|||||||
+57
-53
@@ -7,11 +7,63 @@ const agentManager = require('../utils/agent_manager');
|
|||||||
|
|
||||||
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||||
|
|
||||||
module.exports = function initAgentWebSockets(app) {
|
// ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ──
|
||||||
// Only the WebSocket handler needs the WS server. The REST routes mounted
|
// This is a plain Express Router exported directly so app.js can
|
||||||
// below (/api/agent/*) must work regardless of the WS server state -- gating
|
// `app.use('/api/agent', require('./routes/api_agent'))` at require time. It
|
||||||
// them on `app.wss` made them 404 whenever it wasn't initialized.
|
// must NOT be mounted from the onListen hook (which runs after the 404
|
||||||
if (app.wss) {
|
// 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) => {
|
app.wss.on('connection', (ws, req) => {
|
||||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||||
const token = url.searchParams.get('token') || req.headers['authorization'];
|
const token = url.searchParams.get('token') || req.headers['authorization'];
|
||||||
@@ -73,52 +125,4 @@ module.exports = function initAgentWebSockets(app) {
|
|||||||
}));
|
}));
|
||||||
} catch (e) {}
|
} 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 = [];
|
var rawResources = [];
|
||||||
// resourceId -> { groups: [{cn, accessLevel, exists, memberCount}], memberCount }
|
// resourceId -> { groups: [{cn, accessLevel, exists, memberCount}], memberCount }
|
||||||
var accessSummary = {};
|
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() {
|
$(document).ready(async function() {
|
||||||
await loadResources();
|
await loadResources();
|
||||||
@@ -1179,6 +1182,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveResource() {
|
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 id = $('#res-id').val();
|
||||||
const data = {
|
const data = {
|
||||||
name: $('#res-name').val(),
|
name: $('#res-name').val(),
|
||||||
@@ -1277,6 +1303,7 @@
|
|||||||
allGroups.push(res.results);
|
allGroups.push(res.results);
|
||||||
refreshGroupsUI(resourceId);
|
refreshGroupsUI(resourceId);
|
||||||
$('#new-group-cn').val('');
|
$('#new-group-cn').val('');
|
||||||
|
await loadResources(); // keep the Access column in sync
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action('Failed to add group', app.modal.body(), 'danger');
|
app.messages.action('Failed to add group', app.modal.body(), 'danger');
|
||||||
@@ -1288,6 +1315,7 @@
|
|||||||
await app.api.delete('directory-admin/groups/' + id);
|
await app.api.delete('directory-admin/groups/' + id);
|
||||||
allGroups = allGroups.filter(g => g.id !== id);
|
allGroups = allGroups.filter(g => g.id !== id);
|
||||||
refreshGroupsUI($('#res-id').val());
|
refreshGroupsUI($('#res-id').val());
|
||||||
|
await loadResources(); // keep the Access column in sync
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action('Failed to remove group', app.modal.body(), 'danger');
|
app.messages.action('Failed to remove group', app.modal.body(), 'danger');
|
||||||
@@ -1316,7 +1344,7 @@
|
|||||||
allEdges.push(res.results);
|
allEdges.push(res.results);
|
||||||
refreshEdgesUI(resourceId);
|
refreshEdgesUI(resourceId);
|
||||||
$('#new-edge-target').val('');
|
$('#new-edge-target').val('');
|
||||||
await loadData();
|
await loadResources();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
|
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
|
||||||
@@ -1328,7 +1356,7 @@
|
|||||||
await app.api.delete('directory-admin/edges/' + id);
|
await app.api.delete('directory-admin/edges/' + id);
|
||||||
allEdges = allEdges.filter(e => e.id !== id);
|
allEdges = allEdges.filter(e => e.id !== id);
|
||||||
refreshEdgesUI($('#res-id').val());
|
refreshEdgesUI($('#res-id').val());
|
||||||
await loadData();
|
await loadResources();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
|
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) {
|
function promoteResource(slug) {
|
||||||
app.api.post('discovery/promote/' + slug, {}, function(err, res) {
|
const r = allDiscoveryResources.find(x => x.slug === slug);
|
||||||
if(err) {
|
if (!r) { app.messages.toast('Discovered resource not found', 'danger'); return; }
|
||||||
app.messages.toast("Error promoting resource: " + (err.message || err), 'danger');
|
promoteSlug = slug;
|
||||||
return;
|
openResourceModal('Promote Resource', null); // add-mode: groups/children tabs hidden
|
||||||
}
|
const m = r.metadata || {};
|
||||||
const resource = allDiscoveryResources.find(r => r.slug === slug);
|
$('#res-name').val(r.name || '');
|
||||||
if(resource) {
|
$('#res-slug').val(r.slug || '');
|
||||||
resource.metadata = resource.metadata || {};
|
$('#res-kind').val(r.kind || 'host');
|
||||||
resource.metadata.managed = true;
|
$('#res-description').val(r.description || '');
|
||||||
}
|
$('#res-ip').val(m.ip || '');
|
||||||
$('.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();
|
$('#res-address').val(m.address || '');
|
||||||
renderDiscoveryTable();
|
$('#res-subtype').val(m.subType || '');
|
||||||
loadResources(); // Also update directory tab
|
$('#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 ---
|
// --- THETA AGENT INSTALL MODAL & WIZARD ---
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card shadow">
|
<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">
|
<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"><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" 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>
|
<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>
|
</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>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
|
|||||||
Reference in New Issue
Block a user