mirror of
https://github.com/myronblair/jarvis
synced 2026-07-28 08:43:00 -05:00
Adopt live production state as git baseline
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
# JARVIS Agent Installer — macOS
|
||||
# Usage: bash install-mac.sh --jarvis-url https://jarvis.orbishosting.com --key YOUR_KEY
|
||||
# Or one-liner: bash <(curl -sSL https://jarvis.orbishosting.com/agent/install-mac.sh) --key YOUR_KEY
|
||||
|
||||
set -e
|
||||
|
||||
JARVIS_URL="https://jarvis.orbishosting.com"
|
||||
REG_KEY=""
|
||||
INSTALL_DIR="$HOME/.jarvis-agent"
|
||||
PLIST_PATH="$HOME/Library/LaunchAgents/com.jarvis.agent.plist"
|
||||
SERVICE_LABEL="com.jarvis.agent"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--jarvis-url) JARVIS_URL="$2"; shift 2 ;;
|
||||
--key) REG_KEY="$2"; shift 2 ;;
|
||||
*) echo "Unknown arg: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$REG_KEY" ]]; then
|
||||
read -rp "Registration key: " REG_KEY
|
||||
fi
|
||||
|
||||
JARVIS_URL="${JARVIS_URL%/}"
|
||||
|
||||
echo ""
|
||||
echo " ===================================="
|
||||
echo " JARVIS Agent Installer v3.0 "
|
||||
echo " ===================================="
|
||||
echo ""
|
||||
|
||||
# Check for Python3
|
||||
PYTHON3=$(command -v python3 2>/dev/null || "")
|
||||
if [[ -z "$PYTHON3" ]]; then
|
||||
echo "Python 3 is required. Install it with:"
|
||||
echo " brew install python3"
|
||||
echo " or download from https://www.python.org/downloads/"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using Python: $PYTHON3 ($($PYTHON3 --version 2>&1))"
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# Download macOS-native agent script
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || echo "")"
|
||||
if [[ -f "$SCRIPT_DIR/jarvis-agent-mac.py" ]]; then
|
||||
cp "$SCRIPT_DIR/jarvis-agent-mac.py" "$INSTALL_DIR/jarvis-agent.py"
|
||||
echo "Copied agent from local directory."
|
||||
else
|
||||
echo "Downloading macOS agent..."
|
||||
curl -sSL "$JARVIS_URL/agent/jarvis-agent-mac.py" -o "$INSTALL_DIR/jarvis-agent.py"
|
||||
echo "Downloaded."
|
||||
fi
|
||||
chmod +x "$INSTALL_DIR/jarvis-agent.py"
|
||||
|
||||
HOSTNAME=$(hostname -f 2>/dev/null || hostname)
|
||||
AGENT_ID="${HOSTNAME}_mac"
|
||||
|
||||
# Write config (preserve existing)
|
||||
if [[ ! -f "$INSTALL_DIR/config.json" ]]; then
|
||||
cat > "$INSTALL_DIR/config.json" << JSONEOF
|
||||
{
|
||||
"jarvis_url": "$JARVIS_URL",
|
||||
"registration_key": "$REG_KEY",
|
||||
"hostname": "$HOSTNAME",
|
||||
"agent_id": "$AGENT_ID",
|
||||
"agent_type": "macos",
|
||||
"poll_interval": 30,
|
||||
"heartbeat_every": 10,
|
||||
"update_check_hours": 24,
|
||||
"watch_services": [],
|
||||
"allow_shell_commands": false
|
||||
}
|
||||
JSONEOF
|
||||
chmod 600 "$INSTALL_DIR/config.json"
|
||||
echo "Config written to $INSTALL_DIR/config.json"
|
||||
else
|
||||
echo "Config already exists — preserving $INSTALL_DIR/config.json"
|
||||
fi
|
||||
|
||||
# Write launchd plist
|
||||
mkdir -p "$HOME/Library/LaunchAgents"
|
||||
cat > "$PLIST_PATH" << PLISTEOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$SERVICE_LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$PYTHON3</string>
|
||||
<string>$INSTALL_DIR/jarvis-agent.py</string>
|
||||
</array>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>JARVIS_CONFIG</key>
|
||||
<string>$INSTALL_DIR/config.json</string>
|
||||
<key>JARVIS_STATE</key>
|
||||
<string>$INSTALL_DIR/state.json</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>$INSTALL_DIR/jarvis-agent.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>$INSTALL_DIR/jarvis-agent.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLISTEOF
|
||||
|
||||
# Load/reload the service
|
||||
launchctl unload "$PLIST_PATH" 2>/dev/null || true
|
||||
launchctl load "$PLIST_PATH"
|
||||
|
||||
sleep 2
|
||||
if launchctl list 2>/dev/null | grep -q "$SERVICE_LABEL"; then
|
||||
echo ""
|
||||
echo " JARVIS Agent installed and running."
|
||||
echo " Machine : $HOSTNAME ($AGENT_ID)"
|
||||
echo " JARVIS : $JARVIS_URL"
|
||||
echo " Logs : tail -f $INSTALL_DIR/jarvis-agent.log"
|
||||
echo " Config : $INSTALL_DIR/config.json"
|
||||
echo " Stop : launchctl unload $PLIST_PATH"
|
||||
echo " Update : curl -sSL $JARVIS_URL/agent/install-mac.sh | bash -s -- --key $REG_KEY"
|
||||
else
|
||||
echo ""
|
||||
echo " Agent installed but not detected as running. Check logs:"
|
||||
echo " tail -f $INSTALL_DIR/jarvis-agent.log"
|
||||
fi
|
||||
echo ""
|
||||
@@ -0,0 +1,151 @@
|
||||
#Requires -RunAsAdministrator
|
||||
<#
|
||||
.SYNOPSIS
|
||||
JARVIS Agent installer for Windows.
|
||||
|
||||
.DESCRIPTION
|
||||
Installs JARVIS Agent as a Windows Service that auto-starts at boot.
|
||||
Requires: PowerShell 5.1+, internet access, and Administrator rights.
|
||||
|
||||
.EXAMPLE
|
||||
# Interactive install (prompts for registration key):
|
||||
irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex
|
||||
|
||||
# Silent install with key:
|
||||
$env:JARVIS_REG_KEY='your_key_here'; irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$JARVIS_URL = 'https://jarvis.orbishosting.com'
|
||||
$INSTALL_DIR = 'C:\ProgramData\jarvis-agent'
|
||||
$SERVICE_NAME = 'JARVISAgent'
|
||||
$AGENT_SCRIPT = "$INSTALL_DIR\jarvis-agent-windows.py"
|
||||
$CONFIG_FILE = "$INSTALL_DIR\config.json"
|
||||
|
||||
function Write-Step { param($msg) Write-Host "`n[JARVIS] $msg" -ForegroundColor Cyan }
|
||||
function Write-OK { param($msg) Write-Host " OK: $msg" -ForegroundColor Green }
|
||||
function Write-Fail { param($msg) Write-Host " ERROR: $msg" -ForegroundColor Red; exit 1 }
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Yellow
|
||||
Write-Host " JARVIS Agent Installer for Windows" -ForegroundColor Yellow
|
||||
Write-Host "========================================`n" -ForegroundColor Yellow
|
||||
|
||||
# ── Stop existing service if running ─────────────────────────────────────────
|
||||
$existing = Get-Service -Name $SERVICE_NAME -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
Write-Step "Stopping existing JARVIS Agent service..."
|
||||
if ($existing.Status -eq 'Running') {
|
||||
Stop-Service -Name $SERVICE_NAME -Force
|
||||
Start-Sleep 2
|
||||
}
|
||||
try {
|
||||
& python "$INSTALL_DIR\jarvis-agent-windows.py" remove 2>$null
|
||||
} catch {}
|
||||
Write-OK "Existing service removed."
|
||||
}
|
||||
|
||||
# ── Check / install Python ────────────────────────────────────────────────────
|
||||
Write-Step "Checking Python..."
|
||||
$py = Get-Command python -ErrorAction SilentlyContinue
|
||||
if (-not $py) {
|
||||
Write-Host " Python not found. Installing via winget..." -ForegroundColor Yellow
|
||||
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
|
||||
Write-Fail "winget not available. Please install Python 3.11+ from https://python.org and re-run."
|
||||
}
|
||||
winget install -e --id Python.Python.3.11 --silent --accept-package-agreements --accept-source-agreements
|
||||
$env:PATH = [System.Environment]::GetEnvironmentVariable("PATH","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("PATH","User")
|
||||
$py = Get-Command python -ErrorAction SilentlyContinue
|
||||
if (-not $py) { Write-Fail "Python install failed. Please install manually from https://python.org" }
|
||||
}
|
||||
$pyVersion = & python --version 2>&1
|
||||
Write-OK $pyVersion
|
||||
|
||||
# ── Install pywin32 ───────────────────────────────────────────────────────────
|
||||
Write-Step "Checking pywin32..."
|
||||
$checkWin32 = & python -c "import win32service; print('ok')" 2>&1
|
||||
if ($checkWin32 -ne 'ok') {
|
||||
Write-Host " Installing pywin32..." -ForegroundColor Yellow
|
||||
& python -m pip install --quiet pywin32
|
||||
& python -m pywin32_postinstall -install 2>$null
|
||||
Write-OK "pywin32 installed."
|
||||
} else {
|
||||
Write-OK "pywin32 already installed."
|
||||
}
|
||||
|
||||
# ── Create install dir ────────────────────────────────────────────────────────
|
||||
Write-Step "Creating install directory..."
|
||||
New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null
|
||||
Write-OK $INSTALL_DIR
|
||||
|
||||
# ── Download agent script ─────────────────────────────────────────────────────
|
||||
Write-Step "Downloading JARVIS agent..."
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$JARVIS_URL/agent/jarvis-agent-windows.py" -OutFile $AGENT_SCRIPT -UseBasicParsing
|
||||
Write-OK "Agent downloaded to $AGENT_SCRIPT"
|
||||
} catch {
|
||||
Write-Fail "Failed to download agent: $_"
|
||||
}
|
||||
|
||||
# ── Get registration key ──────────────────────────────────────────────────────
|
||||
$regKey = $env:JARVIS_REG_KEY
|
||||
if (-not $regKey -and (Test-Path $CONFIG_FILE)) {
|
||||
$existingCfg = Get-Content $CONFIG_FILE | ConvertFrom-Json
|
||||
$regKey = $existingCfg.registration_key
|
||||
if ($regKey) { Write-OK "Using existing registration key from config." }
|
||||
}
|
||||
if (-not $regKey) {
|
||||
$regKey = Read-Host "`n Enter JARVIS registration key"
|
||||
if (-not $regKey) { Write-Fail "Registration key required." }
|
||||
}
|
||||
|
||||
# ── Get hostname ──────────────────────────────────────────────────────────────
|
||||
$hostname = $env:COMPUTERNAME
|
||||
$customHostname = $env:JARVIS_HOSTNAME
|
||||
if ($customHostname) { $hostname = $customHostname }
|
||||
|
||||
# ── Write config ──────────────────────────────────────────────────────────────
|
||||
Write-Step "Writing config..."
|
||||
$cfg = @{
|
||||
jarvis_url = $JARVIS_URL
|
||||
registration_key = $regKey
|
||||
hostname = $hostname
|
||||
agent_type = 'windows'
|
||||
ssl_verify = $true
|
||||
poll_interval = 30
|
||||
heartbeat_every = 10
|
||||
update_check_hours = 24
|
||||
watch_services = @('WinDefend', 'Spooler', 'wuauserv')
|
||||
} | ConvertTo-Json -Depth 5
|
||||
$cfg | Out-File -FilePath $CONFIG_FILE -Encoding utf8
|
||||
Write-OK "Config written to $CONFIG_FILE"
|
||||
|
||||
# ── Install Windows Service ───────────────────────────────────────────────────
|
||||
Write-Step "Installing Windows service..."
|
||||
$pyPath = (Get-Command python).Source
|
||||
& $pyPath "$AGENT_SCRIPT" --startup auto install
|
||||
if ($LASTEXITCODE -ne 0) { Write-Fail "Service install failed." }
|
||||
Write-OK "Service '$SERVICE_NAME' installed."
|
||||
|
||||
# ── Start service ─────────────────────────────────────────────────────────────
|
||||
Write-Step "Starting service..."
|
||||
Start-Service -Name $SERVICE_NAME
|
||||
Start-Sleep 3
|
||||
$svc = Get-Service -Name $SERVICE_NAME
|
||||
if ($svc.Status -ne 'Running') { Write-Fail "Service failed to start. Check C:\ProgramData\jarvis-agent\jarvis-agent.log" }
|
||||
Write-OK "Service is running."
|
||||
|
||||
# ── Test connectivity ─────────────────────────────────────────────────────────
|
||||
Write-Step "Testing JARVIS connection..."
|
||||
try {
|
||||
$ping = Invoke-RestMethod -Uri "$JARVIS_URL/api/ping" -TimeoutSec 10
|
||||
Write-OK "JARVIS is online: $($ping.codename)"
|
||||
} catch {
|
||||
Write-Host " WARNING: Could not reach JARVIS at $JARVIS_URL - check connectivity." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Green
|
||||
Write-Host " JARVIS Agent installed successfully!" -ForegroundColor Green
|
||||
Write-Host " Hostname: $hostname" -ForegroundColor Green
|
||||
Write-Host " Service: $SERVICE_NAME (auto-start at boot)" -ForegroundColor Green
|
||||
Write-Host " Logs: C:\ProgramData\jarvis-agent\jarvis-agent.log" -ForegroundColor Green
|
||||
Write-Host "========================================`n" -ForegroundColor Green
|
||||
@@ -0,0 +1,576 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JARVIS Agent for macOS — system monitor that reports metrics to JARVIS HUD.
|
||||
Install: bash <(curl -sSL https://jarvis.orbishosting.com/agent/install-mac.sh) --key YOUR_KEY
|
||||
Config: ~/.jarvis-agent/config.json (or $JARVIS_CONFIG)
|
||||
Logs: ~/.jarvis-agent/jarvis-agent.log (or journalctl via launchd)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
AGENT_VERSION = "3.0"
|
||||
|
||||
# Config/state paths — env vars let the launchd plist override them
|
||||
_default_dir = Path.home() / ".jarvis-agent"
|
||||
CONFIG_PATH = Path(os.environ.get("JARVIS_CONFIG", str(_default_dir / "config.json")))
|
||||
STATE_PATH = Path(os.environ.get("JARVIS_STATE", str(_default_dir / "state.json")))
|
||||
LOG_PATH = _default_dir / "jarvis-agent.log"
|
||||
|
||||
# ── Logging ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def log(msg: str):
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
line = f"[{ts}] {msg}"
|
||||
print(line, flush=True)
|
||||
try:
|
||||
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(LOG_PATH, "a") 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) as f:
|
||||
return json.load(f)
|
||||
|
||||
def load_state() -> dict:
|
||||
if STATE_PATH.exists():
|
||||
with open(STATE_PATH) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_state(state: dict):
|
||||
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(STATE_PATH, "w") 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-macOS/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-macOS/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)}
|
||||
|
||||
# ── 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"
|
||||
|
||||
_last_cpu_times = None
|
||||
|
||||
def get_cpu_percent() -> float:
|
||||
"""Delta-based CPU % using top (two samples)."""
|
||||
global _last_cpu_times
|
||||
try:
|
||||
# top -l 2 gives two snapshots; second line has the real delta
|
||||
r = subprocess.run(
|
||||
["top", "-l", "2", "-s", "0", "-n", "0"],
|
||||
capture_output=True, text=True, timeout=12
|
||||
)
|
||||
idle = None
|
||||
for line in r.stdout.splitlines():
|
||||
if "CPU usage:" in line:
|
||||
m = re.search(r"([\d.]+)%\s+idle", line)
|
||||
if m:
|
||||
idle = float(m.group(1))
|
||||
if idle is not None:
|
||||
return round(100.0 - idle, 1)
|
||||
except Exception:
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
def get_memory() -> dict:
|
||||
try:
|
||||
# Total physical memory
|
||||
r = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=3)
|
||||
total_bytes = int(r.stdout.strip())
|
||||
|
||||
# vm_stat for page counts; default page size on Apple Silicon and Intel = 4096
|
||||
page_size = 4096
|
||||
try:
|
||||
ps_r = subprocess.run(["sysctl", "-n", "hw.pagesize"], capture_output=True, text=True, timeout=3)
|
||||
page_size = int(ps_r.stdout.strip())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
vm_r = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=5)
|
||||
pages = {}
|
||||
for line in vm_r.stdout.splitlines():
|
||||
m = re.match(r"Pages\s+(.+?):\s+([\d]+)", line)
|
||||
if m:
|
||||
pages[m.group(1).strip().lower()] = int(m.group(2))
|
||||
|
||||
free_pages = pages.get("free", 0) + pages.get("speculative", 0)
|
||||
# "available" = free + inactive (can be reclaimed)
|
||||
avail_pages = free_pages + pages.get("inactive", 0)
|
||||
used_pages = (total_bytes // page_size) - avail_pages
|
||||
|
||||
total_mb = round(total_bytes / (1024 * 1024), 1)
|
||||
used_mb = round(used_pages * page_size / (1024 * 1024), 1)
|
||||
avail_mb = round(avail_pages * page_size / (1024 * 1024), 1)
|
||||
used_mb = max(0, min(used_mb, total_mb))
|
||||
|
||||
return {
|
||||
"total_mb": total_mb,
|
||||
"used_mb": used_mb,
|
||||
"free_mb": avail_mb,
|
||||
"percent": round(used_mb / total_mb * 100, 1) if total_mb else 0,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_disk() -> list:
|
||||
disks = []
|
||||
try:
|
||||
r = subprocess.run(["df", "-H", "-l"], capture_output=True, text=True, timeout=5)
|
||||
for line in r.stdout.splitlines()[1:]:
|
||||
parts = line.split()
|
||||
if len(parts) >= 6:
|
||||
mount = parts[5]
|
||||
if not any(mount.startswith(x) for x in ["/dev", "/private/var/vm", "/System/Volumes/VM"]):
|
||||
disks.append({
|
||||
"mount": mount,
|
||||
"size": parts[1],
|
||||
"used": parts[2],
|
||||
"avail": parts[3],
|
||||
"percent": parts[4].rstrip("%"),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return disks
|
||||
|
||||
def get_uptime() -> dict:
|
||||
try:
|
||||
r = subprocess.run(["sysctl", "-n", "kern.boottime"], capture_output=True, text=True, timeout=3)
|
||||
m = re.search(r"sec\s*=\s*(\d+)", r.stdout)
|
||||
if m:
|
||||
boot_ts = int(m.group(1))
|
||||
secs = time.time() - boot_ts
|
||||
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:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def get_load() -> list:
|
||||
try:
|
||||
r = subprocess.run(["sysctl", "-n", "vm.loadavg"], capture_output=True, text=True, timeout=3)
|
||||
# format: "{ 0.12 0.34 0.56 }"
|
||||
nums = re.findall(r"[\d.]+", r.stdout)
|
||||
if len(nums) >= 3:
|
||||
return [float(nums[0]), float(nums[1]), float(nums[2])]
|
||||
except Exception:
|
||||
pass
|
||||
return [0, 0, 0]
|
||||
|
||||
def get_services(cfg: dict) -> list:
|
||||
"""Check macOS LaunchDaemon/LaunchAgent services via launchctl."""
|
||||
watch = cfg.get("watch_services", [])
|
||||
if not watch:
|
||||
return []
|
||||
statuses = []
|
||||
try:
|
||||
r = subprocess.run(["launchctl", "list"], capture_output=True, text=True, timeout=5)
|
||||
running = set()
|
||||
for line in r.stdout.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
running.add(parts[2])
|
||||
except Exception:
|
||||
running = set()
|
||||
for svc in watch:
|
||||
status = "active" if any(svc in s for s in running) else "inactive"
|
||||
statuses.append({"service": svc, "status": status})
|
||||
return statuses
|
||||
|
||||
def detect_capabilities(cfg: dict) -> list:
|
||||
import shutil
|
||||
caps = ["metrics", "commands"]
|
||||
if shutil.which("docker"):
|
||||
caps.append("docker")
|
||||
if shutil.which("ollama"):
|
||||
caps.append("ollama")
|
||||
# screencapture is always available on macOS
|
||||
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": get_load(),
|
||||
"services": get_services(cfg),
|
||||
"platform": "macOS",
|
||||
"os_version": platform.mac_ver()[0],
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
|
||||
# ── Registration ───────────────────────────────────────────────────────────────
|
||||
|
||||
def register(cfg: dict, state: dict) -> str:
|
||||
hostname = cfg.get("hostname", socket.gethostname())
|
||||
agent_type = cfg.get("agent_type", "macos")
|
||||
ip = get_local_ip()
|
||||
capabilities = detect_capabilities(cfg)
|
||||
agent_id = cfg.get("agent_id", f"{hostname}_mac")
|
||||
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, "version": AGENT_VERSION, "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, shutil
|
||||
tmp = tempfile.mktemp(suffix=".png")
|
||||
method = "unknown"
|
||||
|
||||
# screencapture -x = no sound, always available on macOS
|
||||
try:
|
||||
r = subprocess.run(["screencapture", "-x", "-t", "png", tmp],
|
||||
capture_output=True, timeout=15)
|
||||
if r.returncode == 0 and os.path.exists(tmp):
|
||||
method = "screencapture"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: PIL
|
||||
if method == "unknown":
|
||||
try:
|
||||
from PIL import ImageGrab
|
||||
img = ImageGrab.grab()
|
||||
img.save(tmp, "PNG")
|
||||
method = "pil"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if method == "unknown" or not os.path.exists(tmp):
|
||||
snap = _sysinfo_snapshot()
|
||||
snap["screenshot_available"] = False
|
||||
snap["method"] = "text_only"
|
||||
return snap
|
||||
|
||||
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": "macOS"}
|
||||
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:
|
||||
load = get_load()
|
||||
data["load_1m"], data["load_5m"], data["load_15m"] = load[0], load[1], load[2]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
r = subprocess.run(["df", "-H", "/"], capture_output=True, text=True, timeout=5)
|
||||
data["disk"] = r.stdout.splitlines()[1] if r.stdout else ""
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
r = subprocess.run(["ps", "aux", "-r"], capture_output=True, text=True, timeout=5)
|
||||
data["top_procs"] = r.stdout.splitlines()[1:8]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
r = subprocess.run(["netstat", "-an", "-p", "tcp"], capture_output=True, text=True, timeout=5)
|
||||
listening = [l for l in r.stdout.splitlines() if "LISTEN" in l]
|
||||
data["listening_ports"] = "\n".join(listening[:20])
|
||||
except Exception:
|
||||
pass
|
||||
return data
|
||||
|
||||
# ── Command execution ──────────────────────────────────────────────────────────
|
||||
|
||||
def execute_command(cmd: 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", "-c", "3", "-W", "2000", host],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
return {"success": r.returncode == 0, "output": r.stdout}
|
||||
|
||||
elif cmd_type == "update":
|
||||
_cfg = load_config()
|
||||
updated = self_update(_cfg)
|
||||
return {"success": True, "updated": updated}
|
||||
|
||||
elif cmd_type == "shell":
|
||||
_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(cmd_str, shell=True, 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 or "/" in svc:
|
||||
return {"success": False, "error": "Invalid service name"}
|
||||
r = subprocess.run(["launchctl", "kickstart", "-k", f"system/{svc}"],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
if r.returncode != 0:
|
||||
r = subprocess.run(["brew", "services", "restart", svc],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
return {"success": r.returncode == 0, "stdout": r.stdout, "stderr": r.stderr}
|
||||
|
||||
elif cmd_type == "get_logs":
|
||||
svc = cmd_data.get("service", "")
|
||||
lines = min(int(cmd_data.get("lines", 50)), 200)
|
||||
r = subprocess.run(["log", "show", "--predicate", f'process == "{svc}"',
|
||||
"--last", "1h", "--style", "compact"],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
output = "\n".join(r.stdout.splitlines()[-lines:])
|
||||
return {"success": True, "output": output}
|
||||
|
||||
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)}
|
||||
|
||||
# ── Self-update ────────────────────────────────────────────────────────────────
|
||||
|
||||
def self_update(cfg: dict) -> bool:
|
||||
jarvis_url = cfg.get("jarvis_url", "").rstrip("/")
|
||||
default_update_url = f"{jarvis_url}/agent/jarvis-agent-mac.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-macOS/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-macOS/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()
|
||||
|
||||
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
|
||||
|
||||
# ── 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} (macOS) running. Polling {jarvis_url} every {heartbeat_every}s.")
|
||||
|
||||
while True:
|
||||
tick_start = time.time()
|
||||
now = tick_start
|
||||
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)
|
||||
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()
|
||||
@@ -0,0 +1,607 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JARVIS Agent for Windows — system monitor that reports metrics to JARVIS HUD.
|
||||
Runs as a Windows Service (Win 8.1+) via pywin32.
|
||||
|
||||
Install (run PowerShell as Admin):
|
||||
irm https://jarvis.orbishosting.com/agent/install-windows.ps1 | iex
|
||||
|
||||
Service management:
|
||||
python jarvis-agent-windows.py --startup auto install # register service
|
||||
python jarvis-agent-windows.py start # start
|
||||
python jarvis-agent-windows.py stop # stop
|
||||
python jarvis-agent-windows.py remove # uninstall
|
||||
python jarvis-agent-windows.py debug # run in console (for testing)
|
||||
|
||||
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 threading
|
||||
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.1"
|
||||
|
||||
# Set by the service wrapper so self_update knows to stop instead of exec
|
||||
_is_service = False
|
||||
_stop_event = threading.Event()
|
||||
|
||||
# ── 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, "version": AGENT_VERSION, "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)
|
||||
if _is_service:
|
||||
# Signal the main loop to exit; SCM failure-recovery will restart us
|
||||
log("Running as service — stopping for SCM-managed restart after update.")
|
||||
_stop_event.set()
|
||||
else:
|
||||
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...")
|
||||
if _stop_event.wait(60):
|
||||
return
|
||||
|
||||
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 not _stop_event.is_set():
|
||||
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}")
|
||||
|
||||
if _stop_event.wait(heartbeat_every):
|
||||
break # service stop was requested
|
||||
|
||||
log("Agent stopped.")
|
||||
|
||||
|
||||
# ── Windows Service wrapper ────────────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
import win32serviceutil
|
||||
import win32service
|
||||
import win32event
|
||||
import servicemanager
|
||||
_HAS_WIN32 = True
|
||||
except ImportError:
|
||||
_HAS_WIN32 = False
|
||||
|
||||
if _HAS_WIN32:
|
||||
class JarvisAgentService(win32serviceutil.ServiceFramework):
|
||||
_svc_name_ = "JARVISAgent"
|
||||
_svc_display_name_ = "JARVIS AI Agent"
|
||||
_svc_description_ = "JARVIS system monitoring and AI agent — reports metrics to JARVIS HUD"
|
||||
|
||||
def __init__(self, args):
|
||||
win32serviceutil.ServiceFramework.__init__(self, args)
|
||||
self._svc_stop_event = win32event.CreateEvent(None, 0, 0, None)
|
||||
|
||||
def SvcStop(self):
|
||||
self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
|
||||
_stop_event.set()
|
||||
win32event.SetEvent(self._svc_stop_event)
|
||||
|
||||
def SvcDoRun(self):
|
||||
global _is_service
|
||||
_is_service = True
|
||||
servicemanager.LogMsg(
|
||||
servicemanager.EVENTLOG_INFORMATION_TYPE,
|
||||
servicemanager.PYS_SERVICE_STARTED,
|
||||
(self._svc_name_, ""),
|
||||
)
|
||||
main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if _HAS_WIN32:
|
||||
if len(sys.argv) == 1:
|
||||
# No args — SCM is starting us as a service
|
||||
servicemanager.Initialize()
|
||||
servicemanager.PrepareToHostSingle(JarvisAgentService)
|
||||
servicemanager.StartServiceCtrlDispatcher()
|
||||
else:
|
||||
# install / start / stop / remove / debug
|
||||
win32serviceutil.HandleCommandLine(JarvisAgentService)
|
||||
else:
|
||||
# pywin32 not available — run directly (useful for testing)
|
||||
main()
|
||||
Executable
+505
@@ -0,0 +1,505 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JARVIS Agent — lightweight system monitor for Linux machines.
|
||||
Registers with JARVIS, reports metrics, and executes commands.
|
||||
|
||||
Install: sudo bash /opt/jarvis-agent/install.sh
|
||||
Config: /etc/jarvis-agent/config.json
|
||||
Logs: journalctl -u jarvis-agent -f
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_PATH = "/etc/jarvis-agent/config.json"
|
||||
STATE_PATH = "/var/lib/jarvis-agent/state.json"
|
||||
AGENT_VERSION = "3.1"
|
||||
|
||||
# ── Config helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def load_config() -> dict:
|
||||
legacy_path = "/opt/jarvis-agent/config.json"
|
||||
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
if os.path.exists(legacy_path):
|
||||
print(f"[JARVIS] Config found at legacy path {legacy_path} - migrating...", flush=True)
|
||||
Path(CONFIG_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(legacy_path) as f:
|
||||
cfg = json.load(f)
|
||||
else:
|
||||
print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.", flush=True)
|
||||
sys.exit(1)
|
||||
else:
|
||||
with open(CONFIG_PATH) as f:
|
||||
cfg = json.load(f)
|
||||
|
||||
# Migrate old key names so the agent self-heals instead of crash-looping
|
||||
import re as _re
|
||||
changed = False
|
||||
if "server_url" in cfg and "jarvis_url" not in cfg:
|
||||
cfg["jarvis_url"] = cfg.pop("server_url")
|
||||
print("[JARVIS] Config migrated: server_url -> jarvis_url", flush=True)
|
||||
changed = True
|
||||
if "api_key" in cfg and "registration_key" not in cfg:
|
||||
cfg["registration_key"] = cfg.pop("api_key")
|
||||
print("[JARVIS] Config migrated: api_key -> registration_key", flush=True)
|
||||
changed = True
|
||||
if "hostname" not in cfg:
|
||||
cfg["hostname"] = socket.gethostname()
|
||||
changed = True
|
||||
if "ssl_verify" not in cfg:
|
||||
cfg["ssl_verify"] = not bool(_re.match(r"https?://\d+\.\d+\.\d+\.\d+", cfg.get("jarvis_url", "")))
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
json.dump(cfg, f, indent=2)
|
||||
print("[JARVIS] Config saved after migration.", flush=True)
|
||||
|
||||
return cfg
|
||||
|
||||
def load_state() -> dict:
|
||||
if os.path.exists(STATE_PATH):
|
||||
with open(STATE_PATH) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_state(state: dict):
|
||||
Path(STATE_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(STATE_PATH, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
# ── HTTP helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
import ssl as _ssl
|
||||
|
||||
def _make_ssl_ctx(verify: bool) -> _ssl.SSLContext | None:
|
||||
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 = "" # set from config at startup
|
||||
|
||||
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/1.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/1.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)}
|
||||
|
||||
# ── Registration ──────────────────────────────────────────────────────────────
|
||||
|
||||
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 detect_capabilities(cfg: dict) -> list:
|
||||
caps = ["metrics", "commands"]
|
||||
# Check for Proxmox
|
||||
if os.path.exists("/usr/bin/pvesh") or os.path.exists("/usr/sbin/pveversion"):
|
||||
caps.append("proxmox")
|
||||
# Check for Docker
|
||||
if os.path.exists("/usr/bin/docker") or os.path.exists("/usr/local/bin/docker"):
|
||||
caps.append("docker")
|
||||
# Check for Ollama
|
||||
if os.path.exists("/usr/local/bin/ollama") or os.path.exists("/usr/bin/ollama"):
|
||||
caps.append("ollama")
|
||||
# Check for Home Assistant
|
||||
if os.path.exists("/etc/homeassistant") or os.path.exists("/config/configuration.yaml"):
|
||||
caps.append("homeassistant")
|
||||
return caps
|
||||
|
||||
def register(cfg: dict, state: dict) -> str:
|
||||
"""Register with JARVIS. Returns api_key."""
|
||||
hostname = cfg.get("hostname", socket.gethostname())
|
||||
agent_type = cfg.get("agent_type", "linux")
|
||||
ip = get_local_ip()
|
||||
capabilities = detect_capabilities(cfg)
|
||||
agent_id = cfg.get("agent_id", f"{hostname}_{socket.gethostname()[:8]}")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", True))
|
||||
|
||||
print(f"[JARVIS] Registering as '{agent_id}' ({agent_type}) from {ip}...", flush=True)
|
||||
|
||||
result = api_post(
|
||||
f"{cfg['jarvis_url']}/api/agent/register",
|
||||
{
|
||||
"hostname": hostname,
|
||||
"version": AGENT_VERSION,
|
||||
"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:
|
||||
print(f"[ERROR] Registration failed: {result['error']}", flush=True)
|
||||
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)
|
||||
print(f"[JARVIS] Registered. agent_id={state['agent_id']}", flush=True)
|
||||
return api_key
|
||||
|
||||
# ── Metrics collection ────────────────────────────────────────────────────────
|
||||
|
||||
def read_cpu_percent() -> float:
|
||||
try:
|
||||
with open("/proc/stat") as f:
|
||||
line = f.readline()
|
||||
fields = list(map(int, line.split()[1:]))
|
||||
idle = fields[3]
|
||||
total = sum(fields)
|
||||
return round((1 - idle / total) * 100, 1) if total else 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
_last_cpu = None
|
||||
|
||||
def get_cpu_percent() -> float:
|
||||
global _last_cpu
|
||||
try:
|
||||
with open("/proc/stat") as f:
|
||||
line = f.readline()
|
||||
fields = list(map(int, line.split()[1:]))
|
||||
idle = fields[3] + fields[4] # idle + iowait
|
||||
total = sum(fields)
|
||||
if _last_cpu:
|
||||
d_idle = idle - _last_cpu[0]
|
||||
d_total = total - _last_cpu[1]
|
||||
result = round((1 - d_idle / d_total) * 100, 1) if d_total else 0.0
|
||||
else:
|
||||
result = 0.0
|
||||
_last_cpu = (idle, total)
|
||||
return result
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def get_memory() -> dict:
|
||||
mem = {}
|
||||
try:
|
||||
with open("/proc/meminfo") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if parts[0] in ("MemTotal:", "MemAvailable:", "MemFree:", "Buffers:", "Cached:"):
|
||||
mem[parts[0].rstrip(":")] = int(parts[1])
|
||||
total = mem.get("MemTotal", 0)
|
||||
available = mem.get("MemAvailable", 0)
|
||||
used = total - available
|
||||
return {
|
||||
"total_mb": round(total / 1024, 1),
|
||||
"used_mb": round(used / 1024, 1),
|
||||
"free_mb": round(available / 1024, 1),
|
||||
"percent": round(used / total * 100, 1) if total else 0,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_disk() -> list:
|
||||
disks = []
|
||||
try:
|
||||
result = subprocess.run(["df", "-h", "--output=source,fstype,size,used,avail,pcent,target"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
lines = result.stdout.strip().split("\n")[1:]
|
||||
for line in lines:
|
||||
parts = line.split()
|
||||
if len(parts) >= 7:
|
||||
mount = parts[6]
|
||||
if not any(mount.startswith(x) for x in ["/sys", "/proc", "/dev/pts", "/run", "/snap"]):
|
||||
disks.append({
|
||||
"mount": mount,
|
||||
"size": parts[2],
|
||||
"used": parts[3],
|
||||
"avail": parts[4],
|
||||
"percent": parts[5].rstrip("%"),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return disks
|
||||
|
||||
def get_uptime() -> dict:
|
||||
try:
|
||||
with open("/proc/uptime") as f:
|
||||
secs = float(f.read().split()[0])
|
||||
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", [])
|
||||
statuses = []
|
||||
for svc in watch:
|
||||
try:
|
||||
r = subprocess.run(["systemctl", "is-active", svc], capture_output=True, text=True, timeout=3)
|
||||
statuses.append({"service": svc, "status": r.stdout.strip()})
|
||||
except Exception:
|
||||
statuses.append({"service": svc, "status": "unknown"})
|
||||
return statuses
|
||||
|
||||
def get_load() -> list:
|
||||
try:
|
||||
with open("/proc/loadavg") as f:
|
||||
parts = f.read().split()
|
||||
return [float(parts[0]), float(parts[1]), float(parts[2])]
|
||||
except Exception:
|
||||
return [0, 0, 0]
|
||||
|
||||
def get_nordvpn_status() -> dict | None:
|
||||
"""Check nordlynx WireGuard interface. Returns None if nordlynx not present on this host."""
|
||||
try:
|
||||
r = subprocess.run(["ip", "link", "show", "nordlynx"],
|
||||
capture_output=True, text=True, timeout=3)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
active = "UP,LOWER_UP" in r.stdout or "state UP" in r.stdout
|
||||
return {"active": active, "interface": "nordlynx"}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def collect_metrics(cfg: dict) -> dict:
|
||||
# First reading for CPU delta
|
||||
get_cpu_percent()
|
||||
time.sleep(1)
|
||||
metrics = {
|
||||
"hostname": cfg.get("hostname", socket.gethostname()),
|
||||
"cpu_percent": get_cpu_percent(),
|
||||
"memory": get_memory(),
|
||||
"disk": get_disk(),
|
||||
"uptime": get_uptime(),
|
||||
"load": get_load(),
|
||||
"services": get_services(cfg),
|
||||
"platform": platform.system(),
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
nordvpn = get_nordvpn_status()
|
||||
if nordvpn is not None:
|
||||
metrics["nordvpn"] = nordvpn
|
||||
return metrics
|
||||
|
||||
# ── Proxmox metrics ───────────────────────────────────────────────────────────
|
||||
|
||||
def collect_proxmox_metrics(cfg: dict) -> dict | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pvesh", "get", "/nodes/pve/status", "--output-format", "json"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
node_status = json.loads(result.stdout)
|
||||
vms_result = subprocess.run(
|
||||
["pvesh", "get", "/nodes/pve/qemu", "--output-format", "json"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
vms = json.loads(vms_result.stdout)
|
||||
return {"node": node_status, "vms": vms}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
# ── Command execution ─────────────────────────────────────────────────────────
|
||||
|
||||
def execute_command(cmd: dict) -> dict:
|
||||
cmd_type = cmd.get("command_type", "")
|
||||
cmd_data = cmd.get("command_data", {})
|
||||
|
||||
try:
|
||||
if cmd_type == "restart_service":
|
||||
svc = cmd_data.get("service", "")
|
||||
if not svc or "/" in svc:
|
||||
return {"success": False, "error": "Invalid service name"}
|
||||
r = subprocess.run(["systemctl", "restart", svc], capture_output=True, text=True, timeout=30)
|
||||
return {"success": r.returncode == 0, "stdout": r.stdout, "stderr": r.stderr}
|
||||
|
||||
elif cmd_type == "get_logs":
|
||||
svc = cmd_data.get("service", "")
|
||||
lines = min(int(cmd_data.get("lines", 50)), 200)
|
||||
if not svc or "/" in svc:
|
||||
return {"success": False, "error": "Invalid service name"}
|
||||
r = subprocess.run(["journalctl", "-u", svc, "-n", str(lines), "--no-pager"],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
return {"success": True, "output": r.stdout}
|
||||
|
||||
elif cmd_type == "ping":
|
||||
host = cmd_data.get("host", "8.8.8.8")
|
||||
r = subprocess.run(["ping", "-c", "3", "-W", "2", host], capture_output=True, text=True, timeout=15)
|
||||
return {"success": r.returncode == 0, "output": r.stdout}
|
||||
|
||||
elif cmd_type == "update":
|
||||
updated = self_update(cfg)
|
||||
return {"success": True, "updated": updated}
|
||||
|
||||
elif cmd_type == "shell":
|
||||
# Only allow if explicitly enabled in config
|
||||
if not cmd_data.get("allowed", False):
|
||||
return {"success": False, "error": "Shell commands not enabled"}
|
||||
cmd_str = cmd_data.get("command", "")
|
||||
r = subprocess.run(cmd_str, shell=True, capture_output=True, text=True, timeout=30)
|
||||
return {"success": True, "stdout": r.stdout[:2000], "stderr": r.stderr[:500]}
|
||||
|
||||
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))
|
||||
|
||||
# Register if no API key yet
|
||||
api_key = state.get("api_key", "")
|
||||
if not api_key:
|
||||
api_key = register(cfg, state)
|
||||
if not api_key:
|
||||
print("[ERROR] Could not register with JARVIS. Retrying in 60s...", flush=True)
|
||||
time.sleep(60)
|
||||
main()
|
||||
return
|
||||
|
||||
headers = {"X-Agent-Key": api_key}
|
||||
last_metrics = 0
|
||||
last_update_chk = 0
|
||||
update_interval = int(cfg.get("update_check_hours", 24)) * 3600
|
||||
tick = 0
|
||||
|
||||
print(f"[JARVIS] Agent v{AGENT_VERSION} running. Polling {jarvis_url} every {heartbeat_every}s.", flush=True)
|
||||
|
||||
while True:
|
||||
tick += 1
|
||||
now = time.time()
|
||||
|
||||
try:
|
||||
# Heartbeat + get commands
|
||||
hb = api_post(f"{jarvis_url}/api/agent/heartbeat", {"version": AGENT_VERSION}, headers, ssl_verify=ssl_verify)
|
||||
if "error" in hb:
|
||||
print(f"[WARN] Heartbeat failed: {hb['error']}", flush=True)
|
||||
else:
|
||||
commands = hb.get("commands", [])
|
||||
for cmd in commands:
|
||||
print(f"[CMD] Executing: {cmd['command_type']}", flush=True)
|
||||
result = execute_command(cmd)
|
||||
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 (every update_interval seconds, default 24h)
|
||||
if now - last_update_chk >= update_interval:
|
||||
last_update_chk = now
|
||||
self_update(cfg) # restarts process if update found
|
||||
|
||||
# Push metrics every poll_interval seconds
|
||||
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)
|
||||
|
||||
# Proxmox metrics if available
|
||||
if "proxmox" in detect_capabilities(cfg):
|
||||
px = collect_proxmox_metrics(cfg)
|
||||
if px:
|
||||
api_post(f"{jarvis_url}/api/agent/metrics",
|
||||
{"type": "proxmox", "data": px}, headers, ssl_verify=ssl_verify)
|
||||
|
||||
last_metrics = now
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Loop error: {e}", flush=True)
|
||||
|
||||
time.sleep(heartbeat_every)
|
||||
|
||||
|
||||
# ── Self-update ────────────────────────────────────────────────────────────────
|
||||
|
||||
def self_update(cfg: dict) -> bool:
|
||||
"""Check JARVIS server for a newer version of this script. If different, replace and restart."""
|
||||
jarvis_url = cfg.get("jarvis_url", "").rstrip("/")
|
||||
default_update_url = f"{jarvis_url}/agent/jarvis-agent.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__)
|
||||
try:
|
||||
req = urllib.request.Request(update_url)
|
||||
req.add_header("User-Agent", "JARVIS-Agent/1.0")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
new_content = resp.read()
|
||||
with open(script_path, "rb") as f:
|
||||
current = f.read()
|
||||
if new_content != current:
|
||||
print(f"[JARVIS] Update available — replacing {script_path} and restarting...", flush=True)
|
||||
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:
|
||||
print(f"[JARVIS] Self-update check failed: {e}", flush=True)
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JARVIS HA Poller — pulls entity states from Home Assistant REST API
|
||||
and pushes them to JARVIS as a homeassistant-type agent.
|
||||
Runs on VM211 as a systemd service (jarvis-ha-poller).
|
||||
|
||||
Config: /etc/jarvis-agent/ha-poller.json
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import ssl
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_PATH = "/etc/jarvis-agent/ha-poller.json"
|
||||
STATE_PATH = "/var/lib/jarvis-agent/ha-poller-state.json"
|
||||
AGENT_VERSION = "1.0"
|
||||
AGENT_ID = "homeassistant_ha"
|
||||
HOSTNAME = "homeassistant"
|
||||
|
||||
# Domains to skip — don't send to JARVIS (saves DB space, keeps UI clean)
|
||||
SKIP_DOMAINS = {
|
||||
'sensor', 'binary_sensor', 'button', 'update', 'select', 'number',
|
||||
'device_tracker', 'event', 'image', 'person', 'zone', 'tts',
|
||||
'conversation', 'assist_satellite', 'input_button', 'media_player',
|
||||
'scene', 'water_heater', 'alarm_control_panel', 'automation',
|
||||
'script', 'calendar', 'notify', 'weather', 'sun', 'persistent_notification',
|
||||
'tag', 'system_health', 'timer', 'counter',
|
||||
'camera', 'siren', 'remote', 'todo', 'lawn_mower',
|
||||
}
|
||||
|
||||
def log(msg: str):
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[{ts}] {msg}", flush=True)
|
||||
|
||||
def load_config() -> dict:
|
||||
if not os.path.exists(CONFIG_PATH):
|
||||
print(f"[ERROR] Config not found at {CONFIG_PATH}", flush=True)
|
||||
sys.exit(1)
|
||||
with open(CONFIG_PATH) as f:
|
||||
return json.load(f)
|
||||
|
||||
def load_state() -> dict:
|
||||
if os.path.exists(STATE_PATH):
|
||||
with open(STATE_PATH) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_state(state: dict):
|
||||
Path(STATE_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(STATE_PATH, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
def _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
|
||||
|
||||
def jarvis_post(url: str, payload: dict, headers: dict, ssl_verify: bool, timeout: int = 15) -> dict:
|
||||
body = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
for k, v in headers.items():
|
||||
req.add_header(k, v)
|
||||
try:
|
||||
ctx = _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 ha_get(url: str, token: str, timeout: int = 15) -> dict | list | None:
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
log(f"HA API error: {e}")
|
||||
return None
|
||||
|
||||
def register(cfg: dict, state: dict) -> str:
|
||||
jarvis_url = cfg["jarvis_url"].rstrip("/")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", False))
|
||||
reg_key = cfg["registration_key"]
|
||||
|
||||
log(f"Registering HA poller with JARVIS at {jarvis_url}...")
|
||||
result = jarvis_post(
|
||||
f"{jarvis_url}/api/agent/register",
|
||||
{
|
||||
"hostname": HOSTNAME,
|
||||
"version": AGENT_VERSION,
|
||||
"agent_type": "homeassistant",
|
||||
"ip_address": cfg.get("ha_url", "").split("//")[-1].split(":")[0],
|
||||
"capabilities": ["ha_entities", "ha_state"],
|
||||
"agent_id": AGENT_ID,
|
||||
},
|
||||
{"X-Registration-Key": reg_key},
|
||||
ssl_verify,
|
||||
)
|
||||
if "error" in result:
|
||||
log(f"Registration failed: {result['error']}")
|
||||
return ""
|
||||
api_key = result.get("api_key", "")
|
||||
if api_key:
|
||||
state["api_key"] = api_key
|
||||
state["agent_id"] = AGENT_ID
|
||||
save_state(state)
|
||||
log(f"Registered. agent_id={AGENT_ID}")
|
||||
return api_key
|
||||
|
||||
def push_entities(cfg: dict, api_key: str, entities: list) -> bool:
|
||||
jarvis_url = cfg["jarvis_url"].rstrip("/")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", False))
|
||||
headers = {"X-Agent-Key": api_key}
|
||||
|
||||
# Send in batches of 200
|
||||
batch_size = 200
|
||||
total = len(entities)
|
||||
ok = True
|
||||
for i in range(0, total, batch_size):
|
||||
batch = entities[i:i+batch_size]
|
||||
result = jarvis_post(
|
||||
f"{jarvis_url}/api/agent/ha_state",
|
||||
{"entities": batch},
|
||||
headers,
|
||||
ssl_verify,
|
||||
)
|
||||
if "error" in result:
|
||||
log(f"Push batch {i//batch_size+1} failed: {result['error']}")
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
def heartbeat(cfg: dict, api_key: str) -> bool:
|
||||
jarvis_url = cfg["jarvis_url"].rstrip("/")
|
||||
ssl_verify = bool(cfg.get("ssl_verify", False))
|
||||
result = jarvis_post(
|
||||
f"{jarvis_url}/api/agent/heartbeat",
|
||||
{"version": AGENT_VERSION},
|
||||
{"X-Agent-Key": api_key},
|
||||
ssl_verify,
|
||||
timeout=10,
|
||||
)
|
||||
return "error" not in result
|
||||
|
||||
def fetch_ha_states(cfg: dict) -> list:
|
||||
ha_url = cfg["ha_url"].rstrip("/")
|
||||
token = cfg["ha_token"]
|
||||
states = ha_get(f"{ha_url}/api/states", token)
|
||||
if not states or not isinstance(states, list):
|
||||
return []
|
||||
|
||||
entities = []
|
||||
for s in states:
|
||||
entity_id = s.get("entity_id", "")
|
||||
domain = entity_id.split(".")[0] if "." in entity_id else ""
|
||||
if domain in SKIP_DOMAINS:
|
||||
continue
|
||||
attrs = s.get("attributes", {})
|
||||
# Convert ISO 8601 (e.g. "2026-06-28T21:26:01.922366+00:00") to MySQL datetime
|
||||
lc = s.get("last_changed", "")
|
||||
try:
|
||||
dt = datetime.fromisoformat(lc.replace("Z", "+00:00"))
|
||||
lc = dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except Exception:
|
||||
lc = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
entities.append({
|
||||
"entity_id": entity_id,
|
||||
"name": attrs.get("friendly_name") or entity_id,
|
||||
"state": s.get("state", ""),
|
||||
"attributes": attrs,
|
||||
"last_changed": lc,
|
||||
})
|
||||
return entities
|
||||
|
||||
def main():
|
||||
cfg = load_config()
|
||||
state = load_state()
|
||||
|
||||
poll_interval = int(cfg.get("poll_interval", 30))
|
||||
heartbeat_every = int(cfg.get("heartbeat_every", 10))
|
||||
|
||||
api_key = state.get("api_key", "")
|
||||
while not api_key:
|
||||
api_key = register(cfg, state)
|
||||
if not api_key:
|
||||
log("Could not register. Retrying in 60s...")
|
||||
time.sleep(60)
|
||||
|
||||
headers = {"X-Agent-Key": api_key}
|
||||
last_push = 0
|
||||
log(f"HA Poller v{AGENT_VERSION} running. Polling HA every {poll_interval}s, heartbeat every {heartbeat_every}s.")
|
||||
|
||||
while True:
|
||||
now = time.time()
|
||||
|
||||
# Heartbeat
|
||||
if not heartbeat(cfg, api_key):
|
||||
log("Heartbeat failed (401?) — re-registering...")
|
||||
state.clear()
|
||||
save_state(state)
|
||||
api_key = register(cfg, state)
|
||||
if not api_key:
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
# Push entity states every poll_interval
|
||||
if now - last_push >= poll_interval:
|
||||
entities = fetch_ha_states(cfg)
|
||||
if entities:
|
||||
ok = push_entities(cfg, api_key, entities)
|
||||
if ok:
|
||||
log(f"Pushed {len(entities)} HA entities to JARVIS.")
|
||||
last_push = now
|
||||
else:
|
||||
log("No HA entities fetched (HA down or token invalid?)")
|
||||
last_push = now
|
||||
|
||||
time.sleep(heartbeat_every)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
# JARVIS Network Scanner — runs on PVE1, pushes nmap results to JARVIS
|
||||
# Cron: */3 * * * * /usr/local/bin/jarvis-netscan.sh >/dev/null 2>&1
|
||||
|
||||
JARVIS_URL="http://10.48.200.211"
|
||||
JARVIS_HOST="jarvis.orbishosting.com"
|
||||
REG_KEY=$(cat /etc/jarvis-agent/reg-key 2>/dev/null)
|
||||
if [ -z "$REG_KEY" ]; then
|
||||
echo "$(date): ERROR: /etc/jarvis-agent/reg-key not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
SUBNET="10.48.200.0/24"
|
||||
|
||||
TMPFILE=$(mktemp)
|
||||
nmap -sn --send-ip "$SUBNET" 2>/dev/null > "$TMPFILE"
|
||||
|
||||
if [ ! -s "$TMPFILE" ]; then
|
||||
echo "$(date): nmap produced no output" >&2
|
||||
rm -f "$TMPFILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
JSON=$(python3 - "$TMPFILE" <<'PYEOF'
|
||||
import sys, re, json
|
||||
|
||||
with open(sys.argv[1]) as f:
|
||||
data = f.read()
|
||||
|
||||
devices = []
|
||||
cur = None
|
||||
|
||||
for line in data.splitlines():
|
||||
line = line.strip()
|
||||
m = re.match(r'Nmap scan report for (?:(\S+) \()?(\d+\.\d+\.\d+\.\d+)\)?', line)
|
||||
if m:
|
||||
if cur:
|
||||
devices.append(cur)
|
||||
hn = m.group(1) if m.group(1) and m.group(1) != m.group(2) else ''
|
||||
cur = {'ip': m.group(2), 'hostname': hn, 'mac': '', 'vendor': ''}
|
||||
elif cur:
|
||||
m2 = re.match(r'MAC Address: ([0-9A-Fa-f:]{17}) \(([^)]+)\)', line)
|
||||
if m2:
|
||||
cur['mac'] = m2.group(1).lower()
|
||||
cur['vendor'] = '' if m2.group(2) == 'Unknown' else m2.group(2)
|
||||
|
||||
if cur:
|
||||
devices.append(cur)
|
||||
|
||||
print(json.dumps({'devices': devices}))
|
||||
PYEOF
|
||||
)
|
||||
|
||||
rm -f "$TMPFILE"
|
||||
|
||||
if [ -z "$JSON" ]; then
|
||||
echo "$(date): JSON parse failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RESPONSE=$(curl -sk --max-time 15 \
|
||||
-X POST "$JARVIS_URL/api/netscan" \
|
||||
-H "Host: $JARVIS_HOST" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Registration-Key: $REG_KEY" \
|
||||
-d "$JSON" 2>/dev/null)
|
||||
|
||||
echo "$(date): $RESPONSE"
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
# JARVIS VoIP Phone Probe — runs every minute on PVE1
|
||||
# Pings all Yealink phones + checks FusionPBX SIP registration (read-only)
|
||||
# 200.3 is on an external FusionPBX — ping only, no SIP check
|
||||
|
||||
JARVIS_URL="http://10.48.200.211"
|
||||
JARVIS_HOST="jarvis.orbishosting.com"
|
||||
REG_KEY=$(cat /etc/jarvis-agent/reg-key 2>/dev/null)
|
||||
if [ -z "$REG_KEY" ]; then
|
||||
echo "$(date): ERROR: /etc/jarvis-agent/reg-key not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
FUSION_HOST="134.209.72.226"
|
||||
|
||||
# IP|alias|extension(none=skip SIP check)|mac
|
||||
PHONES=(
|
||||
"10.48.200.2|Yealink — Myron Main (Ext 1000)|1000|80:5e:c0:35:04:77"
|
||||
"10.48.200.3|Yealink — United Mirror & Glass (External SIP)|none|c4:fc:22:28:63:71"
|
||||
"10.48.200.43|Yealink T48S — Tommy Main (Ext 1001)|1001|80:5e:0c:15:0c:4f"
|
||||
"10.48.200.86|Yealink — Myron Vanguard WiFi (Offline During Work Hrs)|none|"
|
||||
"10.48.200.65|Yealink — Myron Vanguard Work (Ext 1003)|1003|c4:fc:22:13:e1:89"
|
||||
)
|
||||
|
||||
# Get SIP registrations from FusionPBX (read-only)
|
||||
REG_OUTPUT=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -o BatchMode=yes \
|
||||
root@$FUSION_HOST "fs_cli -x 'show registrations'" 2>/dev/null || echo "")
|
||||
|
||||
# Collect results as TSV, delegate JSON building to python3 to avoid injection
|
||||
RESULTS=""
|
||||
for PHONE in "${PHONES[@]}"; do
|
||||
IFS='|' read -r IP ALIAS EXT MAC <<< "$PHONE"
|
||||
|
||||
if ping -c 1 -W 2 "$IP" > /dev/null 2>&1; then
|
||||
STATUS="online"
|
||||
else
|
||||
STATUS="offline"
|
||||
fi
|
||||
|
||||
if [ "$EXT" = "none" ]; then
|
||||
SIP="external"
|
||||
elif [ -n "$REG_OUTPUT" ] && echo "$REG_OUTPUT" | grep -q "^${EXT},"; then
|
||||
SIP="registered"
|
||||
else
|
||||
SIP="unregistered"
|
||||
fi
|
||||
|
||||
RESULTS="${RESULTS}${IP}\t${ALIAS}\t${MAC}\t${STATUS}\t${SIP}\t${EXT}\n"
|
||||
done
|
||||
|
||||
JSON=$(printf "%b" "$RESULTS" | python3 -c "
|
||||
import sys, json
|
||||
devices = []
|
||||
for line in sys.stdin:
|
||||
line = line.rstrip('\n')
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split('\t')
|
||||
if len(parts) < 6:
|
||||
continue
|
||||
ip, alias, mac, status, sip, ext = parts[:6]
|
||||
devices.append({
|
||||
'ip': ip, 'alias': alias, 'mac': mac,
|
||||
'vendor': 'Yealink', 'status': status,
|
||||
'sip_status': sip, 'extension': ext,
|
||||
})
|
||||
print(json.dumps({'devices': devices}))
|
||||
")
|
||||
|
||||
curl -sk --max-time 10 \
|
||||
-X POST "$JARVIS_URL/api/netscan" \
|
||||
-H "Host: $JARVIS_HOST" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Registration-Key: $REG_KEY" \
|
||||
-d "$JSON" > /dev/null 2>&1
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JARVIS Ping Probe — runs on PVE1 (10.48.200.90), which is on the LAN.
|
||||
Pings devices that can't run the full agent, then calls JARVIS heartbeat
|
||||
on their behalf so the dashboard shows live status.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import ssl
|
||||
|
||||
JARVIS_URL = "http://10.48.200.211"
|
||||
HOST_HEADER = "jarvis.orbishosting.com"
|
||||
|
||||
# Devices to probe: agent_id → api_key
|
||||
DEVICES = {
|
||||
"fortigate_gw": "00103aea6fcbf837bc55e11b445a3620",
|
||||
"yealink_t48s": "2bf8bd7ca8dd31c28fd16aa956e15f88",
|
||||
"homeassistant_ha": "6f8077dee7a7b4af202bc80886f1223d",
|
||||
}
|
||||
|
||||
# Map agent_id → IP (for ping)
|
||||
IPS = {
|
||||
"fortigate_gw": "10.48.200.1",
|
||||
"yealink_t48s": "10.48.200.43",
|
||||
"homeassistant_ha": "10.48.200.97",
|
||||
}
|
||||
|
||||
def ping(ip: str) -> bool:
|
||||
result = subprocess.run(
|
||||
["ping", "-c", "1", "-W", "2", ip],
|
||||
capture_output=True, timeout=5
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
def heartbeat(agent_id: str, api_key: str, alive: bool):
|
||||
# If device is down we still send heartbeat so JARVIS updates last_seen
|
||||
# and sets status based on the alive flag via the metric payload
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
payload = json.dumps({}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{JARVIS_URL}/api/agent/heartbeat",
|
||||
data=payload, method="POST"
|
||||
)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("X-Agent-Key", api_key)
|
||||
req.add_header("Host", HOST_HEADER)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10, context=ctx):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def update_status(agent_id: str, api_key: str, status: str):
|
||||
"""Push a minimal metric so JARVIS knows if device is up or down."""
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
payload = json.dumps({
|
||||
"type": "system",
|
||||
"data": {
|
||||
"hostname": agent_id,
|
||||
"cpu_percent": 0,
|
||||
"ping_only": True,
|
||||
"ping_status": status,
|
||||
}
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{JARVIS_URL}/api/agent/metrics",
|
||||
data=payload, method="POST"
|
||||
)
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("X-Agent-Key", api_key)
|
||||
req.add_header("Host", HOST_HEADER)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10, context=ctx):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def main():
|
||||
for agent_id, api_key in DEVICES.items():
|
||||
ip = IPS.get(agent_id, "")
|
||||
alive = ping(ip) if ip else False
|
||||
status = "online" if alive else "offline"
|
||||
print(f"{agent_id} ({ip}): {status}", flush=True)
|
||||
heartbeat(agent_id, api_key, alive)
|
||||
update_status(agent_id, api_key, status)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user