From d3156b98b33a22cd871e75953401646fd493bd91 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Mon, 25 May 2026 13:51:12 +0000 Subject: [PATCH] Add DB schema, agent script, vhost config, deploy cron - db/schema.sql: full jarvis_db schema (15 tables) - agent/jarvis-agent.py: production agent script - config/vhost/: OpenLiteSpeed vhost configuration - deploy/jarvis-agent.service: systemd unit - deploy/cron-jarvis.txt: JARVIS cron entries - .gitignore: exclude system dirs and logs --- .gitignore | 5 + agent/jarvis-agent.py | 454 ++++++++++++++++++++++++++++++++++++ config/vhost/vhost.conf | 94 ++++++++ config/vhost/vhost.conf0 | 94 ++++++++ config/vhost/vhost.conf0,v | 147 ++++++++++++ db/schema.sql | 250 ++++++++++++++++++++ deploy/cron-jarvis.txt | 2 + deploy/jarvis-agent.service | 14 ++ 8 files changed, 1060 insertions(+) create mode 100755 agent/jarvis-agent.py create mode 100755 config/vhost/vhost.conf create mode 100755 config/vhost/vhost.conf0 create mode 100755 config/vhost/vhost.conf0,v create mode 100644 db/schema.sql create mode 100644 deploy/cron-jarvis.txt create mode 100644 deploy/jarvis-agent.service diff --git a/.gitignore b/.gitignore index c206185..73d1b7d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ logs/ # OS .DS_Store +.imunify_patch_id +.ssh/ +mail.*/ +logs/ +backup/ diff --git a/agent/jarvis-agent.py b/agent/jarvis-agent.py new file mode 100755 index 0000000..8045fc2 --- /dev/null +++ b/agent/jarvis-agent.py @@ -0,0 +1,454 @@ +#!/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 = "2.3" # bumped on each release + +# ── Config helpers ──────────────────────────────────────────────────────────── + +def load_config() -> dict: + if not os.path.exists(CONFIG_PATH): + print(f"[ERROR] Config not found at {CONFIG_PATH}. Run the installer first.", 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) + +# ── 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, + "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", ["ollama", "homeassistant", "mysql", "nginx", "apache2"]) + 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 collect_metrics(cfg: dict) -> dict: + # First reading for CPU delta + get_cpu_percent() + time.sleep(1) + 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": platform.system(), + "timestamp": datetime.utcnow().isoformat() + "Z", + } + +# ── 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", {}, 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() diff --git a/config/vhost/vhost.conf b/config/vhost/vhost.conf new file mode 100755 index 0000000..fe2cee1 --- /dev/null +++ b/config/vhost/vhost.conf @@ -0,0 +1,94 @@ +docRoot $VH_ROOT/public_html +vhDomain $VH_NAME +vhAliases www.$VH_NAME +adminEmails admin@orbishosting.com +enableGzip 1 +enableIpGeo 1 + +index { + useServer 0 + indexFiles index.php, index.html +} + +errorlog $VH_ROOT/logs/$VH_NAME.error_log { + useServer 0 + logLevel WARN + rollingSize 10M +} + +accesslog $VH_ROOT/logs/$VH_NAME.access_log { + useServer 0 + logFormat "%h %l %u %t "%r" %>s %b "%{Referer}i" "%{User-Agent}i"" + logHeaders 5 + rollingSize 10M + keepDays 10 + compressArchive 1 +} + +scripthandler { + add lsapi:jarvi5150 php +} + +extprocessor jarvi5150 { + type lsapi + address UDS://tmp/lshttpd/jarvi5150.sock + maxConns 10 + env LSAPI_CHILDREN=10 + initTimeout 600 + retryTimeout 0 + persistConn 1 + pcKeepAliveTimeout 1 + respBuffer 0 + autoStart 1 + path /usr/local/lsws/lsphp85/bin/lsphp + extUser jarvi5150 + extGroup jarvi5150 + memSoftLimit 1024M + memHardLimit 1024M + procSoftLimit 400 + procHardLimit 500 +} + +phpIniOverride { + +} + +module cache { + storagePath /usr/local/lsws/cachedata/$VH_NAME +} + +rewrite { + enable 1 + autoLoadHtaccess 1 + rules <<s %b "%{Referer}i" "%{User-Agent}i"" + logHeaders 5 + rollingSize 10M + keepDays 10 + compressArchive 1 +} + +scripthandler { + add lsapi:jarvi5150 php +} + +extprocessor jarvi5150 { + type lsapi + address UDS://tmp/lshttpd/jarvi5150.sock + maxConns 10 + env LSAPI_CHILDREN=10 + initTimeout 600 + retryTimeout 0 + persistConn 1 + pcKeepAliveTimeout 1 + respBuffer 0 + autoStart 1 + path /usr/local/lsws/lsphp85/bin/lsphp + extUser jarvi5150 + extGroup jarvi5150 + memSoftLimit 1024M + memHardLimit 1024M + procSoftLimit 400 + procHardLimit 500 +} + +phpIniOverride { + +} + +module cache { + storagePath /usr/local/lsws/cachedata/$VH_NAME +} + +rewrite { + enable 1 + autoLoadHtaccess 1 + rules <<s %b "%{Referer}i" "%{User-Agent}i"" + logHeaders 5 + rollingSize 10M + keepDays 10 + compressArchive 1 +} + +scripthandler { + add lsapi:jarvi5150 php +} + +extprocessor jarvi5150 { + type lsapi + address UDS://tmp/lshttpd/jarvi5150.sock + maxConns 10 + env LSAPI_CHILDREN=10 + initTimeout 600 + retryTimeout 0 + persistConn 1 + pcKeepAliveTimeout 1 + respBuffer 0 + autoStart 1 + path /usr/local/lsws/lsphp85/bin/lsphp + extUser jarvi5150 + extGroup jarvi5150 + memSoftLimit 1024M + memHardLimit 1024M + procSoftLimit 400 + procHardLimit 500 +} + +phpIniOverride { + +} + +module cache { + storagePath /usr/local/lsws/cachedata/$VH_NAME +} + +rewrite { + enable 1 + autoLoadHtaccess 1 + rules <<> /home/jarvis.orbishosting.com/logs/cron.log 2>&1 +*/5 * * * * /usr/local/lsws/lsphp85/bin/lsphp /home/jarvis.orbishosting.com/api/endpoints/stats_cache.php >> /home/jarvis.orbishosting.com/logs/cron.log 2>&1 diff --git a/deploy/jarvis-agent.service b/deploy/jarvis-agent.service new file mode 100644 index 0000000..abde978 --- /dev/null +++ b/deploy/jarvis-agent.service @@ -0,0 +1,14 @@ +[Unit] +Description=JARVIS Agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /usr/local/bin/jarvis-agent.py +Restart=always +RestartSec=30 +User=root + +[Install] +WantedBy=multi-user.target