2026-05-20 17:24:21 +08:00
|
|
|
|
"""
|
2026-05-22 00:37:39 +08:00
|
|
|
|
Nexus Agent — Runs on each managed server
|
|
|
|
|
|
Provides: health endpoint, heartbeat, command execution, config reload
|
2026-05-20 17:24:21 +08:00
|
|
|
|
"""
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import json
|
|
|
|
|
|
import time
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import platform
|
2026-05-22 00:37:39 +08:00
|
|
|
|
import subprocess
|
2026-05-23 15:26:56 +08:00
|
|
|
|
import secrets
|
|
|
|
|
|
import shlex
|
2026-05-22 08:19:56 +08:00
|
|
|
|
from datetime import datetime, timezone
|
2026-05-20 17:24:21 +08:00
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
import httpx
|
2026-06-08 23:05:47 +08:00
|
|
|
|
|
|
|
|
|
|
from heartbeat_policy import heartbeat_should_stop
|
|
|
|
|
|
|
|
|
|
|
|
AGENT_VERSION = "2.0.0"
|
2026-05-20 17:24:21 +08:00
|
|
|
|
import psutil
|
2026-05-24 16:26:40 +08:00
|
|
|
|
from fastapi import FastAPI, HTTPException, Header, Request, Depends
|
2026-05-20 17:24:21 +08:00
|
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
|
|
|
2026-05-22 00:37:39 +08:00
|
|
|
|
# --- Configuration ---
|
2026-05-20 17:24:21 +08:00
|
|
|
|
CONFIG_PATH = Path(__file__).parent / "config.json"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_config():
|
|
|
|
|
|
if CONFIG_PATH.exists():
|
2026-06-04 14:01:14 +08:00
|
|
|
|
with open(CONFIG_PATH, encoding="utf-8") as f:
|
2026-05-20 17:24:21 +08:00
|
|
|
|
return json.load(f)
|
|
|
|
|
|
example = Path(__file__).parent / "config.example.json"
|
|
|
|
|
|
if example.exists():
|
2026-06-04 14:01:14 +08:00
|
|
|
|
with open(example, encoding="utf-8") as f:
|
2026-05-20 17:24:21 +08:00
|
|
|
|
return json.load(f)
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
config = load_config()
|
2026-05-23 15:26:56 +08:00
|
|
|
|
API_KEY = (config.get("api_key") or "").strip()
|
|
|
|
|
|
if not API_KEY:
|
|
|
|
|
|
print("FATAL: api_key must be set in config.json", file=sys.stderr)
|
|
|
|
|
|
sys.exit(1)
|
2026-05-22 00:37:39 +08:00
|
|
|
|
SERVER_ID = config.get("server_id", 0)
|
2026-05-20 17:24:21 +08:00
|
|
|
|
CENTRAL_URL = config.get("central", {}).get("url", "")
|
|
|
|
|
|
CENTRAL_API_KEY = config.get("central", {}).get("api_key", "")
|
2026-05-22 00:37:39 +08:00
|
|
|
|
HEARTBEAT_INTERVAL = config.get("heartbeat_interval", 60)
|
2026-05-20 17:24:21 +08:00
|
|
|
|
|
2026-05-23 18:27:12 +08:00
|
|
|
|
# --- IP allowlist (only accept requests from trusted IPs) ---
|
|
|
|
|
|
# In config.json: "allowed_ips": ["1.2.3.4", "10.0.0.0/8"]
|
|
|
|
|
|
# Empty list = allow all (default for backward compat)
|
|
|
|
|
|
_ALLOWED_IPS: list[str] = config.get("allowed_ips") or []
|
|
|
|
|
|
|
2026-05-20 17:24:21 +08:00
|
|
|
|
# --- Logging ---
|
|
|
|
|
|
|
2026-05-22 00:37:39 +08:00
|
|
|
|
log_file = config.get("log_file", "/var/log/nexus-agent.log")
|
2026-05-20 17:24:21 +08:00
|
|
|
|
log_level = config.get("log_level", "INFO")
|
|
|
|
|
|
|
|
|
|
|
|
logging.basicConfig(
|
|
|
|
|
|
level=getattr(logging, log_level, logging.INFO),
|
|
|
|
|
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
|
|
|
|
handlers=[
|
|
|
|
|
|
logging.StreamHandler(sys.stdout),
|
|
|
|
|
|
logging.FileHandler(log_file, mode="a") if log_file != "stdout" else logging.StreamHandler(sys.stdout),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
2026-05-22 00:37:39 +08:00
|
|
|
|
logger = logging.getLogger("nexus-agent")
|
2026-05-20 17:24:21 +08:00
|
|
|
|
|
2026-06-08 23:05:47 +08:00
|
|
|
|
_heartbeat_task: Optional[asyncio.Task] = None
|
|
|
|
|
|
|
2026-05-20 17:24:21 +08:00
|
|
|
|
# --- FastAPI App ---
|
|
|
|
|
|
|
2026-05-30 18:20:14 +08:00
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
|
|
2026-06-08 23:05:47 +08:00
|
|
|
|
|
|
|
|
|
|
def _start_heartbeat_task() -> asyncio.Task:
|
|
|
|
|
|
"""Start heartbeat loop (idempotent if previous task still running)."""
|
|
|
|
|
|
global _heartbeat_task
|
|
|
|
|
|
if _heartbeat_task is not None and not _heartbeat_task.done():
|
|
|
|
|
|
return _heartbeat_task
|
|
|
|
|
|
_heartbeat_task = asyncio.create_task(send_heartbeat())
|
|
|
|
|
|
return _heartbeat_task
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-30 18:20:14 +08:00
|
|
|
|
@asynccontextmanager
|
|
|
|
|
|
async def agent_lifespan(app):
|
|
|
|
|
|
"""Application lifecycle: start heartbeat task on startup, clean up on shutdown."""
|
2026-06-08 23:05:47 +08:00
|
|
|
|
global _heartbeat_task
|
2026-05-30 18:20:14 +08:00
|
|
|
|
logger.info(f"Nexus Agent starting (server_id={SERVER_ID})")
|
2026-06-08 23:05:47 +08:00
|
|
|
|
_start_heartbeat_task()
|
2026-05-30 18:20:14 +08:00
|
|
|
|
|
|
|
|
|
|
host = config.get("server", {}).get("host", "127.0.0.1")
|
|
|
|
|
|
port = config.get("server", {}).get("port", 8601)
|
|
|
|
|
|
logger.info(f"Agent listening on {host}:{port}")
|
|
|
|
|
|
|
|
|
|
|
|
yield
|
|
|
|
|
|
|
2026-06-08 23:05:47 +08:00
|
|
|
|
if _heartbeat_task is not None:
|
|
|
|
|
|
_heartbeat_task.cancel()
|
|
|
|
|
|
_heartbeat_task = None
|
2026-05-30 18:20:14 +08:00
|
|
|
|
logger.info("Nexus Agent stopped")
|
|
|
|
|
|
|
2026-06-08 23:05:47 +08:00
|
|
|
|
app = FastAPI(title="Nexus Agent", version=AGENT_VERSION, lifespan=agent_lifespan)
|
2026-05-20 17:24:21 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 18:27:12 +08:00
|
|
|
|
def _ip_allowed(client_ip: str) -> bool:
|
|
|
|
|
|
"""Check client IP against allowed_ips list (supports CIDR and exact match)."""
|
|
|
|
|
|
if not _ALLOWED_IPS:
|
|
|
|
|
|
return True # Empty = allow all
|
|
|
|
|
|
import ipaddress
|
|
|
|
|
|
try:
|
|
|
|
|
|
addr = ipaddress.ip_address(client_ip)
|
|
|
|
|
|
for entry in _ALLOWED_IPS:
|
|
|
|
|
|
entry = entry.strip()
|
|
|
|
|
|
try:
|
|
|
|
|
|
if "/" in entry:
|
|
|
|
|
|
if addr in ipaddress.ip_network(entry, strict=False):
|
|
|
|
|
|
return True
|
|
|
|
|
|
elif addr == ipaddress.ip_address(entry):
|
|
|
|
|
|
return True
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
if client_ip == entry:
|
|
|
|
|
|
return True
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return client_ip in _ALLOWED_IPS
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-24 16:26:40 +08:00
|
|
|
|
async def verify_api_key(request: Request, x_api_key: str = Header(default="")):
|
|
|
|
|
|
"""Dependency: IP allowlist + API key verification."""
|
2026-05-23 18:27:12 +08:00
|
|
|
|
client_ip = request.client.host if request.client else ""
|
|
|
|
|
|
if not _ip_allowed(client_ip):
|
|
|
|
|
|
logger.warning("Blocked request from %s (not in allowed_ips)", client_ip)
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="IP not allowed")
|
2026-05-23 15:26:56 +08:00
|
|
|
|
if not secrets.compare_digest(x_api_key or "", API_KEY):
|
2026-05-20 17:24:21 +08:00
|
|
|
|
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Health Endpoint ---
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/health")
|
2026-05-23 18:27:12 +08:00
|
|
|
|
async def health_check(request: Request):
|
2026-05-22 00:37:39 +08:00
|
|
|
|
"""Health check endpoint — called by Nexus central server"""
|
2026-05-23 18:27:12 +08:00
|
|
|
|
client_ip = request.client.host if request.client else ""
|
|
|
|
|
|
if not _ip_allowed(client_ip):
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="IP not allowed")
|
2026-05-20 17:24:21 +08:00
|
|
|
|
try:
|
|
|
|
|
|
cpu_percent = psutil.cpu_percent(interval=0.5)
|
|
|
|
|
|
memory = psutil.virtual_memory()
|
|
|
|
|
|
disk = psutil.disk_usage("/")
|
|
|
|
|
|
|
2026-06-04 14:01:14 +08:00
|
|
|
|
os_release = _os_release()
|
2026-05-27 02:17:35 +08:00
|
|
|
|
|
2026-05-20 17:24:21 +08:00
|
|
|
|
system_info = {
|
|
|
|
|
|
"hostname": platform.node(),
|
|
|
|
|
|
"platform": platform.platform(),
|
2026-05-27 02:17:35 +08:00
|
|
|
|
"os_release": os_release,
|
2026-05-20 17:24:21 +08:00
|
|
|
|
"python_version": platform.python_version(),
|
2026-05-22 00:37:39 +08:00
|
|
|
|
"cpu_usage": cpu_percent,
|
|
|
|
|
|
"mem_usage": memory.percent,
|
|
|
|
|
|
"disk_usage": round(disk.percent, 1),
|
2026-05-20 17:24:21 +08:00
|
|
|
|
"memory_total_gb": round(memory.total / (1024**3), 2),
|
|
|
|
|
|
"memory_used_gb": round(memory.used / (1024**3), 2),
|
|
|
|
|
|
"disk_total_gb": round(disk.total / (1024**3), 2),
|
|
|
|
|
|
"disk_used_gb": round(disk.used / (1024**3), 2),
|
|
|
|
|
|
"uptime_seconds": int(time.time() - psutil.boot_time()),
|
2026-05-22 08:19:56 +08:00
|
|
|
|
"agent_time": datetime.now(timezone.utc).isoformat(),
|
2026-05-20 17:24:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 00:37:39 +08:00
|
|
|
|
return {"status": "healthy", "system_info": system_info}
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return {"status": "degraded", "error": str(e)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Command Execution ---
|
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
|
_running_procs: dict[int, asyncio.subprocess.Process] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _proc_result(proc: asyncio.subprocess.Process, stdout: bytes, stderr: bytes) -> dict:
|
|
|
|
|
|
exit_code = proc.returncode if proc.returncode is not None else -1
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "success" if exit_code == 0 else "failed",
|
|
|
|
|
|
"stdout": stdout.decode(errors="replace")[:10000],
|
|
|
|
|
|
"stderr": stderr.decode(errors="replace")[:10000],
|
|
|
|
|
|
"exit_code": exit_code,
|
|
|
|
|
|
"pid": proc.pid,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 00:37:39 +08:00
|
|
|
|
@app.post("/exec")
|
2026-05-24 16:26:40 +08:00
|
|
|
|
async def exec_command(payload: dict, _auth=Depends(verify_api_key)):
|
2026-05-23 15:26:56 +08:00
|
|
|
|
"""Execute a shell command — wait=true (default) blocks; wait=false returns pid for /exec/wait."""
|
2026-05-22 00:37:39 +08:00
|
|
|
|
|
|
|
|
|
|
command = payload.get("command", "")
|
|
|
|
|
|
timeout = payload.get("timeout", 30)
|
|
|
|
|
|
sudo = payload.get("sudo", False)
|
2026-05-23 15:26:56 +08:00
|
|
|
|
wait = payload.get("wait", True)
|
2026-05-22 00:37:39 +08:00
|
|
|
|
|
|
|
|
|
|
if not command:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="command is required")
|
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Shell exec via subprocess_shell (server_id=%s, wait=%s, len=%s)",
|
|
|
|
|
|
SERVER_ID,
|
|
|
|
|
|
wait,
|
|
|
|
|
|
len(command),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-22 00:37:39 +08:00
|
|
|
|
if sudo:
|
2026-05-23 15:26:56 +08:00
|
|
|
|
command = f"sudo -n sh -c {shlex.quote(command)}"
|
2026-05-22 00:37:39 +08:00
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
|
proc = None
|
2026-05-22 00:37:39 +08:00
|
|
|
|
try:
|
|
|
|
|
|
proc = await asyncio.create_subprocess_shell(
|
|
|
|
|
|
command,
|
|
|
|
|
|
stdout=asyncio.subprocess.PIPE,
|
|
|
|
|
|
stderr=asyncio.subprocess.PIPE,
|
2026-05-23 15:26:56 +08:00
|
|
|
|
start_new_session=True,
|
2026-05-22 00:37:39 +08:00
|
|
|
|
)
|
2026-05-23 15:26:56 +08:00
|
|
|
|
_running_procs[proc.pid] = proc
|
2026-05-22 00:37:39 +08:00
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
|
if not wait:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "started",
|
|
|
|
|
|
"pid": proc.pid,
|
|
|
|
|
|
"stdout": "",
|
|
|
|
|
|
"stderr": "",
|
|
|
|
|
|
"exit_code": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
|
|
|
|
_running_procs.pop(proc.pid, None)
|
|
|
|
|
|
return _proc_result(proc, stdout, stderr)
|
2026-05-22 00:37:39 +08:00
|
|
|
|
except asyncio.TimeoutError:
|
2026-05-23 15:26:56 +08:00
|
|
|
|
if proc:
|
|
|
|
|
|
proc.kill()
|
|
|
|
|
|
_running_procs.pop(proc.pid, None)
|
2026-05-20 17:24:21 +08:00
|
|
|
|
return {
|
2026-05-22 00:37:39 +08:00
|
|
|
|
"status": "timeout",
|
|
|
|
|
|
"stdout": "",
|
|
|
|
|
|
"stderr": f"Command timed out after {timeout}s",
|
|
|
|
|
|
"exit_code": -1,
|
2026-05-23 15:26:56 +08:00
|
|
|
|
"pid": proc.pid if proc else None,
|
2026-05-20 17:24:21 +08:00
|
|
|
|
}
|
2026-05-22 00:37:39 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return {"status": "error", "stdout": "", "stderr": str(e), "exit_code": -1}
|
|
|
|
|
|
|
2026-05-20 17:24:21 +08:00
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
|
@app.post("/exec/kill")
|
2026-05-24 16:26:40 +08:00
|
|
|
|
async def exec_kill(payload: dict, _auth=Depends(verify_api_key)):
|
2026-05-23 15:26:56 +08:00
|
|
|
|
"""Kill a process started with wait=false."""
|
|
|
|
|
|
pid = int(payload.get("pid", 0))
|
|
|
|
|
|
if pid <= 0:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="pid required")
|
|
|
|
|
|
|
|
|
|
|
|
proc = _running_procs.pop(pid, None)
|
|
|
|
|
|
if proc and proc.returncode is None:
|
|
|
|
|
|
proc.kill()
|
|
|
|
|
|
try:
|
|
|
|
|
|
await proc.wait()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return {"status": "killed", "pid": pid}
|
|
|
|
|
|
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=404,
|
|
|
|
|
|
detail="process not found or not started by this agent",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/exec/wait")
|
2026-05-24 16:26:40 +08:00
|
|
|
|
async def exec_wait(payload: dict, _auth=Depends(verify_api_key)):
|
2026-05-23 15:26:56 +08:00
|
|
|
|
"""Wait for a process started with wait=false."""
|
|
|
|
|
|
pid = int(payload.get("pid", 0))
|
|
|
|
|
|
timeout = int(payload.get("timeout", 60))
|
|
|
|
|
|
if pid <= 0:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="pid required")
|
|
|
|
|
|
|
|
|
|
|
|
proc = _running_procs.get(pid)
|
|
|
|
|
|
if not proc:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="unknown pid (finished or invalid)")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
|
|
|
|
_running_procs.pop(pid, None)
|
|
|
|
|
|
return _proc_result(proc, stdout, stderr)
|
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
|
_running_procs.pop(pid, None)
|
|
|
|
|
|
if proc.returncode is None:
|
|
|
|
|
|
proc.kill()
|
|
|
|
|
|
try:
|
|
|
|
|
|
await proc.wait()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "timeout",
|
|
|
|
|
|
"stdout": "",
|
|
|
|
|
|
"stderr": f"Command timed out after {timeout}s",
|
|
|
|
|
|
"exit_code": -1,
|
|
|
|
|
|
"pid": pid,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 00:37:39 +08:00
|
|
|
|
# --- Config Reload ---
|
2026-05-20 17:24:21 +08:00
|
|
|
|
|
|
|
|
|
|
@app.post("/config/reload")
|
2026-05-24 16:26:40 +08:00
|
|
|
|
async def reload_config(data: dict, _auth=Depends(verify_api_key)):
|
2026-05-22 00:37:39 +08:00
|
|
|
|
"""Nexus pushes new config — update config.json and reload"""
|
2026-05-20 17:24:21 +08:00
|
|
|
|
try:
|
2026-05-22 08:19:56 +08:00
|
|
|
|
now = datetime.now(timezone.utc).isoformat()
|
2026-05-20 17:24:21 +08:00
|
|
|
|
data["updated_at"] = now
|
2026-05-22 00:37:39 +08:00
|
|
|
|
data["updated_by"] = "nexus"
|
2026-06-04 14:01:14 +08:00
|
|
|
|
with open(CONFIG_PATH, "w", encoding="utf-8", newline="\n") as f:
|
|
|
|
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
|
|
|
|
f.write("\n")
|
2026-05-22 00:37:39 +08:00
|
|
|
|
|
|
|
|
|
|
global SERVER_ID, CENTRAL_URL, CENTRAL_API_KEY, config
|
2026-05-20 17:24:21 +08:00
|
|
|
|
config = data
|
2026-05-22 00:37:39 +08:00
|
|
|
|
SERVER_ID = config.get("server_id", SERVER_ID)
|
|
|
|
|
|
CENTRAL_URL = config.get("central", {}).get("url", CENTRAL_URL)
|
|
|
|
|
|
CENTRAL_API_KEY = config.get("central", {}).get("api_key", CENTRAL_API_KEY)
|
|
|
|
|
|
|
2026-06-08 23:05:47 +08:00
|
|
|
|
if CENTRAL_URL and SERVER_ID and CENTRAL_API_KEY:
|
|
|
|
|
|
_start_heartbeat_task()
|
|
|
|
|
|
logger.info("Heartbeat task (re)started after config reload")
|
|
|
|
|
|
|
2026-05-22 00:37:39 +08:00
|
|
|
|
return {"status": "ok", "message": "Config updated", "updated_at": now}
|
2026-05-20 17:24:21 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Heartbeat to Central Server ---
|
|
|
|
|
|
|
|
|
|
|
|
_last_metrics = {}
|
|
|
|
|
|
_last_full_sync = 0
|
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
|
|
2026-05-27 02:17:35 +08:00
|
|
|
|
def _os_release() -> str:
|
|
|
|
|
|
"""Read PRETTY_NAME from /etc/os-release (e.g. 'Ubuntu 24.04 LTS')."""
|
|
|
|
|
|
try:
|
2026-06-04 14:01:14 +08:00
|
|
|
|
path = Path("/etc/os-release")
|
|
|
|
|
|
if path.exists():
|
|
|
|
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
2026-05-27 02:17:35 +08:00
|
|
|
|
if line.startswith("PRETTY_NAME="):
|
|
|
|
|
|
return line.split("=", 1)[1].strip().strip('"')
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
|
def _alert_thresholds() -> dict:
|
|
|
|
|
|
"""Align with Nexus center defaults (config.py / settings table). Override in config.json if needed."""
|
|
|
|
|
|
t = config.get("alert_thresholds") or {}
|
|
|
|
|
|
return {
|
|
|
|
|
|
"cpu": float(t.get("cpu", 80)),
|
|
|
|
|
|
"mem": float(t.get("mem", 80)),
|
|
|
|
|
|
"disk": float(t.get("disk", 80)),
|
|
|
|
|
|
}
|
2026-05-20 17:24:21 +08:00
|
|
|
|
|
2026-05-22 00:37:39 +08:00
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
|
def _metrics_over_threshold(cpu: float, mem: float, disk: float) -> bool:
|
|
|
|
|
|
"""Same rule as center _detect_alerts: strictly greater than threshold."""
|
|
|
|
|
|
th = _alert_thresholds()
|
|
|
|
|
|
return cpu > th["cpu"] or mem > th["mem"] or disk > th["disk"]
|
2026-05-20 17:24:21 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def send_heartbeat():
|
2026-05-30 18:20:14 +08:00
|
|
|
|
"""Send heartbeat to Nexus central — smart: change >10%, over threshold, or every 10min.
|
|
|
|
|
|
Uses exponential backoff on consecutive failures (max 5 min)."""
|
2026-05-23 15:26:56 +08:00
|
|
|
|
global _last_metrics, _last_full_sync
|
2026-05-30 18:20:14 +08:00
|
|
|
|
_consecutive_failures = 0
|
2026-05-20 17:24:21 +08:00
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
2026-05-22 00:37:39 +08:00
|
|
|
|
if not CENTRAL_URL or not SERVER_ID:
|
|
|
|
|
|
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
cpu = psutil.cpu_percent()
|
|
|
|
|
|
mem = psutil.virtual_memory().percent
|
|
|
|
|
|
disk = psutil.disk_usage("/").percent
|
|
|
|
|
|
|
|
|
|
|
|
now_ts = time.time()
|
|
|
|
|
|
changed = (
|
|
|
|
|
|
abs(cpu - _last_metrics.get("cpu", 0)) >= 10 or
|
|
|
|
|
|
abs(mem - _last_metrics.get("memory", 0)) >= 10 or
|
|
|
|
|
|
abs(disk - _last_metrics.get("disk", 0)) >= 10
|
|
|
|
|
|
)
|
|
|
|
|
|
force_sync = (now_ts - _last_full_sync) > 600
|
2026-05-23 15:26:56 +08:00
|
|
|
|
over_threshold = _metrics_over_threshold(cpu, mem, disk)
|
2026-05-22 00:37:39 +08:00
|
|
|
|
|
2026-05-23 15:26:56 +08:00
|
|
|
|
if changed or force_sync or over_threshold:
|
2026-05-22 00:37:39 +08:00
|
|
|
|
_last_metrics = {"cpu": cpu, "memory": mem, "disk": disk}
|
|
|
|
|
|
if force_sync:
|
|
|
|
|
|
_last_full_sync = now_ts
|
|
|
|
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"server_id": SERVER_ID,
|
|
|
|
|
|
"is_online": True,
|
2026-06-08 23:05:47 +08:00
|
|
|
|
"agent_version": AGENT_VERSION,
|
2026-05-22 00:37:39 +08:00
|
|
|
|
"system_info": {
|
|
|
|
|
|
"cpu_usage": cpu,
|
|
|
|
|
|
"mem_usage": mem,
|
|
|
|
|
|
"disk_usage": disk,
|
|
|
|
|
|
"hostname": platform.node(),
|
|
|
|
|
|
"platform": platform.platform(),
|
2026-05-27 02:17:35 +08:00
|
|
|
|
"os_release": _os_release(),
|
2026-05-23 17:04:34 +08:00
|
|
|
|
# agent_time: central uses this to detect clock drift
|
|
|
|
|
|
"agent_time": datetime.now(timezone.utc).isoformat(),
|
2026-05-22 00:37:39 +08:00
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
|
f"{CENTRAL_URL}/api/agent/heartbeat",
|
|
|
|
|
|
json=payload,
|
|
|
|
|
|
headers={"X-API-Key": CENTRAL_API_KEY},
|
|
|
|
|
|
)
|
2026-06-08 23:05:47 +08:00
|
|
|
|
body = resp.json() if resp.status_code == 200 else None
|
|
|
|
|
|
if heartbeat_should_stop(resp.status_code, body):
|
|
|
|
|
|
if resp.status_code == 401:
|
|
|
|
|
|
logger.error(
|
|
|
|
|
|
"Heartbeat rejected by central (401): invalid API key or "
|
|
|
|
|
|
"server_id=%s not configured with per-server agent_api_key. "
|
|
|
|
|
|
"Stopping heartbeat — reconfigure agent key via admin UI.",
|
|
|
|
|
|
SERVER_ID,
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
reason = (body or {}).get("reason", "unknown")
|
2026-05-30 18:20:14 +08:00
|
|
|
|
logger.error(
|
|
|
|
|
|
"Heartbeat discarded by central: %s. "
|
|
|
|
|
|
"This agent may be misconfigured (wrong server_id=%s). "
|
|
|
|
|
|
"Stopping heartbeat to prevent noise.",
|
2026-06-08 23:05:47 +08:00
|
|
|
|
reason,
|
|
|
|
|
|
SERVER_ID,
|
2026-05-30 18:20:14 +08:00
|
|
|
|
)
|
2026-06-08 23:05:47 +08:00
|
|
|
|
return
|
|
|
|
|
|
if resp.status_code == 200:
|
2026-05-30 18:20:14 +08:00
|
|
|
|
_consecutive_failures = 0
|
2026-05-22 00:37:39 +08:00
|
|
|
|
logger.debug("Heartbeat sent")
|
|
|
|
|
|
else:
|
2026-05-30 18:20:14 +08:00
|
|
|
|
_consecutive_failures += 1
|
2026-05-22 00:37:39 +08:00
|
|
|
|
logger.warning(f"Heartbeat failed: {resp.status_code}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.debug("Metrics unchanged, skip heartbeat")
|
|
|
|
|
|
|
2026-05-20 17:24:21 +08:00
|
|
|
|
except Exception as e:
|
2026-05-30 18:20:14 +08:00
|
|
|
|
_consecutive_failures += 1
|
2026-05-20 17:24:21 +08:00
|
|
|
|
logger.warning(f"Heartbeat error: {e}")
|
|
|
|
|
|
|
2026-05-30 18:20:14 +08:00
|
|
|
|
# Exponential backoff: base interval × 2^min(failures, 5), capped at 5 min
|
|
|
|
|
|
if _consecutive_failures > 0:
|
|
|
|
|
|
backoff = min(HEARTBEAT_INTERVAL * (2 ** min(_consecutive_failures, 5)), 300)
|
|
|
|
|
|
logger.info(f"Backing off {backoff}s after {_consecutive_failures} consecutive failures")
|
|
|
|
|
|
await asyncio.sleep(backoff)
|
|
|
|
|
|
else:
|
|
|
|
|
|
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
2026-05-20 17:24:21 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2026-05-23 15:26:56 +08:00
|
|
|
|
host = config.get("server", {}).get("host", "127.0.0.1")
|
2026-05-22 00:37:39 +08:00
|
|
|
|
port = config.get("server", {}).get("port", 8601)
|
2026-05-20 17:24:21 +08:00
|
|
|
|
import uvicorn
|
|
|
|
|
|
uvicorn.run(app, host=host, port=port)
|