sec: authenticate theta-agent enrollment; directory + discovery fixes (v1.29.0)
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m30s
Pull Request Tests / Run Tests (20.x) (push) Successful in 23s
Pull Request Tests / Run Tests (22.x) (push) Failing after 37s
Pull Request Tests / Test Summary (push) Failing after 4s

SECURITY

/api/agent/ws authenticated nothing. There was no agent registry, so any
client reaching the SSO could register as a node, publish discovery and
telemetry into the admin view, and receive commands -- including a signed
arbitrary_bash -- addressed to a token it guessed. Tokens were generated
in the BROWSER and never recorded server-side, so there was nothing to
validate against and no way to revoke one.

Agents are now rows in a new Agent table, authenticated by SHA-256 token
hash before the connection is registered or the welcome payload is sent.
Tokens are minted by POST /api/agent/enroll and shown once. Revoke and
rotate drop the live socket immediately. All agent actions are audited.

The Ed25519 command-signing key was generated in the AgentManager
constructor, so it changed on every restart and the public_key pinned in
an agent's agent.yml stopped matching. It now lives in OpenBao at
secret/agent/signing-key; if it cannot be loaded the SSO refuses to send
high-risk commands rather than signing with a key no agent has seen.

DIRECTORY

Agents bind to a host resource instead of being matched by hostname, and
a bound agent's discovery is written onto that resource -- previously the
one source running ON the host contributed nothing to the directory.

The resource tree is collapsible, with state persisted per browser.

DISCOVERY

The Proxmox plugin zipped MACs and IPs from two flat lists by index,
attributing addresses to the wrong NIC on multi-NIC guests. NICs are now
keyed by MAC. Adds an endpoint resource parenting each node, sourceId/
vmid/node identity, container-interface filtering, node IP/MAC, and
offline-node handling.

The reconciler could make a resource its own parent, named hosts after
their MAC address, had a dead isIp() regex (\\. matches a backslash),
merged across kinds, and re-read the whole inventory per resource.

Dockerfile.test-runner never copied nodejs/plugins, so every plugin test
suite failed in CI as "Cannot find module".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 18:44:37 -04:00
parent 49100c9b68
commit 87339da1b2
19 changed files with 1810 additions and 286 deletions
+216 -35
View File
@@ -4,9 +4,16 @@ const express = require('express');
const middleware = require('../middleware/auth');
const permission = require('../utils/permission');
const agentManager = require('../utils/agent_manager');
const agentKeys = require('../utils/agent_keys');
const { Agent } = require('../models/agent');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
// Commands that can change or run code on the host. They are signed with the
// SSO's persisted Ed25519 key and the agent verifies against the key pinned in
// its agent.yml.
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary'];
// ── 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
@@ -17,9 +24,21 @@ const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_adm
// 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.
// Structured audit line for anything that reaches a host. The agent channel can
// run arbitrary bash, so "who told which host to do what" has to be recoverable
// after the fact; previously nothing was recorded at all.
function logAgentAudit(action, details) {
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
component: 'agent',
action,
...details
}));
}
// The agent WebSocket (/api/agent/ws) authenticates its own token against the
// Agent table (see initAgentWebSockets). These REST routes are admin-facing, so
// they're auth + admin gated.
router.use(middleware.auth);
router.use(async (req, res, next) => {
try {
@@ -33,85 +52,246 @@ router.use(async (req, res, next) => {
}
});
router.get('/nodes', (req, res) => {
res.json({
status: 'ok',
agents: agentManager.getConnectedAgents(),
publicKey: agentManager.publicKeyPem
});
// --- Fleet ---
router.get('/nodes', async (req, res, next) => {
try {
const keyStatus = agentKeys.status();
res.json({
status: 'ok',
agents: await agentManager.listAgents(),
// Base64 of the raw 32-byte key: what goes into agent.yml's `public_key`.
publicKey: await agentManager.publicKeyBase64(),
publicKeyPem: await agentManager.publicKeyPem(),
signingAvailable: agentKeys.status().available,
signingError: keyStatus.error || null
});
} catch (err) { next(err); }
});
router.post('/nodes/:token/command', (req, res) => {
const { token } = req.params;
const { command, payload, isHighRisk } = req.body;
// --- Enrollment ---
// The token is minted HERE, not in the browser. It is returned exactly once;
// only its hash is stored, so it cannot be recovered afterwards -- rotate to
// get a new one.
router.post('/enroll', async (req, res, next) => {
try {
const { name, resourceId, description } = req.body || {};
if (!name || !String(name).trim()) {
return res.status(400).json({ status: 'error', message: 'name is required' });
}
if (resourceId) {
const { Resource } = require('../models/resource');
const resource = await Resource.get(resourceId);
if (!resource) return res.status(400).json({ status: 'error', message: 'resourceId does not exist' });
if (resource.kind !== 'host') {
return res.status(400).json({ status: 'error', message: 'an agent can only be bound to a host resource' });
}
}
const { agent, token } = await Agent.enroll({
name: String(name).trim(),
description,
resourceId: resourceId || null,
enrolledBy: req.user.uid
});
logAgentAudit('enroll', { actor: req.user.uid, agentId: agent.id, agentName: agent.name, resourceId: resourceId || null });
const publicKey = await agentManager.publicKeyBase64();
res.json({
status: 'ok',
agent: agent.toPublic(agentManager.liveState(agent.id)),
// Shown once. The UI must make that clear.
token,
publicKey,
signingAvailable: agentKeys.status().available
});
} catch (err) { next(err); }
});
router.put('/nodes/:id', async (req, res, next) => {
try {
const agent = await Agent.get(req.params.id);
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
const patch = {};
if (req.body.name !== undefined) patch.name = req.body.name;
if (req.body.description !== undefined) patch.description = req.body.description;
if (req.body.resourceId !== undefined) {
if (req.body.resourceId) {
const { Resource } = require('../models/resource');
const resource = await Resource.get(req.body.resourceId);
if (!resource) return res.status(400).json({ status: 'error', message: 'resourceId does not exist' });
if (resource.kind !== 'host') {
return res.status(400).json({ status: 'error', message: 'an agent can only be bound to a host resource' });
}
}
patch.resourceId = req.body.resourceId || null;
}
const updated = await agent.update(patch);
logAgentAudit('update', { actor: req.user.uid, agentId: agent.id, fields: Object.keys(patch) });
res.json({ status: 'ok', agent: updated.toPublic(agentManager.liveState(agent.id)) });
} catch (err) { next(err); }
});
// Revoke: the token stops authenticating immediately and any live socket is
// dropped, so revocation takes effect without waiting for a reconnect.
router.post('/nodes/:id/revoke', async (req, res, next) => {
try {
const agent = await Agent.get(req.params.id);
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
await agent.update({ revoked: true });
agentManager.disconnect(agent.id, 4003, 'Enrollment revoked');
logAgentAudit('revoke', { actor: req.user.uid, agentId: agent.id, agentName: agent.name });
res.json({ status: 'ok' });
} catch (err) { next(err); }
});
router.post('/nodes/:id/rotate', async (req, res, next) => {
try {
const agent = await Agent.get(req.params.id);
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
const token = await agent.rotateToken();
// The old token is dead the moment it is replaced; drop the socket that was
// using it so the agent reconnects with the new one.
agentManager.disconnect(agent.id, 4004, 'Token rotated');
logAgentAudit('rotate', { actor: req.user.uid, agentId: agent.id, agentName: agent.name });
res.json({ status: 'ok', token, publicKey: await agentManager.publicKeyBase64() });
} catch (err) { next(err); }
});
router.delete('/nodes/:id', async (req, res, next) => {
try {
const agent = await Agent.get(req.params.id);
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
agentManager.disconnect(agent.id, 4003, 'Enrollment deleted');
await agent.delete();
logAgentAudit('delete', { actor: req.user.uid, agentId: agent.id, agentName: agent.name });
res.json({ status: 'ok' });
} catch (err) { next(err); }
});
// --- Commands ---
// Addressed by agent id, not by token: a token is a credential and has no
// business travelling in a URL, being logged, or sitting in browser history.
router.post('/nodes/:id/command', async (req, res, next) => {
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 agent = await Agent.get(req.params.id);
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
if (agent.revoked) return res.status(403).json({ status: 'error', message: 'agent enrollment is revoked' });
const requiresSigning = isHighRisk || HIGH_RISK_COMMANDS.includes(command);
const msg = await agentManager.sendCommand(agent, command, payload || {}, requiresSigning);
logAgentAudit('command', {
actor: req.user.uid,
agentId: agent.id,
agentName: agent.name,
resourceId: agent.resourceId || null,
command,
signed: requiresSigning
});
const msg = agentManager.sendCommand(token, command, payload || {}, requiresSigning);
res.json({ status: 'ok', sentMessage: msg });
} catch (err) {
logAgentAudit('command_failed', { actor: req.user && req.user.uid, agentId: req.params.id, command, error: err.message });
res.status(400).json({ status: 'error', message: err.message });
}
});
module.exports = router;
module.exports.HIGH_RISK_COMMANDS = HIGH_RISK_COMMANDS;
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) => {
// Warm the signing key at boot so a misconfigured OpenBao policy is a loud
// startup error rather than a surprise the first time someone reboots a host.
agentKeys.load().then(keys => {
if (!keys) console.error(`[Theta Agent] signing key unavailable — high-risk commands will be refused. ${agentKeys.status().error || ''}`);
});
app.wss.on('connection', async (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const token = url.searchParams.get('token') || req.headers['authorization'];
const remoteAddr = req.socket.remoteAddress;
if (!token) {
ws.close(4001, 'Unauthorized: Missing token');
// Authenticate BEFORE doing anything else: no registration, no welcome
// payload, no acknowledgement that the token was close. Until this passes
// the peer is an anonymous stranger, and the old code treated it as a
// trusted node purely for presenting a non-empty string.
let agent = null;
try {
agent = await Agent.authenticate(token);
} catch (err) {
console.error('[Theta Agent] authentication lookup failed:', err.message);
try { ws.close(1011, 'Authentication unavailable'); } catch (e) {}
return;
}
const remoteAddr = req.socket.remoteAddress;
console.log(`[Theta Agent] Agent connected from ${remoteAddr} with token ${token.substring(0, 8)}...`);
if (!agent) {
// Deliberately indistinguishable for unknown vs revoked vs missing: a
// caller probing tokens learns nothing about which part was wrong.
logAgentAudit('auth_rejected', { remoteAddr, tokenPrefix: token ? String(token).slice(0, 8) : null });
try { ws.close(4001, 'Unauthorized'); } catch (e) {}
return;
}
agentManager.registerAgent(token, ws, remoteAddr);
console.log(`[Theta Agent] "${agent.name}" (${agent.id}) connected from ${remoteAddr}`);
logAgentAudit('connected', { agentId: agent.id, agentName: agent.name, remoteAddr });
// Must stay synchronous, and the listeners below must be attached in this
// same tick: the agent sends `discovery` the instant the socket opens, and
// `ws` discards messages emitted while no listener is attached.
agentManager.registerAgent(agent, ws, remoteAddr);
ws.on('message', (message) => {
ws.on('message', async (message) => {
try {
const data = JSON.parse(message);
if (!data || typeof data.type !== 'string') return;
// Re-read the row per message so a revoke mid-session takes effect on
// the next thing the agent says, not only on reconnect.
const current = await Agent.get(agent.id).catch(() => null);
if (!current || current.revoked) {
try { ws.close(4003, 'Enrollment revoked'); } catch (e) {}
return;
}
const payload = data.payload || {};
switch (data.type) {
case 'discovery':
agentManager.handleDiscovery(token, payload);
if (app.io) app.io.emit('agent.discovery', { token, payload });
await agentManager.handleDiscovery(current, payload);
if (app.io) app.io.emit('agent.discovery', { agentId: current.id, payload });
break;
case 'telemetry':
agentManager.handleTelemetry(token, payload);
if (app.io) app.io.emit('agent.telemetry', { token, payload });
await agentManager.handleTelemetry(current, payload);
if (app.io) app.io.emit('agent.telemetry', { agentId: current.id, payload });
break;
case 'heartbeat':
agentManager.handleHeartbeat(token, payload, ws);
await agentManager.handleHeartbeat(current, payload, ws);
break;
case 'response':
agentManager.handleResponse(token, payload);
if (app.io) app.io.emit('agent.response', { token, payload });
await agentManager.handleResponse(current, payload);
if (app.io) app.io.emit('agent.response', { agentId: current.id, payload });
break;
default:
console.log(`[Theta Agent] Received message type '${data.type}' from ${token}`);
console.log(`[Theta Agent] Received message type '${data.type}' from ${current.id}`);
}
} catch (err) {
console.error("[Theta Agent] Error parsing message:", err);
console.error('[Theta Agent] Error handling message:', err);
}
});
ws.on('close', () => {
console.log(`[Theta Agent] Agent disconnected (${token})`);
agentManager.unregisterAgent(token, ws);
console.log(`[Theta Agent] "${agent.name}" (${agent.id}) disconnected`);
agentManager.unregisterAgent(agent.id, ws);
});
// Send initial welcome/config payload
@@ -120,7 +300,8 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
type: 'config',
payload: {
message: 'Connected to SSO Manager C2',
protocol_version: '1.1.0'
protocol_version: '1.2.0',
agent_id: agent.id
}
}));
} catch (e) {}