Files
sso-manager-node/nodejs/utils/agent_manager.js
T
wmantly 181ca8c9cb Add LDAP-over-HTTPS API, agent secrets/IAM engines, and join key management
See CHANGELOG.md for the full breakdown. Summary:

- POST /api/v1/ldap/{bind,search}: LDAP-over-HTTPS so a client stops
  speaking raw LDAP and instead calls the SSO, which binds/searches its
  own OpenLDAP on the caller's behalf (DESIGN.md §3).
- LDAP byte-pump relay (utils/ldap_tunnel.js): forwards raw LDAP bytes
  from an agent's local socket into OpenLDAP over the existing agent WSS
  channel; the SSO never parses LDAP (DESIGN.md §4).
- POST /api/v1/agent/secrets: node-scoped OpenBao secret fetch for
  agents, enforced to each agent's own secret/data/nodes/<id>/* prefix
  (DESIGN.md §5).
- iam_apply signed command: push node-scoped IAM config (sudo rules, SSH
  keys, access control, revocation) to an agent (DESIGN.md §6).
- Agent capability badges on the Directory Metrics tab, sourced from the
  agent's own discovery frame.
- Join key management: GET /api/agent/join-keys/:id/agents (which hosts
  enrolled through a key) plus a Manage join keys table in the Install
  Agent modal with Revoke/Delete actions, confirmed inline per-row rather
  than a blocking native confirm() or the shared app.messages.confirm()
  banner (which desyncs across concurrent rows -- see CHANGELOG).
- docs/agents.md: capability matrix updated for the three new
  capabilities, a full secrets-engine walkthrough with screenshots
  (bash + Node consuming a rendered secret, plus the direct-API
  alternative), and the join-key reuse/UI/audit questions answered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 17:03:41 -04:00

257 lines
9.4 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',
// The agent's enabled capabilities (from its local agent.yml). The agent
// is the authoritative source for what it will actually do.
capabilities: payload.capabilities || {}
};
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;