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:
@@ -4,4 +4,7 @@ module.exports = {
|
||||
redis: {
|
||||
prefix: 'sso_manager_test_'
|
||||
},
|
||||
oauth: {
|
||||
jwtSecret: 'test-jwt-secret-for-automated-tests-only'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user