#!/usr/bin/env bash
# vexor_check_updates - Linux patch/update health, run BY the NRPE agent on the
# node and returned to Vexor over check_nrpe. Read-only: it only queries /
# simulates update state (never installs, removes or changes packages).
#
# One script, many checks. Pick a mode; each Vexor check_command calls a fixed
# mode so operators enable only the sub-checks they want.
#
#   --mode pending     Pending/available updates (total + security-classified).
#   --mode age         Days since the last package install/upgrade.
#   --mode reboot      Reboot-required-after-update (with reason).
#   --mode failed      Failed update transactions in the last N days (best effort).
#   --mode os          Distro/version + approximate end-of-support status.
#   --mode autoupdate  Automatic-update mechanism state (dnf-automatic / unattended).
#   --mode all         Combined summary (worst of pending/age/reboot/autoupdate).
#
# Package managers: dnf/yum (RHEL, CentOS, Alma, Rocky, Oracle, Fedora) and
# apt (Debian, Ubuntu) are first-class; zypper (SUSE), pacman (Arch) and apk
# (Alpine) are best-effort. Output: one Nagios line + perfdata, exit 0/1/2/3.
#
# Runs as the unprivileged nrpe user: package metadata is read from the local
# cache (dnf -C / apt lists refreshed by the distro's own timers), so a check
# never triggers a slow network refresh or needs root.

set -u

OK=0; WARN=1; CRIT=2; UNKNOWN=3

MODE="all"
WARN_TOTAL=1; CRIT_TOTAL=0        # pending: total pending thresholds
WARN_SEC=1;   CRIT_SEC=0          # pending: security pending thresholds
WARN_DAYS=30; CRIT_DAYS=60        # age / os: day thresholds
SEVERITY=""                       # reboot default crit, autoupdate default ok (see probes)
DAYS=7; WARN_COUNT=1; CRIT_COUNT=3 # failed: lookback + count thresholds

while [ $# -gt 0 ]; do
  case "$1" in
    --mode)        MODE="${2:-all}"; shift 2 ;;
    --warn-total)  WARN_TOTAL="${2:-0}"; shift 2 ;;
    --crit-total)  CRIT_TOTAL="${2:-0}"; shift 2 ;;
    --warn-sec)    WARN_SEC="${2:-0}"; shift 2 ;;
    --crit-sec)    CRIT_SEC="${2:-0}"; shift 2 ;;
    --warn-days)   WARN_DAYS="${2:-0}"; shift 2 ;;
    --crit-days)   CRIT_DAYS="${2:-0}"; shift 2 ;;
    --severity)    SEVERITY="${2:-}"; shift 2 ;;
    --days)        DAYS="${2:-7}"; shift 2 ;;
    --warn-count)  WARN_COUNT="${2:-0}"; shift 2 ;;
    --crit-count)  CRIT_COUNT="${2:-0}"; shift 2 ;;
    "" )           shift ;;
    * )            shift ;;   # ignore unknown/empty tokens (NRPE pads $ARGn$)
  esac
done

# int helpers -----------------------------------------------------------------
is_num() { case "$1" in ''|*[!0-9]*) return 1 ;; *) return 0 ;; esac; }
num() { if is_num "${1:-}"; then printf '%s' "$1"; else printf '0'; fi; }
WARN_TOTAL=$(num "$WARN_TOTAL"); CRIT_TOTAL=$(num "$CRIT_TOTAL")
WARN_SEC=$(num "$WARN_SEC");     CRIT_SEC=$(num "$CRIT_SEC")
WARN_DAYS=$(num "$WARN_DAYS");   CRIT_DAYS=$(num "$CRIT_DAYS")
DAYS=$(num "$DAYS"); WARN_COUNT=$(num "$WARN_COUNT"); CRIT_COUNT=$(num "$CRIT_COUNT")

have() { command -v "$1" >/dev/null 2>&1; }

# Package-manager detection ---------------------------------------------------
PKG=""
if   have dnf;     then PKG="dnf"
elif have yum;     then PKG="yum"
elif have apt-get; then PKG="apt"
elif have zypper;  then PKG="zypper"
elif have pacman;  then PKG="pacman"
elif have apk;     then PKG="apk"
fi

# os-release ------------------------------------------------------------------
OSREL=/etc/os-release
os_id=""; os_like=""; os_verid=""; os_pretty=""; os_name=""
if [ -r "$OSREL" ]; then
  # shellcheck disable=SC1090
  . "$OSREL" 2>/dev/null || true
  os_id="${ID:-}"; os_like="${ID_LIKE:-}"; os_verid="${VERSION_ID:-}"
  os_pretty="${PRETTY_NAME:-}"; os_name="${NAME:-}"
fi

# ----------------------------------------------------------------------------
# Probe: pending updates  (echoes:  "<total> <security>")
# ----------------------------------------------------------------------------
probe_pending_counts() {
  local total=0 sec=0 out rc
  case "$PKG" in
    dnf|yum)
      out="$("$PKG" -q -C check-update 2>/dev/null)"; rc=$?
      if [ "$rc" = "100" ]; then
        total="$(printf '%s\n' "$out" | awk 'NF>=3 && $1 ~ /\.[a-z0-9_]+$/ {c++} END{print c+0}')"
      fi
      # security: package updates carrying a security advisory
      out="$("$PKG" -q -C --security check-update 2>/dev/null)"; rc=$?
      if [ "$rc" = "100" ]; then
        sec="$(printf '%s\n' "$out" | awk 'NF>=3 && $1 ~ /\.[a-z0-9_]+$/ {c++} END{print c+0}')"
      fi
      ;;
    apt)
      out="$(apt-get -s -o Debug::NoLocking=true upgrade 2>/dev/null)"
      total="$(printf '%s\n' "$out" | grep -c '^Inst ')"
      sec="$(printf '%s\n' "$out" | grep '^Inst ' | grep -ciE 'security')"
      ;;
    zypper)
      out="$(zypper --quiet --non-interactive list-updates 2>/dev/null)"
      total="$(printf '%s\n' "$out" | awk -F'|' 'NF>=4 && $1 ~ /^v/ {c++} END{print c+0}')"
      sec="$(zypper --quiet --non-interactive list-patches --category security 2>/dev/null | awk -F'|' 'NF>=4 && $1 ~ /[A-Za-z]/ {c++} END{if(c>0)print c-0; else print 0}')"
      ;;
    pacman)
      if have checkupdates; then
        total="$(checkupdates 2>/dev/null | grep -c .)"
      else
        total="$(pacman -Qu 2>/dev/null | grep -c .)"
      fi
      sec="-1"   # Arch has no security classification
      ;;
    apk)
      total="$(apk version -l '<' 2>/dev/null | grep -c '<')"
      sec="-1"
      ;;
    *) total="-1"; sec="-1" ;;
  esac
  is_num "$total" || total=0
  printf '%s %s' "$total" "$sec"
}

probe_pending() {
  local counts total sec
  counts="$(probe_pending_counts)"
  total="${counts%% *}"; sec="${counts##* }"
  if [ "$total" = "-1" ]; then
    STATUS=$UNKNOWN; TEXT="no supported package manager found"; PERF="pending=U;;;0"; return
  fi
  local secreport="$sec"
  [ "$sec" = "-1" ] && secreport="n/a"
  STATUS=$OK
  if [ "$CRIT_SEC" -gt 0 ] && [ "$sec" != "-1" ] && [ "$sec" -ge "$CRIT_SEC" ]; then STATUS=$CRIT
  elif [ "$CRIT_TOTAL" -gt 0 ] && [ "$total" -ge "$CRIT_TOTAL" ]; then STATUS=$CRIT
  elif [ "$WARN_SEC" -gt 0 ] && [ "$sec" != "-1" ] && [ "$sec" -ge "$WARN_SEC" ]; then STATUS=$WARN
  elif [ "$WARN_TOTAL" -gt 0 ] && [ "$total" -ge "$WARN_TOTAL" ]; then STATUS=$WARN
  fi
  TEXT="$total pending update(s), $secreport security ($PKG)"
  local secperf="$sec"; [ "$sec" = "-1" ] && secperf=0
  PERF="pending=$total;$WARN_TOTAL;$CRIT_TOTAL;0 security=$secperf;;;0"
}

# ----------------------------------------------------------------------------
# Probe: age since last package install/upgrade
# ----------------------------------------------------------------------------
probe_age() {
  local epoch="" now days datestr
  case "$PKG" in
    dnf|yum)
      if have rpm; then
        datestr="$(rpm -qa --last 2>/dev/null | head -1 | sed 's/^[^ ]*[ ]*//')"
        [ -n "$datestr" ] && epoch="$(date -d "$datestr" +%s 2>/dev/null || true)"
      fi
      ;;
    apt|zypper)
      if [ -r /var/log/apt/history.log ]; then
        datestr="$(grep '^End-Date:' /var/log/apt/history.log 2>/dev/null | tail -1 | sed 's/^End-Date:[ ]*//')"
        [ -n "$datestr" ] && epoch="$(date -d "$datestr" +%s 2>/dev/null || true)"
      fi
      if [ -z "$epoch" ] && [ -r /var/log/dpkg.log ]; then
        datestr="$(tail -1 /var/log/dpkg.log 2>/dev/null | awk '{print $1" "$2}')"
        [ -n "$datestr" ] && epoch="$(date -d "$datestr" +%s 2>/dev/null || true)"
      fi
      ;;
  esac
  # Generic fallback: mtime of the package database
  if [ -z "$epoch" ]; then
    for f in /var/lib/dpkg/status /var/lib/rpm/rpmdb.sqlite /var/lib/rpm/Packages /var/lib/pacman/local /var/lib/zypp; do
      if [ -e "$f" ]; then epoch="$(stat -c %Y "$f" 2>/dev/null || true)"; [ -n "$epoch" ] && break; fi
    done
  fi
  if [ -z "$epoch" ] || ! is_num "$epoch"; then
    STATUS=$UNKNOWN; TEXT="could not determine last update time"; PERF="days=U;;;0"; return
  fi
  now="$(date +%s)"; days=$(( (now - epoch) / 86400 ))
  [ "$days" -lt 0 ] && days=0
  STATUS=$OK
  if [ "$CRIT_DAYS" -gt 0 ] && [ "$days" -ge "$CRIT_DAYS" ]; then STATUS=$CRIT
  elif [ "$WARN_DAYS" -gt 0 ] && [ "$days" -ge "$WARN_DAYS" ]; then STATUS=$WARN
  fi
  TEXT="last package install/upgrade $days day(s) ago ($(date -d "@$epoch" '+%Y-%m-%d' 2>/dev/null))"
  PERF="days=$days;$WARN_DAYS;$CRIT_DAYS;0"
}

# ----------------------------------------------------------------------------
# Probe: reboot required
# ----------------------------------------------------------------------------
probe_reboot() {
  local needed=0 reason=""
  if [ -f /var/run/reboot-required ] || [ -f /run/reboot-required ]; then
    needed=1; reason="/run/reboot-required present"
    local pk=/var/run/reboot-required.pkgs
    [ -f /run/reboot-required.pkgs ] && pk=/run/reboot-required.pkgs
    if [ -r "$pk" ]; then
      local n; n="$(sort -u "$pk" 2>/dev/null | grep -c .)"
      reason="$reason ($n package(s))"
    fi
  fi
  if [ "$needed" = "0" ] && have needs-restarting; then
    if needs-restarting -r >/dev/null 2>&1; then :; else needed=1; reason="needs-restarting: core libraries/services updated"; fi
  fi
  if [ "$needed" = "0" ] && have zypper; then
    zypper --quiet needs-rebooting >/dev/null 2>&1; local zrc=$?
    [ "$zrc" = "102" ] && { needed=1; reason="zypper: reboot required"; }
  fi
  # Generic fallback: running kernel older than newest installed kernel
  if [ "$needed" = "0" ] && have rpm; then
    local running newest
    running="$(uname -r)"
    newest="$(rpm -q --last kernel-core kernel 2>/dev/null | awk '{print $1}' | sed -e 's/^kernel-core-//' -e 's/^kernel-//' | head -1)"
    if [ -n "$newest" ] && [ "$newest" != "$running" ]; then
      needed=1; reason="running kernel $running, newer $newest installed"
    fi
  fi
  if [ "$needed" = "1" ]; then
    if [ "${SEVERITY:-crit}" = "warn" ]; then STATUS=$WARN; else STATUS=$CRIT; fi
    TEXT="reboot required - $reason"
  else
    STATUS=$OK; TEXT="no reboot required"
  fi
  PERF="reboot_pending=$needed;;;0"
}

# ----------------------------------------------------------------------------
# Probe: failed update transactions (best effort; some sources need root)
# ----------------------------------------------------------------------------
probe_failed() {
  local fail=0 accessible=0 since_epoch now
  now="$(date +%s)"; since_epoch=$(( now - DAYS*86400 ))
  case "$PKG" in
    dnf|yum)
      local hout cutoff
      hout="$("$PKG" history list 2>/dev/null)"; local rc=$?
      cutoff="$(date -d "@$since_epoch" +%Y-%m-%d 2>/dev/null)"
      if [ "$rc" = "0" ] && [ -n "$hout" ]; then
        accessible=1
        # dnf history "Altered" column carries a suffix flag; '*' = transaction
        # aborted, '#' = completed but rpm returned non-zero (i.e. failed).
        # (Plain 'E' means warnings/erase and is too noisy, so it is ignored.)
        fail="$(printf '%s\n' "$hout" | awk -F'|' -v cut="$cutoff" 'NR>2 {
            dt=""; if (match($3, /[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/)) dt=substr($3,RSTART,10);
            alt=$5; gsub(/[ \t]/,"",alt);
            if ((cut=="" || dt>=cut) && alt ~ /[*#]/) c++
        } END{print c+0}')"
      fi
      ;;
    apt|zypper)
      if [ -r /var/log/apt/history.log ]; then
        accessible=1
        # count history blocks with an Error in the last N days
        fail="$(awk -v since="$since_epoch" '
          /^Start-Date:/ { d=$2" "$3; "date -d \""d"\" +%s" | getline e; close("date -d \""d"\" +%s") }
          /^Error:/ { if (e>=since) c++ }
          END{print c+0}' /var/log/apt/history.log 2>/dev/null)"
      fi
      ;;
  esac
  if [ "$accessible" = "0" ]; then
    STATUS=$UNKNOWN; TEXT="update history not accessible (needs root or unsupported)"; PERF="failed=U;;;0"; return
  fi
  is_num "$fail" || fail=0
  STATUS=$OK
  if [ "$CRIT_COUNT" -gt 0 ] && [ "$fail" -ge "$CRIT_COUNT" ]; then STATUS=$CRIT
  elif [ "$WARN_COUNT" -gt 0 ] && [ "$fail" -ge "$WARN_COUNT" ]; then STATUS=$WARN
  fi
  TEXT="$fail failed update transaction(s) in the last $DAYS day(s)"
  PERF="failed=$fail;$WARN_COUNT;$CRIT_COUNT;0"
}

# ----------------------------------------------------------------------------
# Probe: OS / end-of-support
# ----------------------------------------------------------------------------
probe_os() {
  local key="" eol="" ctx=""
  local major="${os_verid%%.*}"
  case " $os_id $os_like " in
    *" rhel "*|*" centos "*|*" fedora "*|*rhel*|*centos*|*fedora*|*rocky*|*almalinux*|*ol*)
      : ;;
  esac
  # RHEL-family (keyed by ID + major). Dates = end of maintenance/security support.
  declare -A RHEL_EOL=(
    ["rhel:7"]="2024-06-30" ["rhel:8"]="2029-05-31" ["rhel:9"]="2032-05-31" ["rhel:10"]="2035-05-31"
    ["centos:7"]="2024-06-30" ["centos:8"]="2021-12-31"
    ["rocky:8"]="2029-05-31" ["rocky:9"]="2032-05-31" ["rocky:10"]="2035-05-31"
    ["almalinux:8"]="2029-05-31" ["almalinux:9"]="2032-05-31" ["almalinux:10"]="2035-05-31"
    ["ol:7"]="2028-12-31" ["ol:8"]="2029-07-31" ["ol:9"]="2032-06-30" ["ol:10"]="2035-06-30"
    ["fedora:40"]="2025-05-13" ["fedora:41"]="2025-11-19" ["fedora:42"]="2026-05-13" ["fedora:43"]="2026-11-18"
  )
  # Debian (LTS security-support end).
  declare -A DEB_EOL=(
    ["10"]="2024-06-30" ["11"]="2026-08-31" ["12"]="2028-06-30" ["13"]="2030-06-30"
  )
  # Ubuntu (standard-support end; ESM extends further).
  declare -A UBU_EOL=(
    ["18.04"]="2023-05-31" ["20.04"]="2025-05-31" ["22.04"]="2027-06-01" ["24.04"]="2029-05-31"
    ["24.10"]="2025-07-31" ["25.04"]="2026-01-31" ["25.10"]="2026-07-31"
  )
  # openSUSE Leap (best effort).
  declare -A SUSE_EOL=(
    ["15.4"]="2023-12-31" ["15.5"]="2024-12-31" ["15.6"]="2025-12-31"
  )

  case "$os_id" in
    rhel|centos|rocky|almalinux|ol|fedora)
      key="$os_id:$major"; eol="${RHEL_EOL[$key]:-}"; ctx="$os_id $major" ;;
    debian)
      eol="${DEB_EOL[$major]:-}"; ctx="Debian $major" ;;
    ubuntu)
      eol="${UBU_EOL[$os_verid]:-}"; ctx="Ubuntu $os_verid" ;;
    opensuse-leap|opensuse|sles|suse)
      eol="${SUSE_EOL[$os_verid]:-}"; ctx="$os_name $os_verid" ;;
  esac
  # Derivative fallback via ID_LIKE if exact ID unknown
  if [ -z "$eol" ] && [ -n "$major" ]; then
    case " $os_like " in
      *rhel*|*centos*|*fedora*) eol="${RHEL_EOL[rhel:$major]:-}"; [ -n "$eol" ] && ctx="$os_name (rhel $major compatible)" ;;
      *debian*) eol="${DEB_EOL[$major]:-}"; [ -n "$eol" ] && ctx="$os_name (debian $major compatible)" ;;
    esac
  fi

  local label="${os_pretty:-$os_name $os_verid}"
  STATUS=$OK
  if [ -n "$eol" ]; then
    local eolep now days
    eolep="$(date -d "$eol" +%s 2>/dev/null || true)"
    now="$(date +%s)"
    if [ -n "$eolep" ]; then
      days=$(( (eolep - now) / 86400 ))
      if [ "$days" -lt 0 ]; then STATUS=$CRIT; TEXT="$label - OUT OF SUPPORT since $eol ($ctx)"
      elif [ "$CRIT_DAYS" -gt 0 ] && [ "$days" -le "$CRIT_DAYS" ]; then STATUS=$CRIT; TEXT="$label - support ends $eol in $days day(s) ($ctx)"
      elif [ "$WARN_DAYS" -gt 0 ] && [ "$days" -le "$WARN_DAYS" ]; then STATUS=$WARN; TEXT="$label - support ends $eol in $days day(s) ($ctx)"
      else TEXT="$label - supported until $eol ($days day(s) left, $ctx)"; fi
    else
      TEXT="$label - EOL date parse error"
    fi
  else
    TEXT="$label - EOL date unknown (review manually)"
  fi
  PERF="eol_days=${days:-U};;;0"
}

# ----------------------------------------------------------------------------
# Probe: automatic-update mechanism
# ----------------------------------------------------------------------------
probe_autoupdate() {
  local enabled=0 name="none" state="not configured"
  if have systemctl; then
    for t in dnf-automatic-install.timer dnf-automatic.timer yum-cron; do
      if systemctl is-enabled "$t" >/dev/null 2>&1; then enabled=1; name="$t"; state="enabled"; break; fi
    done
  fi
  if [ "$enabled" = "0" ]; then
    # Debian/Ubuntu unattended-upgrades
    if [ -r /etc/apt/apt.conf.d/20auto-upgrades ]; then
      if grep -qE 'Unattended-Upgrade[^0-9]+"1"' /etc/apt/apt.conf.d/20auto-upgrades 2>/dev/null; then
        enabled=1; name="unattended-upgrades"; state="enabled"
      fi
    fi
    if [ "$enabled" = "0" ] && have systemctl && systemctl is-enabled apt-daily-upgrade.timer >/dev/null 2>&1; then
      # timer present but unattended-upgrade may still be off; report as partial
      name="apt-daily-upgrade.timer"; state="timer active (unattended-upgrade off)"
    fi
  fi
  if [ "$enabled" = "1" ]; then
    STATUS=$OK; TEXT="automatic updates $state ($name)"
  else
    # By default, not having auto-updates is a policy choice, not an error;
    # operators can opt into an alert with --severity warn|crit.
    case "${SEVERITY:-ok}" in
      warn) STATUS=$WARN ;;
      crit) STATUS=$CRIT ;;
      *)    STATUS=$OK ;;
    esac
    TEXT="automatic updates $state"
  fi
  PERF="auto_enabled=$enabled;;;0"
}

# ----------------------------------------------------------------------------
# Dispatch
# ----------------------------------------------------------------------------
declare -A LABEL=( [pending]=UPDATES [age]=PATCHAGE [reboot]=REBOOT [failed]=UPDATES-FAILED [os]=OSSUPPORT [autoupdate]=AUTOUPDATE [all]=PATCHSTATUS )
STATUS=$UNKNOWN; TEXT=""; PERF=""

emit() {
  local st="$1" label="$2" text="$3" perf="$4"
  local word
  case "$st" in 0) word=OK ;; 1) word=WARNING ;; 2) word=CRITICAL ;; *) word=UNKNOWN ;; esac
  if [ -n "$perf" ]; then printf '%s %s - %s|%s\n' "$label" "$word" "$text" "$perf"
  else printf '%s %s - %s\n' "$label" "$word" "$text"; fi
  exit "$st"
}

if [ -z "$PKG" ] && [ "$MODE" != "os" ]; then
  emit $UNKNOWN "${LABEL[$MODE]:-UPDATES}" "no supported package manager (dnf/yum/apt/zypper/pacman/apk) found" ""
fi

case "$MODE" in
  pending)    probe_pending ;;
  age)        probe_age ;;
  reboot)     probe_reboot ;;
  failed)     probe_failed ;;
  os)         probe_os ;;
  autoupdate) probe_autoupdate ;;
  all)
    # Composite: worst of pending / age / reboot / autoupdate.
    parts=""; worst=$OK
    probe_pending;    p1=$STATUS; t1="$TEXT"; f1="$PERF"
    probe_age;        p2=$STATUS; t2="$TEXT"; f2="$PERF"
    probe_reboot;     p3=$STATUS; t3="$TEXT"; f3="$PERF"
    probe_autoupdate; p4=$STATUS; t4="$TEXT"; f4="$PERF"
    for p in "$p1" "$p2" "$p3" "$p4"; do
      # ordering: CRIT(2) > WARN(1) > UNKNOWN(3 treated below OK) > OK(0)
      if [ "$p" = "2" ]; then worst=2; fi
    done
    if [ "$worst" != "2" ]; then
      for p in "$p1" "$p2" "$p3" "$p4"; do [ "$p" = "1" ] && worst=1; done
    fi
    if [ "$worst" = "0" ]; then
      for p in "$p1" "$p2" "$p3" "$p4"; do [ "$p" = "3" ] && worst=3; done
    fi
    TEXT="$t1 | $t2 | $t3 | $t4"
    PERF="$f1 $f2 $f3 $f4"
    STATUS=$worst
    ;;
  *)
    emit $UNKNOWN UPDATES "unknown --mode '$MODE'" "" ;;
esac

emit "$STATUS" "${LABEL[$MODE]:-UPDATES}" "$TEXT" "$PERF"
