181 lines
5.0 KiB
JavaScript
181 lines
5.0 KiB
JavaScript
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
|
|
class AgentManager {
|
|
constructor() {
|
|
this.agents = new Map(); // token -> agentRecord
|
|
this.privateKeyPem = null;
|
|
this.publicKeyPem = null;
|
|
this.initKeyPair();
|
|
}
|
|
|
|
initKeyPair() {
|
|
try {
|
|
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
|
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
publicKeyEncoding: { type: 'spki', format: 'pem' }
|
|
});
|
|
this.privateKeyPem = privateKey;
|
|
this.publicKeyPem = publicKey;
|
|
} catch (err) {
|
|
console.error('[AgentManager] Failed to generate Ed25519 key pair:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 Ed25519 private key.
|
|
* Returns base64 encoded signature.
|
|
*/
|
|
signPayload(payload) {
|
|
if (!this.privateKeyPem) {
|
|
throw new Error('Ed25519 private key is not initialized');
|
|
}
|
|
const canonicalBytes = Buffer.from(this.canonicalize(payload), 'utf8');
|
|
const signature = crypto.sign(null, canonicalBytes, this.privateKeyPem);
|
|
return signature.toString('base64');
|
|
}
|
|
|
|
registerAgent(token, ws, remoteAddress) {
|
|
const existing = this.agents.get(token);
|
|
if (existing && existing.ws && existing.ws !== ws) {
|
|
try { existing.ws.close(4002, 'Superseded by new connection'); } catch (e) {}
|
|
}
|
|
|
|
const agentRecord = {
|
|
token,
|
|
ws,
|
|
ipAddress: remoteAddress,
|
|
hostname: 'unknown',
|
|
connectedAt: new Date().toISOString(),
|
|
lastSeen: new Date().toISOString(),
|
|
discovery: {},
|
|
telemetry: {},
|
|
pendingResponses: new Map()
|
|
};
|
|
|
|
this.agents.set(token, agentRecord);
|
|
return agentRecord;
|
|
}
|
|
|
|
unregisterAgent(token, ws) {
|
|
const record = this.agents.get(token);
|
|
if (record && record.ws === ws) {
|
|
this.agents.delete(token);
|
|
}
|
|
}
|
|
|
|
handleDiscovery(token, payload) {
|
|
const agent = this.agents.get(token);
|
|
if (!agent) return;
|
|
|
|
agent.lastSeen = new Date().toISOString();
|
|
agent.hostname = payload.hostname || agent.hostname;
|
|
agent.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'
|
|
};
|
|
}
|
|
|
|
handleTelemetry(token, payload) {
|
|
const agent = this.agents.get(token);
|
|
if (!agent) return;
|
|
|
|
agent.lastSeen = new Date().toISOString();
|
|
agent.telemetry = {
|
|
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()
|
|
};
|
|
}
|
|
|
|
handleHeartbeat(token, payload, ws) {
|
|
const agent = this.agents.get(token);
|
|
if (agent) {
|
|
agent.lastSeen = new Date().toISOString();
|
|
}
|
|
try {
|
|
ws.send(JSON.stringify({
|
|
type: 'heartbeat_ack',
|
|
payload: { timestamp: new Date().toISOString() }
|
|
}));
|
|
} catch (e) {}
|
|
}
|
|
|
|
handleResponse(token, payload) {
|
|
const agent = this.agents.get(token);
|
|
if (agent) {
|
|
agent.lastSeen = new Date().toISOString();
|
|
agent.lastResponse = {
|
|
status: payload.status || 'ok',
|
|
message: payload.message || '',
|
|
output: payload.output || '',
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
}
|
|
}
|
|
|
|
sendCommand(token, commandType, payload = {}, isHighRisk = false) {
|
|
const agent = this.agents.get(token);
|
|
if (!agent || !agent.ws || agent.ws.readyState !== 1) {
|
|
throw new Error(`Agent with token "${token}" is not connected`);
|
|
}
|
|
|
|
const finalPayload = { ...payload };
|
|
if (isHighRisk) {
|
|
finalPayload.signature = this.signPayload(finalPayload);
|
|
}
|
|
|
|
const message = {
|
|
type: commandType,
|
|
payload: finalPayload
|
|
};
|
|
|
|
agent.ws.send(JSON.stringify(message));
|
|
return message;
|
|
}
|
|
|
|
getConnectedAgents() {
|
|
const list = [];
|
|
const now = new Date();
|
|
for (const [token, agent] of this.agents.entries()) {
|
|
list.push({
|
|
token,
|
|
hostname: agent.hostname,
|
|
ipAddress: agent.ipAddress,
|
|
connectedAt: agent.connectedAt,
|
|
lastSeen: agent.lastSeen,
|
|
discovery: agent.discovery,
|
|
telemetry: agent.telemetry,
|
|
lastResponse: agent.lastResponse || null,
|
|
isOnline: (now - new Date(agent.lastSeen)) < 90000
|
|
});
|
|
}
|
|
return list;
|
|
}
|
|
}
|
|
|
|
module.exports = new AgentManager();
|