release(v2.0.1): fix openbao container discovery, agent version API collection, console logs, cache invalidation, site status API 500, secrets filtering, and auto-group spawning

This commit is contained in:
2026-08-09 14:46:33 -04:00
parent bb744adb5e
commit 98ed99a7e9
14 changed files with 60 additions and 118 deletions
+11
View File
@@ -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
+3
View File
@@ -4,4 +4,7 @@ module.exports = {
redis: {
prefix: 'sso_manager_test_'
},
oauth: {
jwtSecret: 'test-jwt-secret-for-automated-tests-only'
}
};
+1 -1
View File
@@ -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,
+2
View File
@@ -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
+6 -2
View File
@@ -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_<hostname>' },
{ 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',
+1 -1
View File
@@ -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) => {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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);
-4
View File
@@ -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);
}
+21 -4
View File
@@ -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',
+5 -3
View File
@@ -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);
+1 -1
View File
@@ -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 {
+2 -97
View File
@@ -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();
+5 -3
View File
@@ -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;
}