#!/bin/bash
# RootManage Linux agent — health heartbeat + daily hardening/CVE watch.
# Ubuntu, Debian, RHEL, CloudLinux, AlmaLinux, Rocky. Hardening + package engines if install succeeds.
# Not a DDoS shield. Not a full CVE encyclopedia of every app.
set -u

AGENT_VERSION="2.7.1"
ENV_FILE="${ROOTMANAGE_ENV:-/etc/rootmanage/agent.env}"
STATE_FILE="${ROOTMANAGE_STATE:-/var/lib/rootmanage/net.state}"
CPU_FILE="${ROOTMANAGE_CPU:-/var/lib/rootmanage/cpu.state}"
SCAN_FILE="${ROOTMANAGE_SCAN:-/var/lib/rootmanage/last-scan.json}"
LOG_FILE="${ROOTMANAGE_LOG:-/var/log/rootmanage-agent.log}"

load_env() {
  if [[ ! -f "$ENV_FILE" ]]; then
    echo "missing $ENV_FILE" >&2
    exit 1
  fi
  # shellcheck disable=SC1090
  . "$ENV_FILE"
  if [[ -z "${API_URL:-}" || -z "${ANON_KEY:-}" || -z "${AGENT_TOKEN:-}" ]]; then
    echo "API_URL, ANON_KEY, AGENT_TOKEN required in $ENV_FILE" >&2
    exit 1
  fi
}

num() { echo "${1:-0}" | tr -cd '0-9.'; }

json_esc() {
  echo "${1:-}" | sed 's/\\/\\\\/g; s/"/\\"/g'
}

# Top processes by CPU, for the "heavy process" panel and one-click kill.
# Cheap (single ps call), best-effort: if python3 is missing we just send "[]".
proc_snapshot() {
  command -v python3 >/dev/null 2>&1 || { echo "[]"; return 0; }
  ps -eo pid,user,pcpu,pmem,comm --no-headers --sort=-pcpu 2>/dev/null | head -8 | python3 -c '
import sys, json
rows = []
for line in sys.stdin:
    parts = line.split(None, 4)
    if len(parts) < 5:
        continue
    pid, user, cpu, mem, cmd = parts
    try:
        rows.append({
            "pid": int(pid),
            "user": user[:32],
            "cpu": round(float(cpu), 1),
            "mem": round(float(mem), 1),
            "cmd": cmd.strip()[:60],
        })
    except Exception:
        continue
print(json.dumps(rows, separators=(",", ":")))
' 2>/dev/null || echo "[]"
}

read_cpu_line() {
  # user nice system idle iowait irq softirq steal guest
  awk '/^cpu / {print $2+$3+$4+$5+$6+$7+$8+$9+$10, $5, $6}' /proc/stat
}

once() {
  load_env
  mkdir -p "$(dirname "$STATE_FILE")"
  local hostname kernel os_name uptime cores load1 load5 load15
  hostname="$(hostname 2>/dev/null || echo unknown)"
  kernel="$(uname -r 2>/dev/null || echo unknown)"
  os_name="Linux"
  if [[ -f /etc/os-release ]]; then
    # shellcheck disable=SC1091
    . /etc/os-release
    os_name="${PRETTY_NAME:-Linux}"
  fi
  uptime="$(cut -d. -f1 /proc/uptime 2>/dev/null || echo 0)"
  cores="$(nproc 2>/dev/null || echo 1)"
  read -r load1 load5 load15 _ < /proc/loadavg

  local mem_total mem_avail mem_used mem_pct
  mem_total="$(awk '/MemTotal:/ {print int($2/1024)}' /proc/meminfo)"
  mem_avail="$(awk '/MemAvailable:/ {print int($2/1024)}' /proc/meminfo)"
  mem_used=$((mem_total - mem_avail))
  mem_pct=0
  if [[ "$mem_total" -gt 0 ]]; then
    mem_pct=$((mem_used * 100 / mem_total))
  fi

  local swap_total swap_free swap_used swap_pct
  swap_total="$(awk '/SwapTotal:/ {print int($2/1024)}' /proc/meminfo)"
  swap_free="$(awk '/SwapFree:/ {print int($2/1024)}' /proc/meminfo)"
  swap_used=$((swap_total - swap_free))
  swap_pct=0
  if [[ "$swap_total" -gt 0 ]]; then
    swap_pct=$((swap_used * 100 / swap_total))
  fi

  local disk_pct disk_total_mb disk_used_mb inode_pct
  disk_pct="$(df -P / 2>/dev/null | awk 'NR==2 {gsub(/%/,"",$5); print $5}')"
  disk_pct="$(num "$disk_pct")"
  disk_total_mb="$(df -P / 2>/dev/null | awk 'NR==2 {print int($2/1024)}')"
  disk_used_mb="$(df -P / 2>/dev/null | awk 'NR==2 {print int($3/1024)}')"
  inode_pct="$(df -iP / 2>/dev/null | awk 'NR==2 {gsub(/%/,"",$5); print $5}')"
  inode_pct="$(num "${inode_pct:-0}")"

  local cpu_pct iowait_pct
  cpu_pct=0
  iowait_pct=0
  local cpu_now idle_now wait_now
  read -r cpu_now idle_now wait_now < <(read_cpu_line)
  if [[ -f "$CPU_FILE" ]]; then
    local prev_t prev_idle prev_wait dt_c didle dwait
    read -r prev_t prev_idle prev_wait < "$CPU_FILE" || true
    dt_c=$((cpu_now - prev_t))
    if [[ "$dt_c" -gt 0 ]]; then
      didle=$((idle_now - prev_idle))
      dwait=$((wait_now - prev_wait))
      cpu_pct=$(( (dt_c - didle) * 100 / dt_c ))
      iowait_pct=$(( dwait * 100 / dt_c ))
      [[ "$cpu_pct" -lt 0 ]] && cpu_pct=0
      [[ "$cpu_pct" -gt 100 ]] && cpu_pct=100
      [[ "$iowait_pct" -lt 0 ]] && iowait_pct=0
      [[ "$iowait_pct" -gt 100 ]] && iowait_pct=100
    fi
  fi
  echo "$cpu_now $idle_now $wait_now" > "$CPU_FILE"

  local rx_now tx_now now rx_bps tx_bps
  rx_now=0
  tx_now=0
  now="$(date +%s)"
  while read -r iface; do
    [[ "$iface" == "lo" ]] && continue
    local rx tx
    rx="$(cat "/sys/class/net/$iface/statistics/rx_bytes" 2>/dev/null || echo 0)"
    tx="$(cat "/sys/class/net/$iface/statistics/tx_bytes" 2>/dev/null || echo 0)"
    rx_now=$((rx_now + rx))
    tx_now=$((tx_now + tx))
  done < <(ls /sys/class/net 2>/dev/null)
  rx_bps=0
  tx_bps=0
  if [[ -f "$STATE_FILE" ]]; then
    local prev_t prev_rx prev_tx dt
    read -r prev_t prev_rx prev_tx < "$STATE_FILE" || true
    dt=$((now - prev_t))
    if [[ "$dt" -gt 0 && "$dt" -lt 3600 ]]; then
      rx_bps=$(( (rx_now - prev_rx) / dt ))
      tx_bps=$(( (tx_now - prev_tx) / dt ))
      [[ "$rx_bps" -lt 0 ]] && rx_bps=0
      [[ "$tx_bps" -lt 0 ]] && tx_bps=0
    fi
  fi
  echo "$now $rx_now $tx_now" > "$STATE_FILE"

  local syn_recv tcp_estab tcp_timewait listen_ports ssh_fail proc_count failed_units reboot_required
  syn_recv="$(ss -tan 2>/dev/null | grep -c SYN-RECV || true)"
  syn_recv="$(num "${syn_recv:-0}")"
  tcp_estab="$(ss -H state established 2>/dev/null | wc -l | tr -cd '0-9')"
  tcp_timewait="$(ss -H state time-wait 2>/dev/null | wc -l | tr -cd '0-9')"
  listen_ports="$(ss -tlnH 2>/dev/null | wc -l | tr -cd '0-9')"
  proc_count="$(ps -e --no-headers 2>/dev/null | wc -l | tr -cd '0-9')"
  failed_units=0
  if command -v systemctl >/dev/null 2>&1; then
    failed_units="$(systemctl --failed --no-legend --no-pager 2>/dev/null | wc -l | tr -cd '0-9')"
  fi
  reboot_required=0
  [[ -f /var/run/reboot-required ]] && reboot_required=1

  ssh_fail=0
  if command -v journalctl >/dev/null 2>&1; then
    ssh_fail="$(journalctl -u ssh -u sshd --since '1 hour ago' --no-pager 2>/dev/null | grep -ciE 'Failed password|Invalid user' || true)"
  fi
  ssh_fail="$(num "${ssh_fail:-0}")"

  local updates_total security_updates
  updates_total=0
  security_updates=0
  PKG_CACHE="/var/lib/rootmanage/pkg.cache"
  APT_STAMP="/var/lib/rootmanage/apt.last"
  local reuse=0
  if [[ -f "$PKG_CACHE" ]]; then
    local prev_pkg prev_u prev_s
    read -r prev_pkg prev_u prev_s < "$PKG_CACHE" || true
    if [[ -n "${prev_pkg:-}" && $((now - prev_pkg)) -lt 21600 ]]; then
      updates_total="$(num "$prev_u")"
      security_updates="$(num "$prev_s")"
      reuse=1
    fi
  fi
  if [[ "$reuse" -eq 0 ]]; then
    if command -v apt-get >/dev/null 2>&1; then
      local do_update=1
      if [[ -f "$APT_STAMP" ]]; then
        local last_apt
        last_apt="$(cat "$APT_STAMP" 2>/dev/null || echo 0)"
        if [[ $((now - last_apt)) -lt 21600 ]]; then
          do_update=0
        fi
      fi
      if [[ "$do_update" -eq 1 ]]; then
        timeout 45 apt-get update -qq >/dev/null 2>&1 || true
        echo "$now" > "$APT_STAMP"
      fi
      local sim
      sim="$(timeout 30 apt-get -s upgrade 2>/dev/null || true)"
      updates_total="$(echo "$sim" | grep -c '^Inst ' || true)"
      security_updates="$(echo "$sim" | grep '^Inst ' | grep -ciE 'security' || true)"
    elif command -v dnf >/dev/null 2>&1; then
      timeout 45 dnf -q makecache >/dev/null 2>&1 || true
      updates_total="$(timeout 40 dnf -q check-update 2>/dev/null | awk 'NF && $1 !~ /^Obsoleting|^Last/ {c++} END {print c+0}')"
      security_updates="$(timeout 40 dnf -q updateinfo --security list 2>/dev/null | awk 'NF && $1 !~ /^Last|^Updating/ {c++} END {print c+0}')"
    elif command -v yum >/dev/null 2>&1; then
      updates_total="$(timeout 40 yum -q check-update 2>/dev/null | awk 'NF && $1 !~ /^Obsoleting|^Loaded/ {c++} END {print c+0}')"
      security_updates="$(timeout 40 yum -q updateinfo --security list 2>/dev/null | awk 'NF {c++} END {print c+0}')"
    fi
    updates_total="$(num "${updates_total:-0}")"
    security_updates="$(num "${security_updates:-0}")"
    echo "$now $updates_total $security_updates" > "$PKG_CACHE"
  fi

  local ip_addr
  ip_addr="$(hostname -I 2>/dev/null | awk '{print $1}')"

  local h_esc k_esc o_esc
  h_esc="$(json_esc "$hostname")"
  k_esc="$(json_esc "$kernel")"
  o_esc="$(json_esc "$os_name")"

  local top_procs
  top_procs="$(proc_snapshot)"
  [[ -z "$top_procs" ]] && top_procs="[]"

  local payload
  payload=$(cat <<EOF
{"hostname":"$h_esc","kernel":"$k_esc","os_name":"$o_esc","uptime_sec":$(num "$uptime"),"cpu_cores":$(num "$cores"),"cpu_pct":$(num "$cpu_pct"),"iowait_pct":$(num "$iowait_pct"),"load1":$(num "$load1"),"load5":$(num "$load5"),"load15":$(num "$load15"),"mem_total_mb":$(num "$mem_total"),"mem_used_mb":$(num "$mem_used"),"mem_pct":$(num "$mem_pct"),"swap_total_mb":$(num "$swap_total"),"swap_used_mb":$(num "$swap_used"),"swap_pct":$(num "$swap_pct"),"disk_used_pct":$(num "$disk_pct"),"disk_total_mb":$(num "$disk_total_mb"),"disk_used_mb":$(num "$disk_used_mb"),"inode_pct":$(num "$inode_pct"),"rx_bps":$(num "$rx_bps"),"tx_bps":$(num "$tx_bps"),"syn_recv":$(num "$syn_recv"),"tcp_estab":$(num "${tcp_estab:-0}"),"tcp_timewait":$(num "${tcp_timewait:-0}"),"listen_ports":$(num "${listen_ports:-0}"),"proc_count":$(num "${proc_count:-0}"),"failed_units":$(num "${failed_units:-0}"),"reboot_required":$(num "$reboot_required"),"ssh_fail_1h":$(num "$ssh_fail"),"updates_total":$(num "$updates_total"),"security_updates":$(num "$security_updates"),"ip":"${ip_addr:-}","collected_at":"$now","agent_version":"$AGENT_VERSION","top_procs":$top_procs}
EOF
)

  if [[ -f "$SCAN_FILE" ]] && command -v python3 >/dev/null 2>&1; then
    echo "$payload" >/var/lib/rootmanage/hb.json
    payload="$(python3 -c 'import json,sys; a=json.load(open(sys.argv[1])); b=json.load(open(sys.argv[2])); a.update(b); print(json.dumps(a,separators=(",",":")))' /var/lib/rootmanage/hb.json "$SCAN_FILE")"
  fi

  local body
  body=$(printf '{"p_token":"%s","p_payload":%s}' "$AGENT_TOKEN" "$payload")
  local resp
  resp="$(curl -sS -m 25 -X POST "${API_URL%/}/rest/v1/rpc/report_server_heartbeat" \
    -H "apikey: ${ANON_KEY}" \
    -H "Authorization: Bearer ${ANON_KEY}" \
    -H "Content-Type: application/json" \
    -d "$body" 2>/tmp/rootmanage-agent.err || true)"
  echo "$resp" >/tmp/rootmanage-agent.last
  if echo "$resp" | grep -q '"reason":"inactive"'; then
    echo "$(date -u +%FT%TZ) UNAUTHORIZED_OR_INACTIVE_SUBSCRIPTION" >>"$LOG_FILE"
    return 8
  fi
  if [[ "${PRINT_WELCOME:-}" == "1" ]]; then
    python3 - <<'PY'
import json
try:
    d = json.load(open("/tmp/rootmanage-agent.last"))
except Exception:
    raise SystemExit(0)
if isinstance(d, str):
    try:
        d = json.loads(d)
    except Exception:
        raise SystemExit(0)
if not isinstance(d, dict) or not d.get("ok"):
    raise SystemExit(0)
brand = d.get("company") or d.get("brand") or "RootManage"
site = (d.get("site_url") or "https://rootmanage.com").replace("https://", "")
email = d.get("support_email") or "adzcityinfo@gmail.com"
print("")
print("================================================")
print("  %s" % brand)
print("  Portal : %s" % site)
print("  Support: %s" % email)
print("  Server : %s  %s" % (d.get("server_label") or "", d.get("server_ip") or ""))
print("  Status : %s" % (d.get("status") or ""))
print("  Expires: %s" % (d.get("renewal_date") or "not set"))
print("================================================")
print("  Open Server Health on the portal to see this with full style.")
print("")
PY
  fi
  export API_URL ANON_KEY AGENT_TOKEN
  run_dashboard_jobs || true
  return 0
}

run_dashboard_jobs() {
  [[ "${SKIP_JOBS:-}" == "1" ]] && return 0
  export API_URL ANON_KEY AGENT_TOKEN
  command -v python3 >/dev/null 2>&1 || return 0
  local raw
  raw="$(curl -sS -m 20 -X POST "${API_URL%/}/rest/v1/rpc/claim_agent_jobs" \
    -H "apikey: ${ANON_KEY}" \
    -H "Authorization: Bearer ${ANON_KEY}" \
    -H "Content-Type: application/json" \
    -d "{\"p_token\":\"${AGENT_TOKEN}\"}" 2>/dev/null || true)"
  [[ -z "$raw" ]] && return 0
  python3 - "$raw" <<'PY' >/tmp/rootmanage-jobs.list 2>/dev/null || true
import base64,json,sys
raw=sys.argv[1]
try:
    d=json.loads(raw)
except Exception:
    raise SystemExit(0)
if isinstance(d,str):
    try: d=json.loads(d)
    except Exception: raise SystemExit(0)
jobs=(d or {}).get("jobs") or []
for j in jobs:
    if isinstance(j,dict) and j.get("id") and j.get("kind"):
        payload = j.get("payload") or {}
        b64 = base64.b64encode(json.dumps(payload).encode()).decode()
        print("%s %s %s" % (j["id"], j["kind"], b64))
PY
  [[ -s /tmp/rootmanage-jobs.list ]] || return 0
  local id kind payload_b64 log rc
  while read -r id kind payload_b64; do
    [[ -z "$id" || -z "$kind" ]] && continue
    log="/tmp/rootmanage-job-${id}.log"
    rc=1
    set +e
    case "$kind" in
      health)
        SKIP_JOBS=1 timeout 90 nice -n 19 /usr/local/bin/rootmanage-agent --once >"$log" 2>&1
        rc=$?
        ;;
      scan)
        SKIP_JOBS=1 timeout 420 nice -n 19 ionice -c3 /usr/local/bin/rootmanage-agent --scan >"$log" 2>&1
        rc=$?
        ;;
      backup)
        if [[ -x /usr/local/bin/rootmanage-backup ]]; then
          timeout 600 nice -n 19 ionice -c3 /usr/local/bin/rootmanage-backup --run >"$log" 2>&1
          rc=$?
        else
          echo "Backup agent not installed on this server." >"$log"
          rc=1
        fi
        ;;
      metrics)
        if [[ -x /usr/local/bin/rootmanage-metrics ]]; then
          timeout 60 nice -n 19 ionice -c3 /usr/local/bin/rootmanage-metrics --once >"$log" 2>&1
          rc=$?
        else
          echo "Metrics agent not installed on this server." >"$log"
          rc=1
        fi
        ;;
      litehouse)
        if [[ -x /usr/local/bin/rootmanage-litehouse ]]; then
          timeout 180 nice -n 19 ionice -c3 /usr/local/bin/rootmanage-litehouse --run >"$log" 2>&1
          rc=$?
        else
          echo "LiteHouse agent not installed on this server." >"$log"
          rc=1
        fi
        ;;
      clean)
        if [[ -x /usr/local/bin/rootmanage-clean ]]; then
          timeout 7200 nice -n 19 ionice -c3 /usr/local/bin/rootmanage-clean --run >"$log" 2>&1
          rc=$?
        else
          echo "Site Clean agent not installed on this server." >"$log"
          rc=1
        fi
        ;;
      clean_quarantine|clean_delete|clean_suspend)
        if [[ -x /usr/local/bin/rootmanage-clean ]]; then
          timeout 60 nice -n 19 ionice -c3 /usr/local/bin/rootmanage-clean --job "$kind" --payload-b64 "$payload_b64" >"$log" 2>&1
          rc=$?
        else
          echo "Site Clean agent not installed on this server." >"$log"
          rc=1
        fi
        ;;
      kill_process)
        # Admin-only, enforced server-side before this job is ever created.
        local kp_pid kp_comm
        kp_pid="$(echo "$payload_b64" | base64 -d 2>/dev/null | python3 -c '
import json, sys
try:
    d = json.load(sys.stdin)
    print(int(d.get("pid", 0)))
except Exception:
    print(0)
' 2>/dev/null || echo 0)"
        kp_comm=""
        [[ -r "/proc/$kp_pid/comm" ]] && kp_comm="$(cat "/proc/$kp_pid/comm" 2>/dev/null)"
        if ! [[ "$kp_pid" =~ ^[0-9]+$ ]] || [[ "$kp_pid" -le 1 ]] || [[ "$kp_pid" -eq "$$" ]] || [[ "$kp_pid" -eq "$PPID" ]]; then
          echo "Refused: missing or unsafe PID ($kp_pid)." >"$log"
          rc=1
        elif [[ "$kp_comm" =~ ^(init|systemd|sshd|rootmanage-agent)$ ]]; then
          echo "Refused: PID $kp_pid is a protected process ($kp_comm)." >"$log"
          rc=1
        elif ! kill -0 "$kp_pid" 2>/dev/null; then
          echo "PID $kp_pid was not running (already exited)." >"$log"
          rc=0
        else
          kill -TERM "$kp_pid" 2>/dev/null
          sleep 2
          if kill -0 "$kp_pid" 2>/dev/null; then
            kill -KILL "$kp_pid" 2>/dev/null
            sleep 1
          fi
          if kill -0 "$kp_pid" 2>/dev/null; then
            echo "PID $kp_pid ($kp_comm) still running after TERM+KILL." >"$log"
            rc=1
          else
            echo "PID $kp_pid ($kp_comm) terminated." >"$log"
            rc=0
          fi
        fi
        ;;
      *)
        echo "unknown kind $kind" >"$log"
        rc=1
        ;;
    esac
    set -u
    local tail pok
    tail="$(tail -c 11000 "$log" 2>/dev/null || true)"
    pok=false
    [[ "$rc" -eq 0 ]] && pok=true
    python3 - "$AGENT_TOKEN" "$id" "$pok" "$tail" <<'PY'
import json,os,sys,urllib.request
token,jid,pok,log=sys.argv[1],sys.argv[2],sys.argv[3]=="true",sys.argv[4]
api=os.environ.get("API_URL","").rstrip("/")
key=os.environ.get("ANON_KEY","")
body=json.dumps({"p_token":token,"p_job_id":jid,"p_ok":pok,"p_log":log[:12000]})
req=urllib.request.Request(api+"/rest/v1/rpc/report_agent_job",data=body.encode(),headers={"apikey":key,"Authorization":"Bearer "+key,"Content-Type":"application/json"},method="POST")
try:
    urllib.request.urlopen(req,timeout=20).read()
except Exception:
    pass
PY
  done < /tmp/rootmanage-jobs.list
}

loop() {
  while true; do
    once || true
    maybe_self_update || true
    sleep "${INTERVAL:-60}"
  done
}

throttle() {
  if command -v ionice >/dev/null 2>&1; then
    nice -n 19 ionice -c 3 "$@"
  else
    nice -n 19 "$@"
  fi
}

# Keep the person running --scan from getting CSF/Guard/fail2ban dropped.
protect_operator_ip() {
  local sip=""
  sip="$(echo "${SSH_CONNECTION:-${SSH_CLIENT:-}}" | awk '{print $1}')"
  if [[ ! "$sip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
    return 0
  fi
  mkdir -p /var/lib/rootmanage
  touch /var/lib/rootmanage/guard.allow
  grep -qxF "$sip" /var/lib/rootmanage/guard.allow 2>/dev/null || echo "$sip" >> /var/lib/rootmanage/guard.allow
  if command -v csf >/dev/null 2>&1; then
    csf -dr "$sip" >/dev/null 2>&1 || true
    csf -a "$sip" "RootManage operator scan" >/dev/null 2>&1 || true
  fi
  if command -v fail2ban-client >/dev/null 2>&1; then
    fail2ban-client set sshd unbanip "$sip" >/dev/null 2>&1 || true
  fi
  if [[ -x /usr/local/bin/rootmanage-guard ]]; then
    /usr/local/bin/rootmanage-guard --protect >/dev/null 2>&1 || true
  fi
  echo "$(date -u +%FT%TZ) operator_allow $sip" >>"$LOG_FILE"
}

detect_stacks() {
  local s=""
  [[ -x /usr/local/cpanel/cpanel || -d /usr/local/cpanel ]] && s="${s}cpanel,"
  [[ -x /usr/local/psa/bin/plesk || -d /usr/local/psa ]] && s="${s}plesk,"
  [[ -x /usr/local/lsws/bin/lshttpd || -d /usr/local/lsws ]] && s="${s}litespeed,"
  command -v imunify-agent >/dev/null 2>&1 || [[ -d /opt/imunify360 ]] && s="${s}imunify360,"
  [[ -d /usr/share/l.v.e-manager || -d /etc/cagefs ]] && s="${s}cloudlinux,"
  command -v jetbackup5api >/dev/null 2>&1 || [[ -d /usr/local/jetapps ]] && s="${s}jetbackup,"
  echo "${s%,}"
}

preflight() {
  load_env
  once
  local code=$?
  if [[ "$code" -eq 8 ]]; then
    echo "abort: inactive or unpaid server" >&2
    return 8
  fi
  return 0
}

self_update() {
  load_env
  mkdir -p /var/lib/rootmanage
  local url="${UPDATE_URL:-${API_URL%/}/agent/rootmanage-agent.sh}"
  if [[ -n "${GITHUB_RAW:-}" ]]; then
    url="$GITHUB_RAW"
  fi
  local tmp="/var/lib/rootmanage/agent.next"
  if ! throttle curl -fsSL -m 60 "$url" -o "$tmp"; then
    echo "$(date -u +%FT%TZ) update_failed url=$url" >>"$LOG_FILE"
    return 1
  fi
  if ! grep -q 'AGENT_VERSION=' "$tmp"; then
    echo "$(date -u +%FT%TZ) update_rejected (not an agent file)" >>"$LOG_FILE"
    rm -f "$tmp"
    return 1
  fi
  local remote_ver local_ver
  remote_ver="$(grep -m1 'AGENT_VERSION=' "$tmp" || true)"
  local_ver="$(grep -m1 'AGENT_VERSION=' /usr/local/bin/rootmanage-agent 2>/dev/null || true)"
  if [[ -n "$remote_ver" && "$remote_ver" == "$local_ver" ]]; then
    rm -f "$tmp"
    echo "$(date -u +%FT%TZ) self_update skip (same version)" >>"$LOG_FILE"
    return 0
  fi
  chmod 755 "$tmp"
  mv "$tmp" /usr/local/bin/rootmanage-agent
  ln -sf /usr/local/bin/rootmanage-agent /usr/bin/rootmanage-agent 2>/dev/null || true
  ln -sf /usr/local/bin/rootmanage-agent /usr/sbin/rootmanage-agent 2>/dev/null || true
  echo "$(date -u +%FT%TZ) self_update ok from $url" >>"$LOG_FILE"
  python3 - <<PY
import json, os, time
p="/var/lib/rootmanage/last-scan.json"
d={}
if os.path.isfile("$SCAN_FILE"):
    try:
        d=json.load(open("$SCAN_FILE"))
    except Exception:
        d={}
d["last_self_update"]=int(time.time())
d["agent_version"]="$AGENT_VERSION"
json.dump(d, open("$SCAN_FILE","w"), separators=(",",":"))
PY
  systemctl restart rootmanage-agent.service 2>/dev/null || true
}

# Quiet pull from rootmanage.com so the next client does not need a second curl.
maybe_self_update() {
  [[ "${PRINT_WELCOME:-}" == "1" ]] && return 0
  mkdir -p /var/lib/rootmanage
  local stamp="/var/lib/rootmanage/agent.update.stamp"
  local age=999999
  if [[ -f "$stamp" ]]; then
    age=$(( $(date +%s) - $(stat -c %Y "$stamp" 2>/dev/null || echo 0) ))
  fi
  if [[ "$age" -lt 3600 ]]; then
    return 0
  fi
  touch "$stamp"
  self_update || true
}

update_engines() {
  mkdir -p /opt/rootmanage /usr/local/bin /var/log /var/lib/rootmanage
  local stamp="/var/lib/rootmanage/engine.stamp"
  local need=0
  command -v lynis >/dev/null 2>&1 || [[ -x /opt/rootmanage/lynis/lynis ]] || need=1
  command -v trivy >/dev/null 2>&1 || need=1
  if [[ "$need" -eq 0 && -f "$stamp" ]]; then
    local age=999999
    age=$(( $(date +%s) - $(stat -c %Y "$stamp" 2>/dev/null || echo 0) ))
    if [[ "$age" -lt 604800 ]]; then
      echo "$(date -u +%FT%TZ) engine_update skip (fresh)" >>"$LOG_FILE"
      return 0
    fi
  fi
  echo "$(date -u +%FT%TZ) engine_update start" >>"$LOG_FILE"
  if ! command -v lynis >/dev/null 2>&1 && [[ ! -x /opt/rootmanage/lynis/lynis ]]; then
    if command -v apt-get >/dev/null 2>&1; then
      throttle timeout 90 apt-get update -qq >/dev/null 2>&1 || true
      throttle timeout 120 env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq lynis >/dev/null 2>&1 || true
    elif command -v dnf >/dev/null 2>&1; then
      throttle timeout 120 dnf install -y -q lynis >/dev/null 2>&1 || true
    elif command -v yum >/dev/null 2>&1; then
      throttle timeout 120 yum install -y -q lynis >/dev/null 2>&1 || true
    fi
  fi
  if ! command -v trivy >/dev/null 2>&1; then
    throttle timeout 120 sh -c 'curl -fsSL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin' >/dev/null 2>&1 || true
    ln -sf /usr/local/bin/trivy /usr/bin/trivy 2>/dev/null || true
  fi
  touch "$stamp"
  echo "$(date -u +%FT%TZ) engine_update lynis=$(command -v lynis 2>/dev/null || echo none) git_lynis=$([[ -x /opt/rootmanage/lynis/lynis ]] && echo yes || echo no) trivy=$(command -v trivy 2>/dev/null || echo none)" >>"$LOG_FILE"
}

write_scan() {
  load_env
  mkdir -p /var/lib/rootmanage /var/log
  local stacks scan_at pkg_kind
  stacks="$(detect_stacks)"
  scan_at="$(date +%s)"
  pkg_kind="apt"
  if command -v dnf >/dev/null 2>&1; then
    pkg_kind="dnf"
  elif command -v yum >/dev/null 2>&1; then
    pkg_kind="yum"
  fi
  local lynis_out="/var/lib/rootmanage/lynis.dat"
  mkdir -p /etc/rootmanage
  cat > /etc/rootmanage/lynis.prf <<'PRF'
# Do not probe gateway/LAN — that trip CSF, fail2ban, and the VPS router.
skip-test=NETW-2704
skip-test=NETW-2705
skip-test=NETW-3014
skip-test=NETW-3032
skip-test=NETW-3200
PRF
  local lynis_bin=""
  if [[ -x /opt/rootmanage/lynis/lynis ]]; then
    lynis_bin="/opt/rootmanage/lynis/lynis"
  elif command -v lynis >/dev/null 2>&1; then
    lynis_bin="$(command -v lynis)"
  fi
  if [[ -n "$lynis_bin" ]]; then
    throttle timeout 480 "$lynis_bin" audit system --quiet --no-colors \
      --profile /etc/rootmanage/lynis.prf \
      --skip-tests NETW-2704,NETW-2705,NETW-3014,NETW-3032,NETW-3200 \
      --report-file "$lynis_out" --logfile /var/lib/rootmanage/lynis.log >/dev/null 2>&1 || \
    throttle timeout 480 "$lynis_bin" audit system --quiet --no-colors \
      --report-file "$lynis_out" --logfile /var/lib/rootmanage/lynis.log >/dev/null 2>&1 || true
  fi
  if command -v trivy >/dev/null 2>&1; then
    local textra=()
    if [[ -d /root/.cache/trivy/db || -d /var/lib/rootmanage/trivy ]]; then
      textra=(--skip-db-update --offline-scan)
    fi
    throttle timeout 240 trivy rootfs --scanners vuln --severity CRITICAL,HIGH,MEDIUM \
      --pkg-types os --quiet --format json --timeout 3m "${textra[@]}" \
      -o /var/lib/rootmanage/trivy.json / >/dev/null 2>&1 || \
    throttle timeout 240 trivy fs --scanners vuln --severity CRITICAL,HIGH,MEDIUM \
      --pkg-types os --quiet --format json --timeout 3m "${textra[@]}" \
      -o /var/lib/rootmanage/trivy.json / >/dev/null 2>&1 || true
  fi
  local sshd="/etc/ssh/sshd_config"
  python3 - "$stacks" "$scan_at" "$AGENT_VERSION" "$SCAN_FILE" "$sshd" "$pkg_kind" <<'PY'
import json, os, re, shutil, sys
stacks, scan_at, ver, out, sshd, pkg_kind = sys.argv[1:7]
findings = []
LYNIS_DAT = "/var/lib/rootmanage/lynis.dat"
TRIVY_JSON = "/var/lib/rootmanage/trivy.json"

SOLVE = {
  "SSH-7408": "sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config && (systemctl reload sshd || systemctl reload ssh)",
  "SSH-7404": "sed -i 's/^#\\?Protocol.*/Protocol 2/' /etc/ssh/sshd_config && (systemctl reload sshd || systemctl reload ssh)",
  "SSH-7440": "sed -i 's/^#\\?MaxAuthTries.*/MaxAuthTries 3/' /etc/ssh/sshd_config && (systemctl reload sshd || systemctl reload ssh)",
  "SSH-7464": "sed -i 's/^#\\?PermitEmptyPasswords.*/PermitEmptyPasswords no/' /etc/ssh/sshd_config && (systemctl reload sshd || systemctl reload ssh)",
  "SSH-001": "sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config && (systemctl reload sshd || systemctl reload ssh)",
  "SSH-002": "sed -i 's/^#\\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config && (systemctl reload sshd || systemctl reload ssh)",
  "FILE-6310": "grep -qE '^[^#].*\\s/tmp\\s' /etc/fstab || echo 'tmpfs /tmp tmpfs defaults,noexec,nosuid,nodev 0 0' >> /etc/fstab; mount -o remount /tmp",
  "AUTH-9262": "sed -i 's/^ENCRYPT_METHOD.*/ENCRYPT_METHOD SHA512/' /etc/login.defs",
  "NET-001": "command -v ufw >/dev/null && ufw --force enable || (command -v firewall-cmd && systemctl enable --now firewalld)",
  "OS-001": "shutdown -r +5 'RootManage: reboot to finish kernel patches'",
  "APP-001": "echo 'WHM: Home → Update Preferences / cPanel Update; then /scripts/upcp --force'",
  "APP-002": "plesk installer update || echo 'Plesk UI → Tools & Settings → Updates'",
  "APP-003": "systemctl restart lsws || /usr/local/lsws/bin/lswsctrl restart",
  "APP-004": "imunify-agent status || echo 'Keep Imunify360 malware scan scheduled'",
  "APP-005": "echo 'JetBackup: run a test restore to a destination off this disk'",
}

def pkg_upgrade(pkg=""):
    if pkg_kind == "dnf":
        return ("dnf upgrade -y " + pkg).strip() if pkg else "dnf -y upgrade --security"
    if pkg_kind == "yum":
        return ("yum update -y " + pkg).strip() if pkg else "yum -y update --security"
    if pkg:
        return "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install --only-upgrade -y " + pkg
    return "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y"

def add(i, sev, comp, cause, fix, engine="host", extra=None):
    row = {"id": i, "engine": engine, "severity": sev, "component": comp, "cause": cause[:500], "fix": fix[:500], "solve": SOLVE.get(i, fix)[:700]}
    if extra:
        row.update(extra)
    findings.append(row)

sshd_txt = open(sshd, errors="ignore").read() if os.path.isfile(sshd) else ""
def ssh_val(key, default=""):
    m = re.findall(r"(?im)^\s*"+re.escape(key)+r"\s+(\S+)", sshd_txt)
    return m[-1] if m else default

if ssh_val("PermitRootLogin", "yes").lower() in ("yes", "without-password"):
    add("SSH-001", "high", "sshd", "Root SSH login is allowed.", "Set PermitRootLogin no then reload sshd.")
if ssh_val("PasswordAuthentication", "yes").lower() == "yes":
    add("SSH-002", "medium", "sshd", "Password authentication is on (prefer keys).", "Set PasswordAuthentication no after keys work, then reload sshd.")
if not os.path.exists("/usr/sbin/ufw") and not os.path.exists("/usr/bin/firewall-cmd") and not os.path.isfile("/etc/csf/csf.conf"):
    add("NET-001", "high", "firewall", "No UFW, firewalld, or CSF detected.", "Install and enable a host firewall.")
if os.path.isfile("/var/run/reboot-required"):
    add("OS-001", "medium", "kernel", "A reboot is required to finish patches.", "Schedule a reboot after notifying clients.")
st = stacks.split(",")
if "cpanel" in st: add("APP-001", "low", "cpanel", "cPanel/WHM detected. Keep WHM up to date.", "WHM Update Config / upcp.")
if "plesk" in st: add("APP-002", "low", "plesk", "Plesk detected. Apply Plesk updates.", "plesk installer update")
if "litespeed" in st: add("APP-003", "low", "litespeed", "LiteSpeed detected.", "Update LSWS then restart lsws.")
if "imunify360" in st: add("APP-004", "low", "imunify360", "Imunify360 detected. Keep malware scan on.", "Confirm Imunify360 license and schedule.")
if "jetbackup" in st: add("APP-005", "low", "jetbackup", "JetBackup detected. Confirm off-server restore.", "Run a test restore off this disk.")

lynis_score = None
lynis_version = ""
lynis_present = shutil.which("lynis") is not None
warn_n = sug_n = 0
if os.path.isfile(LYNIS_DAT):
    for line in open(LYNIS_DAT, errors="ignore"):
        line = line.strip()
        if line.startswith("hardening_index=") or line.startswith("hardening_index[]="):
            v = line.split("=",1)[1].strip().split("|")[0].strip()
            if v.isdigit():
                lynis_score = int(v)
        elif line.startswith("lynis_version="):
            lynis_version = line.split("=",1)[1].strip()
        elif line.startswith("warning[]=") or line.startswith("suggestion[]="):
            kind = "warning" if line.startswith("warning") else "suggestion"
            parts = line.split("=",1)[1].split("|")
            tid = (parts[0] or "LYNIS").strip()
            desc = parts[1].strip() if len(parts) > 1 else line
            extra = parts[2].strip() if len(parts) > 2 else ""
            sol = parts[3].strip() if len(parts) > 3 else ""
            if kind == "warning":
                warn_n += 1
                sev = "high"
            else:
                sug_n += 1
                if sug_n > 25:
                    continue
                sev = "medium"
            if warn_n > 30 and kind == "warning":
                continue
            cause = desc + ((" — " + extra) if extra else "")
            solve = SOLVE.get(tid, sol or ("Review RootManage hardening control %s" % tid))
            add(tid, sev, "lynis", cause, sol or solve, "lynis", {"solve": solve[:700]})

    if lynis_score is None:
        for path in (LYNIS_DAT, "/var/lib/rootmanage/lynis.log"):
            if not os.path.isfile(path):
                continue
            txt = open(path, errors="ignore").read()
            m = re.search(r"hardening_index(?:\[\])?\s*=\s*(\d+)", txt, re.I)
            if not m:
                m = re.search(r"Hardening index\s*:\s*\[?(\d+)", txt, re.I)
            if m:
                lynis_score = int(m.group(1))
                break

trivy_present = shutil.which("trivy") is not None
crit_n = high_n = med_n = 0
seen = set()
if os.path.isfile(TRIVY_JSON):
    try:
        d = json.load(open(TRIVY_JSON))
    except Exception:
        d = {}
    rows = []
    for r in d.get("Results") or []:
        target = r.get("Target") or r.get("Class") or "os"
        for v in r.get("Vulnerabilities") or []:
            cve = v.get("VulnerabilityID") or ""
            pkg = v.get("PkgName") or v.get("PkgID") or ""
            key = cve + "|" + pkg
            if key in seen:
                continue
            seen.add(key)
            sev = (v.get("Severity") or "MEDIUM").lower()
            if sev == "critical":
                crit_n += 1
                s = "critical"
            elif sev == "high":
                high_n += 1
                s = "high"
            else:
                med_n += 1
                s = "medium"
            inst = v.get("InstalledVersion") or ""
            fixed = v.get("FixedVersion") or ""
            title = v.get("Title") or v.get("Description") or cve
            solve = pkg_upgrade(pkg)
            rows.append((s, cve, pkg, inst, fixed, title, target, solve))
    order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
    rows.sort(key=lambda x: order.get(x[0], 9))
    for s, cve, pkg, inst, fixed, title, target, solve in rows[:40]:
        extra = {"cve": cve, "pkg": pkg, "installed": inst, "fixed_in": fixed, "solve": solve}
        add(cve or pkg or "CVE", s, pkg or target, title, ("Upgrade %s (installed %s → %s)" % (pkg, inst or "?", fixed or "latest")), "trivy", extra)

trivy_high_count = crit_n + high_n
data = {
  "agent_version": ver,
  "stacks": [x for x in stacks.split(",") if x],
  "lynis_score": lynis_score,
  "lynis_version": lynis_version,
  "lynis_present": bool(lynis_present or os.path.isfile(LYNIS_DAT)),
  "lynis_warning_count": warn_n,
  "lynis_suggestion_count": sug_n,
  "trivy_present": bool(trivy_present or os.path.isfile(TRIVY_JSON)),
  "trivy_high_count": trivy_high_count,
  "trivy_crit_count": crit_n,
  "trivy_med_count": med_n,
  "scan_at": int(scan_at),
  "pkg_kind": pkg_kind,
  "findings": findings[:80],
}
json.dump(data, open(out, "w"), separators=(",", ":"))
print(out)
PY
}

daily_job() {
  protect_operator_ip || true
  if ! preflight; then
    return 8
  fi
  update_engines || true
  write_scan || true
  self_update || true
  protect_operator_ip || true
  once || true
}

case "${1:-}" in
  --once) PRINT_WELCOME=1 once ;;
  --loop|"") loop ;;
  --scan) daily_job ;;
  --self-update) self_update ;;
  --version) echo "$AGENT_VERSION" ;;
  *) echo "usage: rootmanage-agent [--once|--loop|--scan|--self-update|--version]" >&2; exit 1 ;;
esac
