From 2193075da3ddd9b8a2e6edbddf04bcc0f43953e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 11:26:14 +0000 Subject: [PATCH] =?UTF-8?q?feat(ops):=20add=20rw=5Fcpu=5Fguard.sh=20?= =?UTF-8?q?=E2=80=94=20nuclear=20CPU=20governor=20for=20KVM1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents Runewager from being starved by CPU hogs on Hostinger KVM1. Features: - Load-aware 3-zone response (none / soft / hard) with per-zone %CPU thresholds so the guard is only aggressive when the box is hot - Kill ladder: renice+ionice → cgroup-quota → STOP/CONT (3 s) → SIGTERM → SIGKILL - cgroup v1 + v2 auto-detection; configurable CPU quota (default 70%) - Fork-bomb detection via total-process-count ceiling (default 200) - Telegram admin alerts on STOP/CONT and kills (5-min cooldown, reads BOT_TOKEN + ADMIN_IDS from .env automatically) - Duplicate-instance guard via PID file - Atomic state-file updates (mktemp + mv) — no corruption under load - Comprehensive whitelist covering Runewager (all launch forms), PM2, systemd, sshd, cron, fail2ban, interactive shells, and guard itself; children of whitelisted parents are also protected - Modes: daemon (default), --install (writes systemd unit + enables), --status, --dry-run, --once (single scan, great for cron testing) - No `set -e` in daemon loop — a subcommand failure never kills the guard - Falls back to /tmp paths if /var/log isn't writable (non-root runs) https://claude.ai/code/session_01WpEqAiDbpqnBywMjYXJHf4 --- scripts/rw_cpu_guard.sh | 657 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 657 insertions(+) create mode 100755 scripts/rw_cpu_guard.sh diff --git a/scripts/rw_cpu_guard.sh b/scripts/rw_cpu_guard.sh new file mode 100755 index 0000000..dc58d31 --- /dev/null +++ b/scripts/rw_cpu_guard.sh @@ -0,0 +1,657 @@ +#!/usr/bin/env bash +# +# rw_cpu_guard.sh — NUCLEAR EDITION v2 +# Hostinger KVM1 hardened CPU governor for Runewager +# +# Keeps the Runewager bot alive at all costs while ruthlessly +# throttling/killing any non-whitelisted CPU hog. +# +# Features: +# - Load-aware 3-zone response (normal / soft / hard) +# - Kill ladder: renice → ionice → cgroup → STOP/CONT → SIGTERM → SIGKILL +# - cgroup v1 + v2 auto-detection and CPU quota enforcement +# - Fork-bomb detection (process-count ceiling) +# - Telegram admin alerts on STOP/CONT and kills (5-min cooldown) +# - Duplicate-instance guard via PID file +# - Atomic state-file updates (no corruption under concurrent writes) +# - --install: write + enable + start systemd service +# - --status : show live guard state +# - --dry-run: simulate every action without executing it +# - --once : single scan then exit (great for cron) +# +# Usage: +# ./rw_cpu_guard.sh # foreground daemon +# ./rw_cpu_guard.sh --install # install as systemd service (requires root) +# ./rw_cpu_guard.sh --status # print current state +# ./rw_cpu_guard.sh --dry-run # simulate (pair with --once for quick test) +# ./rw_cpu_guard.sh --once # one scan then exit +# +# Environment overrides (all optional): +# VCPU_COUNT, MAX_LOAD_SOFT, MAX_LOAD_HARD +# PROC_CPU_SOFT, PROC_CPU_HARD, PROC_CPU_KILL +# SCAN_INTERVAL, MAX_STRIKES, MAX_TOTAL_PROCS +# CGROUP_NAME, CGROUP_QUOTA_PCT +# TG_BOT_TOKEN, TG_ADMIN_IDS (auto-loaded from .env if absent) +# LOG_FILE, STATE_FILE, PID_FILE, DRY_RUN +# + +# No set -e in daemon context — a subcommand failure must never kill the guard. +set -uo pipefail + +# ─────────────────────────────────── IDENTITY ──────────────────────────────── + +readonly SCRIPT_NAME="rw_cpu_guard" +readonly SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" +readonly APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# ─────────────────────────────────── CONFIG ────────────────────────────────── + +# vCPU count on this VPS (Hostinger KVM1 = 1) +VCPU_COUNT="${VCPU_COUNT:-1}" + +# Load thresholds (multiples of vCPU count) +# Soft zone → 1.5× vCPU : apply heavier throttling +# Hard zone → 2.5× vCPU : all stops pulled, kill eligible +MAX_LOAD_SOFT="${MAX_LOAD_SOFT:-$(awk "BEGIN{printf \"%.1f\", ${VCPU_COUNT} * 1.5}")}" +MAX_LOAD_HARD="${MAX_LOAD_HARD:-$(awk "BEGIN{printf \"%.1f\", ${VCPU_COUNT} * 2.5}")}" + +# Per-process %CPU thresholds that trigger action in each load zone. +# Using a layered approach so we're only aggressive when the box is hot. +# Normal load: only smash extreme hogs (90 %+) +# Soft load : hit hard hogs (70 %+) +# Hard load : everyone above 40 % gets managed +PROC_CPU_SOFT="${PROC_CPU_SOFT:-40}" # hard-load zone trigger +PROC_CPU_HARD="${PROC_CPU_HARD:-70}" # soft-load zone trigger +PROC_CPU_KILL="${PROC_CPU_KILL:-90}" # normal zone trigger (only extreme hogs) + +SCAN_INTERVAL="${SCAN_INTERVAL:-8}" # seconds between scans +MAX_STRIKES="${MAX_STRIKES:-3}" # strikes before KILL + +# Fork-bomb guard: if total live process count exceeds this, go nuclear +MAX_TOTAL_PROCS="${MAX_TOTAL_PROCS:-200}" + +# Files +LOG_FILE="${LOG_FILE:-/var/log/${SCRIPT_NAME}.log}" +STATE_FILE="${STATE_FILE:-/tmp/${SCRIPT_NAME}.state}" +PID_FILE="${PID_FILE:-/tmp/${SCRIPT_NAME}.pid}" + +# cgroup CPU quota applied to throttled (non-whitelisted) processes +CGROUP_NAME="${CGROUP_NAME:-rw_cpu_guard}" +CGROUP_QUOTA_PCT="${CGROUP_QUOTA_PCT:-70}" # % of one CPU + +# Telegram alerts — loaded from .env automatically if not set in environment +TG_BOT_TOKEN="${TG_BOT_TOKEN:-}" +TG_ADMIN_IDS="${TG_ADMIN_IDS:-}" + +# Dry-run flag (set to 1 to simulate without acting) +DRY_RUN="${DRY_RUN:-0}" + +# ─────────────────────────────────── WHITELIST ─────────────────────────────── +# +# NEVER touch any process whose full cmdline matches one of these patterns. +# Patterns are matched as simple substrings first, then as extended regex. +# Any process whose *parent* matches a pattern is also protected. +# +WHITELIST_PATTERNS=( + # Guard itself + "rw_cpu_guard" + + # Runewager bot — all known launch forms + "runewager" + "Runewager" + "index.js" + "node.*index" + "/var/www" + "rw_bot" + + # Node / PM2 infrastructure + "pm2" + "pm2-runtime" + "PM2" + + # Init and core OS daemons — NEVER kill these + "systemd" + "systemd-" + "/lib/systemd" + "/usr/lib/systemd" + "dbus-daemon" + "rsyslogd" + "syslogd" + + # Remote access — losing SSH = game over + "sshd" + "sftp" + "dropbear" + + # Scheduling + "cron" + "crond" + "atd" + + # Security / firewall + "fail2ban" + "ufw" + "iptables" + "nftables" + + # Containers (don't interfere if present) + "containerd" + "dockerd" + "runc" + + # Interactive shells and common admin tools + "bash" + "zsh" + "sh " + "sh\t" + "-bash" + "-zsh" + "tmux" + "screen" + "vim" + "nano" + + # Utilities this script itself calls + "awk" + "sed" + "grep" + "ps " + "sleep" + "curl" +) + +# ─────────────────────────────────── GLOBALS ───────────────────────────────── + +_CGROUP_V2=0 +_CGROUP_AVAILABLE=0 +_LAST_ALERT_TS=0 +_ALERT_COOLDOWN=300 # seconds between Telegram alerts (prevent spam) + +# ─────────────────────────────────── LOGGING ───────────────────────────────── + +_ensure_log() { + # Fall back to /tmp if /var/log isn't writable + touch "$LOG_FILE" 2>/dev/null || LOG_FILE="/tmp/${SCRIPT_NAME}.log" +} + +log() { + local level="$1"; shift + local ts; ts="$(date '+%F %T')" + local line="[${ts}] [${level}] $*" + echo "$line" >> "$LOG_FILE" 2>/dev/null || true + # Mirror to stdout when interactive or in dry-run + [[ -t 1 || "${DRY_RUN}" == "1" ]] && echo "$line" || true +} + +info() { log "INFO " "$@"; } +warn() { log "WARN " "$@"; } +error() { log "ERROR" "$@"; } +act() { log "ACT " "$@"; } + +# ─────────────────────────────────── ENV LOADER ────────────────────────────── + +_load_env() { + local env_file="${APP_DIR}/.env" + [[ -f "$env_file" ]] || return 0 + while IFS= read -r line; do + # skip comments and blanks + [[ "$line" =~ ^[[:space:]]*# || -z "${line// }" ]] && continue + local key="${line%%=*}" + local val="${line#*=}" + # strip surrounding quotes + val="${val%\"}" ; val="${val#\"}" + val="${val%\'}" ; val="${val#\'}" + case "$key" in + BOT_TOKEN|TELEGRAM_BOT_TOKEN) + [[ -z "$TG_BOT_TOKEN" ]] && TG_BOT_TOKEN="$val" ;; + ADMIN_IDS) + [[ -z "$TG_ADMIN_IDS" ]] && TG_ADMIN_IDS="$val" ;; + esac + done < "$env_file" +} + +# ─────────────────────────────────── TELEGRAM ALERT ────────────────────────── + +tg_alert() { + local msg="$1" + [[ -z "$TG_BOT_TOKEN" || -z "$TG_ADMIN_IDS" ]] && return 0 + + local now; now=$(date +%s) + local elapsed=$(( now - _LAST_ALERT_TS )) + (( elapsed < _ALERT_COOLDOWN )) && return 0 + _LAST_ALERT_TS=$now + + IFS=',' read -ra _ids <<< "$TG_ADMIN_IDS" + local id + for id in "${_ids[@]}"; do + id="${id//[[:space:]]/}" + [[ -z "$id" ]] && continue + curl -sS --max-time 6 \ + "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage" \ + --data-urlencode "chat_id=${id}" \ + --data-urlencode "text=🛡 [rw_cpu_guard] ${msg}" \ + -d "parse_mode=Markdown" \ + >/dev/null 2>&1 || true + done +} + +# ─────────────────────────────────── CGROUP ────────────────────────────────── + +_detect_cgroup() { + if [[ -f /sys/fs/cgroup/cgroup.controllers ]]; then + _CGROUP_V2=1 + _CGROUP_AVAILABLE=1 + info "cgroup v2 detected" + elif [[ -d /sys/fs/cgroup/cpu ]]; then + _CGROUP_V2=0 + _CGROUP_AVAILABLE=1 + info "cgroup v1 detected" + else + _CGROUP_AVAILABLE=0 + warn "No cgroup CPU controller found — quota enforcement disabled" + fi +} + +_setup_cgroup() { + _detect_cgroup + (( _CGROUP_AVAILABLE == 0 )) && return 0 + + # period = 100 ms; quota = CGROUP_QUOTA_PCT% of that period + local period_us=100000 + local quota_us=$(( period_us * CGROUP_QUOTA_PCT / 100 )) + + if (( _CGROUP_V2 == 1 )); then + local cg_path="/sys/fs/cgroup/${CGROUP_NAME}" + mkdir -p "$cg_path" 2>/dev/null || { warn "cgroup v2 mkdir failed (non-fatal)"; _CGROUP_AVAILABLE=0; return 0; } + echo "${quota_us} ${period_us}" > "${cg_path}/cpu.max" 2>/dev/null || true + info "cgroup v2 /${CGROUP_NAME} ready (CPU quota ${CGROUP_QUOTA_PCT}% of 1 core)" + else + local cg_path="/sys/fs/cgroup/cpu/${CGROUP_NAME}" + mkdir -p "$cg_path" 2>/dev/null || { warn "cgroup v1 mkdir failed (non-fatal)"; _CGROUP_AVAILABLE=0; return 0; } + echo "$period_us" > "${cg_path}/cpu.cfs_period_us" 2>/dev/null || true + echo "$quota_us" > "${cg_path}/cpu.cfs_quota_us" 2>/dev/null || true + info "cgroup v1 /cpu/${CGROUP_NAME} ready (CPU quota ${CGROUP_QUOTA_PCT}% of 1 core)" + fi +} + +_apply_cgroup() { + local pid="$1" + (( _CGROUP_AVAILABLE == 0 )) && return 0 + if (( _CGROUP_V2 == 1 )); then + echo "$pid" > "/sys/fs/cgroup/${CGROUP_NAME}/cgroup.procs" 2>/dev/null || true + else + echo "$pid" > "/sys/fs/cgroup/cpu/${CGROUP_NAME}/cgroup.procs" 2>/dev/null || true + fi +} + +# ─────────────────────────────────── HELPERS ───────────────────────────────── + +# Float comparison without bc — uses awk (always available) +_float_gt() { awk "BEGIN { exit !($1 > $2) }"; } + +_current_load() { awk '{print $1}' /proc/loadavg; } + +_is_alive() { kill -0 "$1" 2>/dev/null; } + +# Full cmdline from /proc (ps truncates at 80 chars — this doesn't) +_full_cmd() { + tr '\0' ' ' < "/proc/$1/cmdline" 2>/dev/null | head -c 300 || echo "(unknown)" +} + +# Returns 0 (true) if the process is whitelisted +_is_whitelisted() { + local cmd="$1" + local pid="$2" + + # PID 1 (init/systemd) always protected + [[ "$pid" == "1" ]] && return 0 + + local pat + for pat in "${WHITELIST_PATTERNS[@]}"; do + # substring match (fast) + [[ "$cmd" == *"${pat}"* ]] && return 0 + # extended-regex match (for patterns with .* / \? etc.) + echo "$cmd" | grep -qE "$pat" 2>/dev/null && return 0 + done + + # Protect children of whitelisted parents + local ppid + ppid=$(awk '{print $4}' "/proc/${pid}/stat" 2>/dev/null) || return 1 + if [[ -n "$ppid" && "$ppid" != "1" && "$ppid" != "0" ]]; then + local parent_cmd; parent_cmd=$(_full_cmd "$ppid") + for pat in "${WHITELIST_PATTERNS[@]}"; do + [[ "$parent_cmd" == *"${pat}"* ]] && return 0 + done + fi + + return 1 +} + +# ─────────────────────────────────── STRIKE STATE ──────────────────────────── + +_get_strikes() { + grep -E "^$1 " "$STATE_FILE" 2>/dev/null | awk '{print $2}' || echo 0 +} + +_set_strikes() { + local pid="$1" count="$2" + local tmp + tmp=$(mktemp "${STATE_FILE}.XXXXXX" 2>/dev/null) || { + # fallback: append directly (not atomic but won't corrupt) + sed -i "/^${pid} /d" "$STATE_FILE" 2>/dev/null || true + echo "$pid $count" >> "$STATE_FILE" + return + } + grep -v "^${pid} " "$STATE_FILE" 2>/dev/null >> "$tmp" || true + echo "$pid $count" >> "$tmp" + mv -f "$tmp" "$STATE_FILE" +} + +_clear_dead_pids() { + [[ -f "$STATE_FILE" ]] || return 0 + local tmp + tmp=$(mktemp "${STATE_FILE}.XXXXXX" 2>/dev/null) || return 0 + while IFS=' ' read -r pid rest; do + _is_alive "$pid" && echo "$pid $rest" >> "$tmp" || true + done < "$STATE_FILE" + mv -f "$tmp" "$STATE_FILE" +} + +# ─────────────────────────────────── ACTION EXECUTOR ───────────────────────── + +# Wraps every destructive call so --dry-run suppresses execution +_do() { + if [[ "$DRY_RUN" == "1" ]]; then + info "[DRY-RUN] $*" + return 0 + fi + "$@" 2>/dev/null || true +} + +# ─────────────────────────────────── KILL LADDER ───────────────────────────── + +_handle_hog() { + local pid="$1" + local cpu_pct="$2" # integer % + local cmd="$3" + local zone="$4" # none | soft | hard + + local strikes + strikes=$(_get_strikes "$pid") + strikes=$(( strikes + 1 )) + _set_strikes "$pid" "$strikes" + + # Always move into throttle cgroup first (best-effort) + _apply_cgroup "$pid" + + local short_cmd="${cmd:0:80}" + + if (( strikes == 1 )); then + # ── Strike 1: Graceful throttle ────────────────────────────────────── + _do renice +15 -p "$pid" + _do ionice -c 3 -p "$pid" + act "STRIKE1 | PID=${pid} CPU=${cpu_pct}% zone=${zone} cmd=${short_cmd}" + + elif (( strikes == 2 )); then + # ── Strike 2: Hard pause — STOP 3s then CONT ───────────────────────── + _do kill -STOP "$pid" + sleep 3 + _do kill -CONT "$pid" + act "STRIKE2 | PID=${pid} CPU=${cpu_pct}% zone=${zone} STOP/CONT cmd=${short_cmd}" + tg_alert "⚠️ STOP/CONT throttle — PID=${pid} CPU=${cpu_pct}% zone=${zone} — \`${short_cmd}\`" + + else + # ── Strike 3+: Termination (SIGTERM then SIGKILL) ──────────────────── + _do kill -TERM "$pid" + sleep 2 + if _is_alive "$pid"; then + _do kill -KILL "$pid" + act "KILLED(SIGKILL) | PID=${pid} CPU=${cpu_pct}% zone=${zone} cmd=${short_cmd}" + tg_alert "💀 SIGKILL — PID=${pid} CPU=${cpu_pct}% zone=${zone} — \`${short_cmd}\`" + else + act "KILLED(SIGTERM) | PID=${pid} CPU=${cpu_pct}% zone=${zone} cmd=${short_cmd}" + tg_alert "💀 SIGTERM — PID=${pid} CPU=${cpu_pct}% zone=${zone} — \`${short_cmd}\`" + fi + fi +} + +# ─────────────────────────────────── FORK-BOMB GUARD ───────────────────────── + +_check_fork_bomb() { + local total + total=$(ls /proc | grep -cE '^[0-9]+$' 2>/dev/null) || return 0 + (( total <= MAX_TOTAL_PROCS )) && return 0 + + warn "FORK-BOMB alert: ${total} processes (threshold ${MAX_TOTAL_PROCS})" + tg_alert "🚨 Fork-bomb suspected! ${total} processes running — emergency cleanup started." + + local killed=0 + # Kill highest-PID (most recently forked) non-whitelisted processes first + while IFS= read -r pid; do + [[ "$pid" == "$$" ]] && continue + local cmd; cmd=$(_full_cmd "$pid") + _is_whitelisted "$cmd" "$pid" && continue + _do kill -KILL "$pid" + (( killed++ )) + (( killed >= 30 )) && break + done < <(ls /proc | grep -E '^[0-9]+$' | sort -rn 2>/dev/null) + + warn "Fork-bomb response: killed ${killed} processes" +} + +# ─────────────────────────────────── MAIN SCAN ─────────────────────────────── + +_scan_once() { + local load; load=$(_current_load) + + # Determine load zone + local zone="none" + if _float_gt "$load" "$MAX_LOAD_HARD" 2>/dev/null; then + zone="hard" + elif _float_gt "$load" "$MAX_LOAD_SOFT" 2>/dev/null; then + zone="soft" + fi + + info "scan | load=${load} soft=${MAX_LOAD_SOFT} hard=${MAX_LOAD_HARD} zone=${zone}" + + _clear_dead_pids + _check_fork_bomb + + # Per-zone CPU threshold: more aggressive when load is higher + local threshold + case "$zone" in + hard) threshold="$PROC_CPU_SOFT" ;; # 40%+ — cast a wide net + soft) threshold="$PROC_CPU_HARD" ;; # 70%+ — medium aggression + *) threshold="$PROC_CPU_KILL" ;; # 90%+ — only extreme hogs + esac + + # ps: pid + %cpu + command, sorted descending by cpu, no header + while IFS= read -r line; do + local pid cpu cmd + read -r pid cpu cmd <<< "$line" + + # Truncate fractional %cpu to integer + cpu="${cpu%.*}" + [[ -z "$cpu" || "$cpu" -lt 1 ]] 2>/dev/null && continue + (( cpu < threshold )) && continue + + # Prefer /proc cmdline (full path, not truncated by ps) + local full_cmd; full_cmd=$(_full_cmd "$pid") + + if _is_whitelisted "$full_cmd" "$pid"; then + info "PROTECTED | PID=${pid} CPU=${cpu}% cmd=${full_cmd:0:80}" + continue + fi + + _handle_hog "$pid" "$cpu" "$full_cmd" "$zone" + + done < <(ps -eo pid=,pcpu=,cmd= --sort=-pcpu 2>/dev/null) +} + +# ─────────────────────────────────── INSTALL ───────────────────────────────── + +_install_service() { + if [[ "$(id -u)" -ne 0 ]]; then + echo "ERROR: --install requires root (sudo $0 --install)" >&2 + exit 1 + fi + + local svc="/etc/systemd/system/${SCRIPT_NAME}.service" + info "Installing systemd service: ${svc}" + + cat > "$svc" </dev/null) + echo -n " Guard PID : ${gpid} " + _is_alive "$gpid" && echo "(RUNNING)" || echo "(DEAD / stale PID file)" + else + echo " Guard PID : no PID file (not running as daemon)" + fi + + if command -v systemctl >/dev/null 2>&1; then + local svc_state + svc_state=$(systemctl is-active "${SCRIPT_NAME}" 2>/dev/null) || true + echo " Service state: ${svc_state}" + fi + + echo "" + echo "--- Strike records (${STATE_FILE}) ---" + if [[ -s "$STATE_FILE" ]]; then + while IFS=' ' read -r pid strikes; do + local cmd; cmd=$(_full_cmd "$pid" 2>/dev/null || echo "(gone)") + printf " PID=%-7s strikes=%-2s %s\n" "$pid" "$strikes" "${cmd:0:60}" + done < "$STATE_FILE" + else + echo " (empty)" + fi + + echo "" + echo "--- Last 20 log lines ---" + tail -n 20 "$LOG_FILE" 2>/dev/null || echo " (log not found)" +} + +# ─────────────────────────────────── CLEANUP ───────────────────────────────── + +_cleanup() { + rm -f "$PID_FILE" 2>/dev/null || true + info "${SCRIPT_NAME} stopped (PID $$)" +} + +# ─────────────────────────────────── MAIN ──────────────────────────────────── + +main() { + local mode="daemon" + + for arg in "$@"; do + case "$arg" in + --install) mode="install" ;; + --status) mode="status" ;; + --once) mode="once" ;; + --dry-run) DRY_RUN="1" ;; + --help|-h) + grep '^#' "$SCRIPT_PATH" 2>/dev/null | head -30 | sed 's/^# *//' + exit 0 + ;; + *) + echo "Unknown option: $arg (use --help)" >&2 + exit 1 + ;; + esac + done + + # Modes that don't need daemon infrastructure + case "$mode" in + install) _install_service; exit 0 ;; + status) _show_status; exit 0 ;; + esac + + # ── Daemon / once setup ─────────────────────────────────────────────────── + + _ensure_log + touch "$STATE_FILE" 2>/dev/null || STATE_FILE="/tmp/${SCRIPT_NAME}.state" + + # Prevent duplicate daemon instances + if [[ -f "$PID_FILE" ]]; then + local old_pid; old_pid=$(cat "$PID_FILE" 2>/dev/null) + if _is_alive "$old_pid" 2>/dev/null; then + echo "${SCRIPT_NAME} is already running (PID ${old_pid}). Exiting." >&2 + exit 0 + fi + rm -f "$PID_FILE" + fi + echo $$ > "$PID_FILE" + + trap _cleanup EXIT INT TERM + + _load_env + _setup_cgroup + + [[ "$DRY_RUN" == "1" ]] && info "DRY-RUN mode — no actions will be executed" + + info "${SCRIPT_NAME} NUCLEAR started | vCPU=${VCPU_COUNT} | load_soft=${MAX_LOAD_SOFT} | load_hard=${MAX_LOAD_HARD} | interval=${SCAN_INTERVAL}s | cgroup_available=${_CGROUP_AVAILABLE}" + + if [[ "$mode" == "once" ]]; then + _scan_once + exit 0 + fi + + # ── Main daemon loop ────────────────────────────────────────────────────── + while true; do + _scan_once || warn "scan error (guard continues)" + sleep "$SCAN_INTERVAL" + done +} + +main "$@"