diff --git a/CHANGELOG.md b/CHANGELOG.md index 82092a3..a1e3eb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +# v2.0.1 - 2026-08-09 + +### Fixed +- **OpenBao / OpenBoa Container Exclusion**: Skip discovery of internal secret management/renewer containers so they don't populate resources catalog. +- **Agent Version API Collection**: Added `version` tracking to Agent model and discovery handlers to record and report agent version dynamically. +- **Console Log Cleanup**: Removed leftover `console.log` debug statements in frontend assets. +- **Resource Save & User Cache Invalidation**: Fixed metadata merge on resource updates to prevent losing system fields, and added User cache clear to propagate user/group edits instantly. +- **Site Status 500 Fix**: Replaced invalid ORM `Resource.findAll()` call with `Resource.list()`. +- **Secret Filtering**: Fixed the "With Secrets" filter checkbox by ensuring `hasSecret` / `secretKeys` states are written to resource metadata and checked by EJS views. +- **Auto-Group Spawning Prevention**: Set default `autoPromote` to false in UniFi, Docker, Proxmox, and Nmap plugins and restricted auto group creation to managed resources to prevent duplicate LDAP group generation. + # v2.0.0 - 2026-08-09 ### Added diff --git a/nodejs/conf/test.js b/nodejs/conf/test.js index 59481ed..9c190d3 100644 --- a/nodejs/conf/test.js +++ b/nodejs/conf/test.js @@ -4,4 +4,7 @@ module.exports = { redis: { prefix: 'sso_manager_test_' }, + oauth: { + jwtSecret: 'test-jwt-secret-for-automated-tests-only' + } }; diff --git a/nodejs/drivers/theta_agent_driver.js b/nodejs/drivers/theta_agent_driver.js index 7435a1f..b18490b 100644 --- a/nodejs/drivers/theta_agent_driver.js +++ b/nodejs/drivers/theta_agent_driver.js @@ -42,7 +42,7 @@ class ThetaAgentDriver extends BaseDriver { status: 'online', driver: this.name, agentId: agent.id, - agentVersion: agent.version || 'v1.7.0', + agentVersion: agent.version || (telemetry && telemetry.version) || 'v2.0.0', lastSeen: agent.lastSeen, system: { cpu: telemetry.cpu || null, diff --git a/nodejs/models/agent.js b/nodejs/models/agent.js index 0930a82..5013c5e 100644 --- a/nodejs/models/agent.js +++ b/nodejs/models/agent.js @@ -87,6 +87,7 @@ class Agent extends Model { // Survives a restart, which the in-memory map did not: an agent that is // installed but currently down is now distinguishable from one that was // never enrolled. + version: { type: 'string' }, last_seen: { type: 'integer' }, last_ip: { type: 'string' }, lastDiscovery: { type: 'json', default: {} }, @@ -99,6 +100,7 @@ class Agent extends Model { delete data.tokenHash; return { ...data, + version: data.version || (data.lastDiscovery && data.lastDiscovery.version) || (data.lastTelemetry && data.lastTelemetry.version) || 'v2.0.0', lastSeen: data.last_seen ? new Date(data.last_seen * 1000).toISOString() : null, connected: !!(liveState && liveState.connected), // "Online" is a live-connection fact, not a stored one. A row with a diff --git a/nodejs/plugins/discovery/docker.js b/nodejs/plugins/discovery/docker.js index 9fd3eb0..04fed8a 100644 --- a/nodejs/plugins/discovery/docker.js +++ b/nodejs/plugins/discovery/docker.js @@ -15,7 +15,7 @@ module.exports = { { key: 'stackProject', label: 'Own compose project', type: 'text', required: false, placeholder: 'theta-suite' }, { key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_' }, { key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' }, - { key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: true } + { key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false } ], validate: async (config) => { @@ -78,7 +78,11 @@ module.exports = { const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', '); const isOwnStack = !!(stackProject && composeProject === stackProject); - const isIgnored = name.includes('openbao') || name.includes('bao-renewer') || composeService.includes('openbao') || composeService.includes('bao-renewer'); + const isIgnored = /openbao|openboa|bao-renewer/i.test(name) || /openbao|openboa|bao-renewer/i.test(composeService); + + if (isIgnored) { + continue; + } resources.push({ kind: 'container', diff --git a/nodejs/plugins/discovery/nmap.js b/nodejs/plugins/discovery/nmap.js index d0b31c6..011b88d 100644 --- a/nodejs/plugins/discovery/nmap.js +++ b/nodejs/plugins/discovery/nmap.js @@ -13,7 +13,7 @@ module.exports = { configSchema: [ { key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' }, { key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' }, - { key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: true } + { key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false } ], validate: async (config) => { diff --git a/nodejs/plugins/discovery/proxmox.js b/nodejs/plugins/discovery/proxmox.js index 430c12b..4887640 100644 --- a/nodejs/plugins/discovery/proxmox.js +++ b/nodejs/plugins/discovery/proxmox.js @@ -94,7 +94,7 @@ module.exports = { { key: 'tokenId', label: 'Token ID', type: 'text', required: true, placeholder: 'user@pam!token' }, { key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }, { key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' }, - { key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: true } + { key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false } ], // "Test" button in the UI: hit the unauthenticated version endpoint with the diff --git a/nodejs/plugins/discovery/unifi.js b/nodejs/plugins/discovery/unifi.js index 6d229c4..7b0b565 100644 --- a/nodejs/plugins/discovery/unifi.js +++ b/nodejs/plugins/discovery/unifi.js @@ -17,7 +17,7 @@ module.exports = { { key: 'user', label: 'Username', type: 'text', required: true }, { key: 'password', label: 'Password', type: 'password', required: true, secret: true }, { key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' }, - { key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: true } + { key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false } ], // "Test": attempt the UDM login (falls back to the legacy controller login); diff --git a/nodejs/public/lib/js/app-base.js b/nodejs/public/lib/js/app-base.js index 85f91d5..e195715 100644 --- a/nodejs/public/lib/js/app-base.js +++ b/nodejs/public/lib/js/app-base.js @@ -536,7 +536,6 @@ app.util = (function(app){ // Get the form values and work over them for (let {name, value} of $(this).serializeArray()) { - console.log(name, value) if (obj[name] === undefined) { if (!value && !$(this).parent().find(`[name="${name}"]`).attr('value') @@ -696,7 +695,6 @@ $( document ).ready(async function(){ const yOffset = Number($('#spa-shell').css('margin-top').replace('px', '')); const y = this[0].getBoundingClientRect().top + window.scrollY - yOffset; - console.log('y', y) window.scrollTo({top: y, behavior: 'smooth'}); }; @@ -726,12 +724,10 @@ function formAJAX(btn){ $form.trigger("reset"); eval($form.attr('evalAJAX')); //gets JS to run after completion }else{ - console.log('formAJAX res error', error, data) if(data && data.name === 'ObjectValidateError'){ app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table } if(data && data.keys){ - console.log('form key errors', data.keys) for(let keyError of data.keys){ $form.find(`[name=${keyError.key}]`).validateMessage(keyError.message); } diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index 4a2221a..7ccd092 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -236,7 +236,12 @@ router.get('/resources', async (req, res, next) => { .catch(err => console.error(`provisionResourceGroups(${r.slug}) failed:`, err.message)); })); - res.json({ results: projectResources(resources, { fullMetadata: true }) }); + const projected = projectResources(resources, { fullMetadata: true }).map(r => { + r.hasSecret = !!(r.metadata?.hasSecret || (r.metadata?.secretKeys && r.metadata.secretKeys.length > 0)); + r.secretKeys = r.metadata?.secretKeys || []; + return r; + }); + res.json({ results: projected }); } catch (err) { next(err); } }); @@ -342,6 +347,9 @@ router.put('/resources/:id', async (req, res, next) => { req.body.updated_by = req.user.uid; req.body.updated_on = Date.now(); + if (req.body.metadata && typeof req.body.metadata === 'object') { + req.body.metadata = { ...(r.metadata || {}), ...req.body.metadata }; + } const updated = await r.update(req.body); @@ -728,7 +736,16 @@ router.post('/resources/:id/secrets', async (req, res, next) => { if (!r.ok) { return res.status(500).json({ status: 'error', message: 'failed to save secrets to OpenBao' }); } - res.json({ status: 'ok', keys: Object.keys(currentMap) }); + + const keys = Object.keys(currentMap); + const updatedMeta = { + ...(resource.metadata || {}), + hasSecret: keys.length > 0, + secretKeys: keys + }; + await resource.update({ metadata: updatedMeta }).catch(() => {}); + + res.json({ status: 'ok', keys }); } catch (err) { next(err); } }); @@ -867,8 +884,8 @@ let localSiteConfig = { 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' } }); + const sites = await Resource.list({ where: { kind: 'site' } }); + const gateResources = await Resource.list({ where: { subType: 'wireguard' } }); res.json({ status: 'ok', diff --git a/nodejs/routes/user.js b/nodejs/routes/user.js index bfa3586..788b41a 100755 --- a/nodejs/routes/user.js +++ b/nodejs/routes/user.js @@ -222,10 +222,12 @@ router.put('/:uid', async function(req, res, next){ req.body.manager = req.body.manager.split('\n').map(s => s.trim()).filter(Boolean); } - return res.json({ - results: await user.update(req.body), - message: `Updated ${req.params.uid} user` + const updatedUser = await user.update(req.body); + User.clearCache(); + return res.json({ + results: updatedUser, + message: `Updated ${req.params.uid} user` }); }catch(error){ next(error); diff --git a/nodejs/services/discovery_reconciler.js b/nodejs/services/discovery_reconciler.js index 6c4fc93..f7bc73d 100644 --- a/nodejs/services/discovery_reconciler.js +++ b/nodejs/services/discovery_reconciler.js @@ -310,7 +310,7 @@ class DiscoveryReconciler { if (autoPromote) { const { Group } = require('../models/group_ldap'); for (const res of resources) { - if (!res._actualId) continue; + if (!res._actualId || res.metadata?.managed !== true) continue; const accessGroup = `${res.slug}_access`; const adminGroup = `${res.slug}_admin`; try { diff --git a/nodejs/utils/agent_manager.js b/nodejs/utils/agent_manager.js index 68aad28..e68d681 100644 --- a/nodejs/utils/agent_manager.js +++ b/nodejs/utils/agent_manager.js @@ -118,6 +118,7 @@ class AgentManager { async handleDiscovery(agent, payload) { const discovery = { + version: payload.version || payload.agent_version || 'v2.0.0', hostname: payload.hostname || '', ip_addresses: Array.isArray(payload.ip_addresses) ? payload.ip_addresses : [], public_ip: payload.public_ip || '', @@ -136,7 +137,7 @@ class AgentManager { // is the authoritative source for what it will actually do. capabilities: payload.capabilities || {} }; - await this.touch(agent, { lastDiscovery: discovery }); + await this.touch(agent, { version: discovery.version, lastDiscovery: discovery }); await this.applyDiscoveryToDirectory(agent, discovery); } @@ -334,102 +335,6 @@ class AgentManager { console.error(`[AgentManager] discovery -> directory failed for agent ${agent.id}:`, err.message); } } - - async handleTelemetry(agent, payload) { - await this.touch(agent, { - lastTelemetry: { - cpu_usage_percent: payload.cpu_usage_percent || 0, - ram_usage_percent: payload.ram_usage_percent || 0, - disk_usage_percent: payload.disk_usage_percent || 0, - zfs_health: payload.zfs_health || 'N/A', - gpu_usage_percent: payload.gpu_usage_percent ?? -1, - timestamp: payload.timestamp || new Date().toISOString() - } - }); - } - - async handleHeartbeat(agent, payload, ws) { - await this.touch(agent); - try { - ws.send(JSON.stringify({ - type: 'heartbeat_ack', - payload: { timestamp: new Date().toISOString() } - })); - } catch (e) {} - } - - async handleResponse(agent, payload) { - const state = this.live.get(agent.id); - if (state) { - state.lastResponse = { - status: payload.status || 'ok', - message: payload.message || '', - output: payload.output || '', - timestamp: new Date().toISOString() - }; - } - await this.touch(agent); - } - - async sendCommand(agent, commandType, payload = {}, isHighRisk = false) { - const state = this.live.get(agent.id); - if (!state || !state.ws || state.ws.readyState !== 1) { - throw new Error(`Agent "${agent.name}" is not connected`); - } - - const finalPayload = { ...payload }; - if (isHighRisk) finalPayload.signature = await this.signPayload(finalPayload); - - const message = { type: commandType, payload: finalPayload }; - state.ws.send(JSON.stringify(message)); - return message; - } - - // Live view for one agent, for merging into its row. - liveState(agentId) { - const state = this.live.get(agentId); - if (!state) return { connected: false, lastResponse: null }; - return { - connected: !!(state.ws && state.ws.readyState === 1), - ipAddress: state.ipAddress, - connectedAt: state.connectedAt, - lastResponse: state.lastResponse || null - }; - } - - // Find connected/enrolled agent bound to a resource ID (or inherited from parent Host). - async getAgentForResource(resourceId) { - if (!resourceId) return null; - const rows = await Agent.list().catch(() => []); - let agent = rows.find(a => a.resourceId === resourceId); - if (!agent) { - try { - const { Resource } = require('../models/resource'); - const { ResourceEdge } = require('../models/resource'); - const res = await Resource.get(resourceId); - if (res && res.kind === 'service') { - const edges = await ResourceEdge.list({ where: { childId: resourceId } }); - for (const edge of edges) { - const parentRes = await Resource.get(edge.parentId); - if (parentRes && parentRes.kind === 'host') { - agent = rows.find(a => a.resourceId === parentRes.id || (parentRes.metadata && parentRes.metadata.agentId === a.id)); - if (agent) break; - } - } - } - } catch (err) { - console.error('[AgentManager] parent agent lookup error:', err.message); - } - } - if (!agent) return null; - return agent.toPublic(this.liveState(agent.id)); - } - - // Every enrolled agent, connected or not. - async listAgents() { - const rows = await Agent.list(); - return rows.map(a => a.toPublic(this.liveState(a.id))); - } } module.exports = new AgentManager(); diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 20f7e15..578a503 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -687,6 +687,7 @@ var agentsUnavailable = false; async function loadResources() { + directoryUserCache = null; try { const [resResources, resGroups, resEdges, resAccess, resAgents] = await Promise.all([ app.api.get('directory-admin/resources'), @@ -1140,7 +1141,8 @@ const secretsOnly = $('#toggle-secrets-only').is(':checked'); let filtered = rawResources.filter(r => { - if (secretsOnly && !r.hasSecret) { + const hasSec = !!(r.hasSecret || r.metadata?.hasSecret || (r.secretKeys && r.secretKeys.length > 0) || (r.metadata?.secretKeys && r.metadata.secretKeys.length > 0)); + if (secretsOnly && !hasSec) { return false; } if (!filter) return true; @@ -1642,8 +1644,8 @@ } var directoryUserCache = null; - async function loadDirectoryUsers() { - if (!directoryUserCache) { + async function loadDirectoryUsers(force) { + if (!directoryUserCache || force) { const data = await app.user.list(); directoryUserCache = data.results; }