Files
jarvis/agent/jarvis-agent-windows.py
T

535 lines
20 KiB
Python

#!/usr/bin/env python3
"""
JARVIS Agent for Windows — system monitor that reports metrics to JARVIS HUD.
Install via PowerShell (as Admin): irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex
Config: C:\ProgramData\jarvis-agent\config.json
Logs: C:\ProgramData\jarvis-agent\jarvis-agent.log
"""
import json
import os
import platform
import socket
import subprocess
import sys
import time
import urllib.request
import urllib.error
import hashlib
from datetime import datetime
from pathlib import Path
INSTALL_DIR = Path(r"C:\ProgramData\jarvis-agent")
CONFIG_PATH = INSTALL_DIR / "config.json"
STATE_PATH = INSTALL_DIR / "state.json"
LOG_PATH = INSTALL_DIR / "jarvis-agent.log"
AGENT_VERSION = "3.0"
# ── Logging ────────────────────────────────────────────────────────────────────
def log(msg: str):
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[{ts}] {msg}"
print(line, flush=True)
try:
with open(LOG_PATH, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception:
pass
# ── Config ─────────────────────────────────────────────────────────────────────
def load_config() -> dict:
if not CONFIG_PATH.exists():
print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.")
sys.exit(1)
with open(CONFIG_PATH, encoding="utf-8") as f:
return json.load(f)
def load_state() -> dict:
if STATE_PATH.exists():
with open(STATE_PATH, encoding="utf-8") as f:
return json.load(f)
return {}
def save_state(state: dict):
INSTALL_DIR.mkdir(parents=True, exist_ok=True)
with open(STATE_PATH, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2)
# ── HTTP ───────────────────────────────────────────────────────────────────────
import ssl as _ssl
def _make_ssl_ctx(verify: bool):
if not verify:
ctx = _ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = _ssl.CERT_NONE
return ctx
return None
_host_header: str = ""
def api_post(url: str, payload: dict, headers: dict = {}, timeout: int = 15,
ssl_verify: bool = True) -> dict:
body = json.dumps(payload).encode()
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0")
if _host_header:
req.add_header("Host", _host_header)
for k, v in headers.items():
req.add_header(k, v)
try:
ctx = _make_ssl_ctx(ssl_verify)
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
return {"error": f"HTTP {e.code}: {e.read().decode()[:200]}"}
except Exception as e:
return {"error": str(e)}
def api_get(url: str, headers: dict = {}, timeout: int = 10,
ssl_verify: bool = True) -> dict:
req = urllib.request.Request(url)
req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0")
if _host_header:
req.add_header("Host", _host_header)
for k, v in headers.items():
req.add_header(k, v)
try:
ctx = _make_ssl_ctx(ssl_verify)
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
return json.loads(resp.read().decode())
except Exception as e:
return {"error": str(e)}
# ── PowerShell helper ──────────────────────────────────────────────────────────
def _ps(script: str, timeout: int = 8) -> str:
try:
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
capture_output=True, text=True, timeout=timeout
)
return r.stdout.strip()
except Exception:
return ""
# ── Metrics ────────────────────────────────────────────────────────────────────
def get_local_ip() -> str:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return "unknown"
def get_cpu_percent() -> float:
try:
out = _ps("(Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average")
return round(float(out), 1)
except Exception:
return 0.0
def get_memory() -> dict:
try:
out = _ps("$o=Get-CimInstance Win32_OperatingSystem; [PSCustomObject]@{total=$o.TotalVisibleMemorySize;free=$o.FreePhysicalMemory}|ConvertTo-Json")
d = json.loads(out)
total_kb = int(d.get("total", 0))
free_kb = int(d.get("free", 0))
used_kb = total_kb - free_kb
if total_kb == 0:
return {}
return {
"total_mb": round(total_kb / 1024, 1),
"used_mb": round(used_kb / 1024, 1),
"free_mb": round(free_kb / 1024, 1),
"percent": round(used_kb / total_kb * 100, 1),
}
except Exception:
return {}
def get_disk() -> list:
try:
out = _ps("Get-PSDrive -PSProvider FileSystem | Where-Object{$_.Used -ne $null} | Select-Object Name,@{n='used';e={[math]::Round($_.Used/1GB,2)}},@{n='free';e={[math]::Round($_.Free/1GB,2)}} | ConvertTo-Json")
if not out:
return []
items = json.loads(out)
if isinstance(items, dict):
items = [items]
disks = []
for d in items:
used = float(d.get("used", 0))
free = float(d.get("free", 0))
total = used + free
pct = round(used / total * 100, 1) if total else 0
disks.append({
"mount": d.get("Name", "?") + ":\\",
"size": f"{round(total, 1)}G",
"used": f"{used}G",
"avail": f"{free}G",
"percent": str(int(pct)),
})
return disks
except Exception:
return []
def get_uptime() -> dict:
try:
out = _ps("(Get-Date) - (gcim Win32_OperatingSystem).LastBootUpTime | Select-Object -ExpandProperty TotalSeconds")
secs = float(out)
days = int(secs // 86400)
hours = int((secs % 86400) // 3600)
minutes = int((secs % 3600) // 60)
return {"seconds": int(secs), "days": days, "hours": hours, "minutes": minutes,
"human": f"{days}d {hours}h {minutes}m"}
except Exception:
return {}
def get_services(cfg: dict) -> list:
watch = cfg.get("watch_services", ["WinDefend", "Spooler"])
statuses = []
for svc in watch:
try:
out = _ps(f"(Get-Service -Name '{svc}' -ErrorAction SilentlyContinue).Status")
status = "active" if out.lower() == "running" else (out.lower() or "unknown")
statuses.append({"service": svc, "status": status})
except Exception:
statuses.append({"service": svc, "status": "unknown"})
return statuses
def detect_capabilities(cfg: dict) -> list:
caps = ["metrics", "commands"]
import shutil
if Path(r"C:\Program Files\Docker\Docker\Docker Desktop.exe").exists():
caps.append("docker")
# Screenshot via PIL or PowerShell .NET (always available on Windows)
caps.append("screenshot")
caps.append("sysinfo")
return caps
def collect_metrics(cfg: dict) -> dict:
return {
"hostname": cfg.get("hostname", socket.gethostname()),
"cpu_percent": get_cpu_percent(),
"memory": get_memory(),
"disk": get_disk(),
"uptime": get_uptime(),
"load": [0, 0, 0],
"services": get_services(cfg),
"platform": "Windows",
"os_version": platform.version(),
"timestamp": datetime.utcnow().isoformat() + "Z",
}
# ── Registration ───────────────────────────────────────────────────────────────
def register(cfg: dict, state: dict) -> str:
hostname = cfg.get("hostname", socket.gethostname().lower())
agent_type = cfg.get("agent_type", "windows")
ip = get_local_ip()
capabilities = detect_capabilities(cfg)
agent_id = cfg.get("agent_id", f"{hostname}_windows")
ssl_verify = bool(cfg.get("ssl_verify", True))
log(f"Registering as '{agent_id}' ({agent_type}) from {ip}...")
result = api_post(
f"{cfg['jarvis_url']}/api/agent/register",
{"hostname": hostname, "agent_type": agent_type, "ip_address": ip,
"capabilities": capabilities, "agent_id": agent_id},
headers={"X-Registration-Key": cfg["registration_key"]},
ssl_verify=ssl_verify,
)
if "error" in result:
log(f"[ERROR] Registration failed: {result['error']}")
return ""
api_key = result.get("api_key", "")
if api_key:
state["api_key"] = api_key
state["agent_id"] = result.get("agent_id", agent_id)
save_state(state)
log(f"Registered. agent_id={state['agent_id']}")
return api_key
# ── Screenshot ─────────────────────────────────────────────────────────────────
def _take_screenshot(cmd_data: dict) -> dict:
import base64, tempfile
tmp = str(INSTALL_DIR / "screenshot_tmp.png")
method = "unknown"
# Try PIL/Pillow first
try:
from PIL import ImageGrab
img = ImageGrab.grab(all_screens=True)
img.save(tmp, "PNG")
method = "pil"
except ImportError:
pass
except Exception:
pass
# Fallback: PowerShell .NET screenshot (no extra packages needed)
if method == "unknown":
ps_script = (
"Add-Type -AssemblyName System.Windows.Forms,System.Drawing; "
"$s=[System.Windows.Forms.SystemInformation]::VirtualScreen; "
"$bmp=New-Object System.Drawing.Bitmap($s.Width,$s.Height); "
"$g=[System.Drawing.Graphics]::FromImage($bmp); "
"$g.CopyFromScreen($s.Location,[System.Drawing.Point]::Empty,$s.Size); "
f"$bmp.Save('{tmp}'); $g.Dispose(); $bmp.Dispose()"
)
try:
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
capture_output=True, timeout=15
)
if r.returncode == 0 and os.path.exists(tmp):
method = "powershell"
except Exception:
pass
if method == "unknown" or not os.path.exists(tmp):
return _sysinfo_snapshot()
try:
with open(tmp, "rb") as f:
raw = f.read()
b64 = base64.b64encode(raw).decode()
try:
os.unlink(tmp)
except Exception:
pass
return {
"success": True,
"method": method,
"image_b64": b64,
"image_mime": "image/png",
"file_size": len(raw),
"hostname": socket.gethostname(),
}
except Exception as e:
return {"success": False, "error": str(e), "method": method}
# ── Sysinfo ────────────────────────────────────────────────────────────────────
def _sysinfo_snapshot() -> dict:
data = {"success": True, "hostname": socket.gethostname(),
"snapshot_type": "text", "screenshot_available": False, "platform": "Windows"}
try:
mem = get_memory()
data["mem_total_mb"] = int(mem.get("total_mb", 0))
data["mem_used_mb"] = int(mem.get("used_mb", 0))
data["mem_avail_mb"] = int(mem.get("free_mb", 0))
except Exception:
pass
try:
data["cpu_percent"] = get_cpu_percent()
except Exception:
pass
try:
data["disk"] = get_disk()
except Exception:
pass
try:
ut = get_uptime()
data["uptime"] = ut.get("human", "")
except Exception:
pass
try:
out = _ps("Get-Process | Sort-Object CPU -Descending | Select-Object -First 8 Name,CPU,WorkingSet | ConvertTo-Json")
data["top_procs"] = json.loads(out) if out else []
except Exception:
pass
try:
out = _ps("netstat -an | findstr LISTENING | head -20")
data["listening_ports"] = out[:800]
except Exception:
pass
return data
# ── Self-update ────────────────────────────────────────────────────────────────
def self_update(cfg: dict) -> bool:
jarvis_url = cfg.get("jarvis_url", "").rstrip("/")
default_update_url = f"{jarvis_url}/agent/jarvis-agent-windows.py" if jarvis_url else ""
update_url = cfg.get("update_url", default_update_url)
if not update_url:
return False
script_path = os.path.abspath(__file__)
ssl_verify = bool(cfg.get("ssl_verify", True))
try:
# Download expected hash
hash_url = update_url + ".sha256"
req_hash = urllib.request.Request(hash_url)
req_hash.add_header("User-Agent", "JARVIS-Agent-Windows/3.0")
if _host_header:
req_hash.add_header("Host", _host_header)
expected_hash = None
try:
ctx = _make_ssl_ctx(ssl_verify)
with urllib.request.urlopen(req_hash, timeout=10, context=ctx) as resp:
expected_hash = resp.read().decode().strip().split()[0]
except Exception:
pass
# Download new script
req = urllib.request.Request(update_url)
req.add_header("User-Agent", "JARVIS-Agent-Windows/3.0")
if _host_header:
req.add_header("Host", _host_header)
ctx = _make_ssl_ctx(ssl_verify)
with urllib.request.urlopen(req, timeout=30, context=ctx) as resp:
new_content = resp.read()
# Verify hash
if expected_hash:
actual_hash = hashlib.sha256(new_content).hexdigest()
if actual_hash != expected_hash:
log(f"Update hash mismatch (expected {expected_hash[:16]}… got {actual_hash[:16]}…) — aborting")
return False
with open(script_path, "rb") as f:
current = f.read()
if new_content != current:
log(f"Update verified — replacing {script_path} and restarting...")
with open(script_path, "wb") as f:
f.write(new_content)
os.execv(sys.executable, [sys.executable] + sys.argv)
return True
return False
except Exception as e:
log(f"Self-update check failed: {e}")
return False
# ── Command execution ──────────────────────────────────────────────────────────
def execute_command(cmd: dict, cfg: dict) -> dict:
cmd_type = cmd.get("command_type", "")
cmd_data = cmd.get("command_data", {})
try:
if cmd_type == "ping":
host = cmd_data.get("host", "8.8.8.8")
r = subprocess.run(["ping", "-n", "3", host], capture_output=True, text=True, timeout=15)
return {"success": r.returncode == 0, "output": r.stdout}
elif cmd_type == "update":
log("[CMD] Self-update requested")
updated = self_update(cfg)
return {"success": True, "updated": updated}
elif cmd_type == "shell":
# Guard reads LOCAL config — never trust server-supplied flags
_cfg = load_config()
if not _cfg.get("allow_shell_commands", False):
return {"success": False, "error": "Shell commands not enabled in agent config"}
cmd_str = cmd_data.get("command", "")
r = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd_str],
capture_output=True, text=True, timeout=30)
return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]}
elif cmd_type == "restart_service":
svc = cmd_data.get("service", "")
if not svc:
return {"success": False, "error": "No service specified"}
out = _ps(f"Restart-Service -Name '{svc}' -Force -ErrorAction Stop; 'ok'")
return {"success": "ok" in out.lower(), "output": out}
elif cmd_type == "get_logs":
svc = cmd_data.get("service", "")
lines = min(int(cmd_data.get("lines", 50)), 200)
if not svc:
return {"success": False, "error": "No service specified"}
out = _ps(f"Get-EventLog -LogName Application -Source '{svc}' -Newest {lines} -ErrorAction SilentlyContinue | Format-List TimeGenerated,EntryType,Message | Out-String")
return {"success": True, "output": out[:3000]}
elif cmd_type == "screenshot":
return _take_screenshot(cmd_data)
elif cmd_type == "sysinfo":
return _sysinfo_snapshot()
else:
return {"success": False, "error": f"Unknown command type: {cmd_type}"}
except subprocess.TimeoutExpired:
return {"success": False, "error": "Command timed out"}
except Exception as e:
return {"success": False, "error": str(e)}
# ── Main loop ──────────────────────────────────────────────────────────────────
def main():
global _host_header
cfg = load_config()
state = load_state()
jarvis_url = cfg["jarvis_url"].rstrip("/")
ssl_verify = bool(cfg.get("ssl_verify", True))
_host_header = cfg.get("host_header", "")
poll_interval = int(cfg.get("poll_interval", 30))
heartbeat_every = int(cfg.get("heartbeat_every", 10))
update_interval = int(cfg.get("update_check_hours", 24)) * 3600
# Always re-register on startup to refresh capabilities, version, IP
api_key = state.get("api_key", "")
registered_key = register(cfg, state)
if registered_key:
api_key = registered_key
elif not api_key:
while not api_key:
api_key = register(cfg, state)
if not api_key:
log("[ERROR] Could not register with JARVIS. Retrying in 60s...")
time.sleep(60)
headers = {"X-Agent-Key": api_key}
last_metrics = 0
last_update_chk = 0
log(f"Agent v{AGENT_VERSION} (Windows) running. Polling {jarvis_url} every {heartbeat_every}s.")
while True:
now = time.time()
try:
hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {}, headers, ssl_verify=ssl_verify)
if "error" in hb:
log(f"[WARN] Heartbeat failed: {hb['error']}")
else:
for cmd in hb.get("commands", []):
log(f"[CMD] Executing: {cmd['command_type']}")
result = execute_command(cmd, cfg)
api_post(f"{jarvis_url}/api/agent/command_result",
{"command_id": cmd["id"], "success": result.get("success", False), "result": result},
headers, ssl_verify=ssl_verify)
# Self-update check
if now - last_update_chk >= update_interval:
last_update_chk = now
self_update(cfg)
# Push metrics
if now - last_metrics >= poll_interval:
metrics = collect_metrics(cfg)
api_post(f"{jarvis_url}/api/agent/metrics",
{"type": "system", "data": metrics}, headers, ssl_verify=ssl_verify)
last_metrics = now
except Exception as e:
log(f"[ERROR] Loop error: {e}")
time.sleep(heartbeat_every)
if __name__ == "__main__":
main()