Files
sso-manager-node/nodejs/utils/agent_manager.js
wmantly 87339da1b2
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
sec: authenticate theta-agent enrollment; directory + discovery fixes (v1.29.0)
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>
2026-08-05 18:44:37 -04:00

254 lines
9.2 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const agentKeys = require('./agent_keys');
const { Agent } = require('../models/agent');
// Tracks the live WebSocket for each enrolled agent and brokers commands to it.
//
// The durable facts about an agent (identity, host binding, last seen, last
// discovery/telemetry) live in the Agent table; this class holds only what
// cannot be persisted -- the open socket. That split is what makes an installed
// -but-offline agent visible, and what stops a restart from erasing the fleet.
class AgentManager {
constructor() {
// agentId -> { ws, ipAddress, connectedAt, lastResponse, pending }
this.live = new Map();
}
/**
* Canonicalize payload for signing per PROTOCOL.md v1.1.0 section 5:
* Sort keys alphabetically, remove whitespace, omit 'signature' key.
*/
canonicalize(payload) {
const cleanObj = {};
const sortedKeys = Object.keys(payload).filter(k => k !== 'signature').sort();
for (const key of sortedKeys) {
cleanObj[key] = payload[key];
}
return JSON.stringify(cleanObj);
}
/**
* Sign payload using the persisted Ed25519 private key. Throws when no key is
* available rather than minting a throwaway one -- an agent verifies against
* the key pinned in its agent.yml, so a signature from a key it has never
* seen is not a weaker signature, it is a broken command that looks fine from
* this side.
*/
async signPayload(payload) {
const keys = await agentKeys.load();
if (!keys) {
const { error } = agentKeys.status();
throw new Error(`agent command signing is unavailable: ${error || 'no signing key'}`);
}
const canonicalBytes = Buffer.from(this.canonicalize(payload), 'utf8');
return crypto.sign(null, canonicalBytes, keys.privateKeyPem).toString('base64');
}
async publicKeyBase64() {
const keys = await agentKeys.load();
return keys ? keys.publicKeyBase64 : null;
}
async publicKeyPem() {
const keys = await agentKeys.load();
return keys ? keys.publicKeyPem : null;
}
// Bind a freshly authenticated socket to an enrolled agent. `agent` is an
// Agent row that Agent.authenticate() has already vouched for -- this method
// never sees a raw token and must never be called with an unauthenticated one.
// Synchronous by design. The caller must attach its `message` listener in the
// same tick as the connection is accepted: `ws` drops events emitted before a
// listener exists, and the agent sends `discovery` immediately on open, so
// awaiting a database round-trip here silently lost every agent's first
// discovery frame. The connect timestamp is persisted in the background.
registerAgent(agent, ws, remoteAddress) {
const existing = this.live.get(agent.id);
if (existing && existing.ws && existing.ws !== ws) {
try { existing.ws.close(4002, 'Superseded by new connection'); } catch (e) {}
}
this.live.set(agent.id, {
ws,
ipAddress: remoteAddress,
connectedAt: new Date().toISOString(),
lastResponse: null
});
agent.update({
last_seen: Math.floor(Date.now() / 1000),
last_ip: remoteAddress || null
}).catch(err => console.error(`[AgentManager] could not record connect for ${agent.id}:`, err.message));
}
unregisterAgent(agentId, ws) {
const state = this.live.get(agentId);
if (state && state.ws === ws) this.live.delete(agentId);
}
// Drop an agent's live socket now. Revocation that only takes effect on the
// next reconnect is not revocation -- a connected agent would keep receiving
// commands indefinitely.
disconnect(agentId, code = 4003, reason = 'Disconnected by server') {
const state = this.live.get(agentId);
if (!state || !state.ws) return false;
try { state.ws.close(code, reason); } catch (e) {}
this.live.delete(agentId);
return true;
}
isConnected(agentId) {
const state = this.live.get(agentId);
return !!(state && state.ws && state.ws.readyState === 1);
}
async touch(agent, extra = {}) {
await agent.update({
last_seen: Math.floor(Date.now() / 1000),
...extra
}).catch(err => console.error(`[AgentManager] could not persist agent ${agent.id}:`, err.message));
}
async handleDiscovery(agent, payload) {
const discovery = {
hostname: payload.hostname || '',
ip_addresses: Array.isArray(payload.ip_addresses) ? payload.ip_addresses : [],
os: payload.os || '',
kernel: payload.kernel || '',
cpu: payload.cpu || '',
ram_total_gb: payload.ram_total_gb || 0,
disk_total_gb: payload.disk_total_gb || 0,
location: payload.location || 'default'
};
await this.touch(agent, { lastDiscovery: discovery });
await this.applyDiscoveryToDirectory(agent, discovery);
}
// An agent runs ON the host it describes, which makes it the most
// authoritative source the directory has -- more so than a hypervisor API or
// a network scan. It previously updated nothing at all: the facts sat on an
// in-memory record and were lost on disconnect.
//
// When the agent is bound to a resource we write that row directly; guessing
// is only for an unbound agent, and then we let the shared reconciler do the
// matching (same MAC/IP/name rules every other source goes through) rather
// than inventing a second matcher here.
async applyDiscoveryToDirectory(agent, discovery) {
try {
const { Resource } = require('../models/resource');
const metadata = {
os: discovery.os || undefined,
kernel: discovery.kernel || undefined,
cpu: discovery.cpu || undefined,
ram_total_gb: discovery.ram_total_gb || undefined,
disk_total_gb: discovery.disk_total_gb || undefined,
ip: (discovery.ip_addresses || [])[0] || undefined,
agentId: agent.id,
last_seen: Date.now()
};
// Drop undefined so a field the agent could not determine never
// overwrites a good value already in the directory.
for (const k of Object.keys(metadata)) if (metadata[k] === undefined) delete metadata[k];
if (agent.resourceId) {
const resource = await Resource.get(agent.resourceId);
if (!resource) return;
const merged = { ...(resource.metadata || {}), ...metadata };
const sources = new Set(merged.discovery_sources || []);
sources.add('theta-agent');
merged.discovery_sources = [...sources];
await resource.update({ metadata: merged, updated_on: Math.floor(Date.now() / 1000) });
return;
}
if (!discovery.hostname) return;
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
await DiscoveryReconciler.reconcile('theta-agent', {
resources: [{
kind: 'host',
name: discovery.hostname,
slug: `agent-${agent.id.slice(0, 8)}`,
metadata: { ...metadata, subType: 'linux' }
}],
edges: []
});
} catch (err) {
// Never let a directory write break the agent connection.
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
};
}
// 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();
module.exports.AgentManager = AgentManager;