#!/usr/bin/env bash
#
# CheckMK local check: mailpiler (Community Edition, systemd)
#
#   ohne Parameter : erzeugt <<<local:sep(0)>>> Output
#   --detect       : exit 0 = Plugin wird auf dieser Maschine benoetigt
#
# Benoetigt: bash, systemctl, mysql-client, dpkg/apt-cache, git oder curl
# Netz:      github.com (Upstream-Version)
#
# Achtung: kein "set -e" - viele Aufrufe duerfen fehlschlagen, ohne dass das
# Plugin abbricht. Fehler werden pro Service in einen Status uebersetzt.
set -uo pipefail

########################### Konfiguration ##################################
PILER_UNITS="${PILER_UNITS:-piler piler-smtp pilersearch}"

# Leer lassen = automatisch suchen. Die Legacy-Installation (Quellbau, meist
# vor 1.4.5) liegt unter /usr/local, das Paket unter /etc.
PILER_CONF="${PILER_CONF:-}"
PILER_CONF_CANDIDATES="${PILER_CONF_CANDIDATES:-/etc/piler/piler.conf /usr/local/etc/piler/piler.conf}"

# Legacy-Installation: eigener Status, per Default WARN.
LEGACY_STATE="${LEGACY_STATE:-1}"

# Gleichzeitig laufende Checks. 1 = seriell.
MAX_PARALLEL="${MAX_PARALLEL:-$(nproc 2>/dev/null || echo 4)}"

# Archivierung: Alter der juengsten archivierten Mail.
# Die Defaults sind bewusst hoch: ein Firmenarchiv bekommt uebers Wochenende
# keine Mail, 48h wuerden am Sonntag falsch alarmieren. Die saubere Loesung
# fuer Nacht und Wochenende ist eine CheckMK-Zeitperiode, nicht ein noch
# groesserer Schwellwert hier.
ARCHIVE_WARN_HOURS="${ARCHIVE_WARN_HOURS:-24}"
ARCHIVE_CRIT_HOURS="${ARCHIVE_CRIT_HOURS:-72}"

# Update-Quellen
PILER_PKG="${PILER_PKG:-piler}"
PILER_GIT_REPO="${PILER_GIT_REPO:-https://github.com/jsuto/piler}"
PILER_GIT_API="${PILER_GIT_API:-https://api.github.com/repos/jsuto/piler/releases/latest}"
# Paketquelle: gebaut aus gitlab.bashclub.org/bashclub/piler, veroeffentlicht
# auf apt.bashclub.org. Massgeblich ist hier der apt-Cache, weil nur der sagt,
# was tatsaechlich installierbar ist.
# Nach diesem String wird in der Herkunftsspalte von "apt-cache madison"
# gefiltert, damit nur Pakete aus dem eigenen Repo zaehlen.
# Paketquelle: gebaut aus https://gitlab.bashclub.org/bashclub/piler,
# ausgeliefert ueber apt.bashclub.org.
PILER_REPO_MATCH="${PILER_REPO_MATCH:-apt.bashclub.org}"
GIT_TIMEOUT="${GIT_TIMEOUT:-20}"

########################### Helfer #########################################

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

emit() {  # <state> <servicename> <perfdata|-> <text>
  local state="$1" name="$2" perf="${3:--}" text="$4"
  name="${name//\"/}"; name="${name//$'\t'/ }"
  text="${text//$'\n'/ }"; text="${text//$'\t'/ }"
  printf '%s "%s" %s %s\n' "$state" "$name" "$perf" "$text"
}

# Wert aus piler.conf. Kommentare beginnen dort am Zeilenanfang mit '#',
# der Wert selbst reicht bis Zeilenende - ein '#' im Passwort darf ihn also
# nicht abschneiden.
piler_conf() {  # $1 = key
  sed -nE "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*(.*)\$/\1/p" "$PILER_CONF" 2>/dev/null \
    | tail -n1 | sed -E "s/[[:space:]]+\$//; s/^\"(.*)\"\$/\1/; s/^'(.*)'\$/\1/"
}

# Setzt PILER_CONF, falls nicht vorgegeben.
resolve_conf() {
  local c
  [[ -n "$PILER_CONF" ]] && return 0
  for c in $PILER_CONF_CANDIDATES; do
    [[ -r "$c" ]] && { PILER_CONF="$c"; return 0; }
  done
  PILER_CONF="${PILER_CONF_CANDIDATES%% *}"
}

# Setzt INSTALL_TYPE und INSTALL_PREFIX.
#   paket  - Debian-Paket aus dem apt-Repo
#   legacy - Quellbau unter /usr/local (typisch vor 1.4.5)
#   quelle - Quellbau ohne /usr/local-Marker
INSTALL_TYPE="unbekannt"
INSTALL_PREFIX=""
detect_install() {
  if have dpkg-query && dpkg-query -W -f='${Version}' "$PILER_PKG" >/dev/null 2>&1; then
    INSTALL_TYPE="paket"; INSTALL_PREFIX="/usr"; return 0
  fi
  # /usr/sbin/piler ist bei Legacy nur ein Symlink nach /usr/local/sbin -
  # unterschieden wird deshalb ueber die /usr/local-Marker, nicht ueber /usr.
  if [[ -x /usr/local/sbin/piler || -f /usr/local/etc/piler/piler.conf ]]; then
    INSTALL_TYPE="legacy"; INSTALL_PREFIX="/usr/local"; return 0
  fi
  if [[ -x /usr/sbin/piler || -x /usr/bin/piler ]]; then
    INSTALL_TYPE="quelle"; INSTALL_PREFIX="/usr"
  fi
  return 0
}

########################### detect mode ####################################

resolve_conf
detect_install

if [[ "${1:-}" == "--detect" ]]; then
  have systemctl || exit 1
  [[ -f "$PILER_CONF" ]] && exit 0
  [[ -f /usr/local/etc/piler/piler.conf || -x /usr/local/sbin/piler ]] && exit 0
  systemctl list-unit-files piler.service >/dev/null 2>&1 \
    && systemctl cat piler.service >/dev/null 2>&1 && exit 0
  exit 1
fi

########################### Job-Pool #######################################

SEM_FD=""

pool_init() {
  local fifo i
  (( MAX_PARALLEL > 1 )) || return 1
  fifo="$(mktemp -u)" || return 1
  mkfifo "$fifo" 2>/dev/null || return 1
  exec {SEM_FD}<>"$fifo" || { rm -f "$fifo"; SEM_FD=""; return 1; }
  rm -f "$fifo"
  for (( i = 0; i < MAX_PARALLEL; i++ )); do printf '.' >&"$SEM_FD"; done
  return 0
}

pool_run() {  # <ausgabedatei> <kommando...>
  local out="$1"; shift
  if [[ -z "$SEM_FD" ]]; then "$@" > "$out" 2>/dev/null; return 0; fi
  local tok
  IFS= read -r -n1 -u "$SEM_FD" tok
  { "$@" > "$out" 2>/dev/null; printf '.' >&"$SEM_FD"; } &
}

########################### Dienste ########################################

# "active, aber disabled" ist ein eigener Fehlerfall: der Dienst laeuft jetzt,
# kommt nach einem Reboot aber nicht wieder - und genau das faellt sonst erst
# beim naechsten Neustart auf.
# Prozessname zu einer Unit. pilersearch startet searchd bzw. manticore.
unit_process_running() {  # $1 = unit
  local names="$1" p
  [[ "$1" == "pilersearch" ]] && names="searchd manticore"
  for p in $names; do pgrep -x "$p" >/dev/null 2>&1 && return 0; done
  return 1
}

check_services() {
  local u active enabled
  local total=0 running=0
  local -a dead=() notenabled=() nounit=()

  for u in $PILER_UNITS; do
    total=$(( total + 1 ))
    active="$(systemctl is-active "$u.service" 2>/dev/null)"
    enabled="$(systemctl is-enabled "$u.service" 2>/dev/null)"

    if [[ "$active" == "active" ]]; then
      running=$(( running + 1 ))
      [[ "$enabled" == "enabled" || "$enabled" == "static" || "$enabled" == "indirect" ]] \
        || notenabled+=("$u:${enabled:-unbekannt}")
      continue
    fi

    # Alte Quellinstallationen starten searchd teils ueber rc.searchd statt
    # ueber eine Unit. Eine fehlende Unit bei laufendem Prozess ist ein
    # Konfigurationsunterschied, kein Ausfall - und darf nicht CRIT geben.
    if ! systemctl cat "$u.service" >/dev/null 2>&1; then
      if unit_process_running "$u"; then
        nounit+=("$u")
        running=$(( running + 1 ))
      else
        dead+=("$u:keine-unit-kein-prozess")
      fi
      continue
    fi
    dead+=("$u:${active:-unbekannt}")
  done

  local perf="services_running=$running;;;0;$total services_failed=${#dead[@]};;1;0"

  if (( ${#dead[@]} > 0 )); then
    emit 2 "Piler Services" "$perf" "CRIT - $running/$total aktiv, nicht aktiv: ${dead[*]}"
  elif (( ${#notenabled[@]} > 0 || ${#nounit[@]} > 0 )); then
    local hint=""
    (( ${#notenabled[@]} > 0 )) && hint="nicht fuer Autostart aktiviert: ${notenabled[*]}"
    (( ${#nounit[@]} > 0 )) && hint="${hint:+$hint; }laeuft ohne systemd-Unit: ${nounit[*]}"
    emit 1 "Piler Services" "$perf" "WARN - $running/$total aktiv, $hint"
  else
    emit 0 "Piler Services" "$perf" "OK - alle $total Dienste aktiv und aktiviert"
  fi
}

check_installation() {
  local inst inst_full inst_up legacy_note=""
  inst="$(piler_versions)"; inst_full="${inst%%|*}"; inst_up="$(cut -d'|' -f2 <<< "$inst")"
  # Vor PILER_PKG_SINCE gab es noch kein Paket - das erklaert die Legacy-
  # Installation und gehoert in den Text, damit klar ist, warum sie existiert.
  if [[ -n "$inst_up" ]] && dpkg --compare-versions "$inst_up" lt "$PILER_PKG_SINCE" 2>/dev/null; then
    legacy_note=", aelter als $PILER_PKG_SINCE"
  fi

  case "$INSTALL_TYPE" in
    paket)
      emit 0 "Piler Installation" "-" \
        "OK - Paketinstallation ${PILER_PKG} ${inst_full:-?} (Repo ${PILER_REPO_MATCH})" ;;
    legacy)
      emit "$LEGACY_STATE" "Piler Installation" "-" \
        "Legacy-Quellinstallation unter ${INSTALL_PREFIX} (Version ${inst_up:-unbekannt}${legacy_note}), kein Paket - Migration auf das ${PILER_REPO_MATCH}-Paket empfohlen" ;;
    quelle)
      emit "$LEGACY_STATE" "Piler Installation" "-" \
        "Quellinstallation ohne Paket (Version ${inst_up:-unbekannt}), Updates muessen manuell eingespielt werden" ;;
    *)
      emit 3 "Piler Installation" "-" \
        "UNKNOWN - Installationstyp nicht bestimmbar (weder Paket noch piler-Binary gefunden)" ;;
  esac
}

########################### Archivierung ###################################

piler_sql() {  # $1 = query
  local host user pass db sock
  local -a args=()
  [[ -r "$PILER_CONF" ]] || return 1
  have mysql || return 1
  user="$(piler_conf mysqluser)"; pass="$(piler_conf mysqlpwd)"
  db="$(piler_conf mysqldb)";     sock="$(piler_conf mysqlsocket)"
  host="$(piler_conf mysqlhost)"
  [[ -n "$user" && -n "$db" ]] || return 1
  if [[ -n "$sock" ]]; then args=(--socket="$sock"); else args=(-h"${host:-localhost}"); fi
  # Passwort per Umgebungsvariable, nicht als Argument - sonst steht es in
  # der Prozessliste.
  MYSQL_PWD="$pass" timeout 30 mysql -N -B -u"$user" "${args[@]}" "$db" -e "$1" 2>/dev/null
}

check_archiving() {
  local row newest last24 total age_h now
  row="$(piler_sql "
    SELECT COALESCE(MAX(arrived),0),
           COALESCE(SUM(arrived >= UNIX_TIMESTAMP()-86400),0),
           COUNT(*)
    FROM metadata")"

  IFS=$'\t' read -r newest last24 total <<< "${row:-}"
  if [[ -z "${total:-}" ]]; then
    emit 3 "Piler Archiving" "-" \
      "UNKNOWN - Datenbankabfrage fehlgeschlagen (mysql-Client, $PILER_CONF lesbar?)"
    return 0
  fi
  if [[ "${newest:-0}" == "0" ]]; then
    emit 3 "Piler Archiving" "messages_total=$total" \
      "UNKNOWN - noch keine archivierte Mail in der Datenbank"
    return 0
  fi

  now="$(date +%s)"
  age_h=$(( (now - newest) / 3600 ))

  local state=0 txt="OK"
  if   (( age_h >= ARCHIVE_CRIT_HOURS )); then state=2; txt="CRIT"
  elif (( age_h >= ARCHIVE_WARN_HOURS )); then state=1; txt="WARN"
  fi

  emit "$state" "Piler Archiving" \
    "last_mail_age=$age_h;$ARCHIVE_WARN_HOURS;$ARCHIVE_CRIT_HOURS;0 mails_24h=$last24 messages_total=$total" \
    "$txt - juengste Mail vor ${age_h}h ($(date -d "@$newest" '+%Y-%m-%d %H:%M' 2>/dev/null)), $last24 in 24h, $total insgesamt"
}

########################### Installationsart ###############################

# Versionsermittlung. Die Installationsart steht bereits in INSTALL_TYPE
# (siehe detect_install), hier geht es nur noch um die Versionsnummern.
piler_versions() {  # -> "paketversion|upstreamversion|binary"
  local pkgver="" ver="" bin="" b
  if [[ "$INSTALL_TYPE" == "paket" ]] && have dpkg-query; then
    pkgver="$(dpkg-query -W -f='${Version}' "$PILER_PKG" 2>/dev/null)"
    ver="$(sed -E 's/^[0-9]+://; s/-.*$//' <<< "$pkgver")"
  fi

  for b in "${INSTALL_PREFIX}/sbin/piler" "${INSTALL_PREFIX}/bin/piler" \
           /usr/sbin/piler /usr/bin/piler /usr/local/sbin/piler; do
    [[ -x "$b" ]] && { bin="$b"; break; }
  done

  # Ohne Paket bleibt nur das Binary selbst als Versionsquelle.
  if [[ -z "$ver" && -n "$bin" ]]; then
    ver="$(timeout 10 "$bin" -V 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"
    [[ -n "$ver" ]] || ver="$(timeout 10 "$bin" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)"
  fi

  printf '%s|%s|%s\n' "$pkgver" "$ver" "$bin"
}

########################### Update #########################################

piler_upstream() {  # -> hoechster Release-Tag im piler-Git
  local v=""
  if have git; then
    v="$(timeout "$GIT_TIMEOUT" git ls-remote --tags --refs "$PILER_GIT_REPO" 2>/dev/null \
         | sed -nE 's#.*refs/tags/piler-([0-9][0-9.]*)$#\1#p' | sort -V | tail -n1)"
  fi
  if [[ -z "$v" ]] && have curl; then
    v="$(timeout "$GIT_TIMEOUT" curl -fsS "$PILER_GIT_API" 2>/dev/null \
         | sed -nE 's/.*"tag_name"[[:space:]]*:[[:space:]]*"piler-([^"]+)".*/\1/p' | head -n1)"
  fi
  printf '%s\n' "$v"
}

# Hoechste Version des Pakets, die aus dem eigenen Repo verfuegbar ist.
# Gelesen wird der lokale apt-Cache - das Plugin macht bewusst KEIN
# "apt-get update", das gehoert in den apt-Timer des Systems.
piler_repo_version() {
  have apt-cache || return 0
  apt-cache madison "$PILER_PKG" 2>/dev/null \
    | awk -F'|' -v m="$PILER_REPO_MATCH" '
        index($3, m) > 0 { gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); print $2 }' \
    | sort -V | tail -n1
}

# Wie alt sind die apt-Listen? Ein haengender apt-Timer laesst ein laengst
# veroeffentlichtes Paket unsichtbar werden - der Check meldete dann WARN
# ("noch kein Paket") statt CRIT ("installierbar").
apt_lists_age_days() {
  local newest
  newest="$(stat -c %Y /var/lib/apt/periodic/update-success-stamp 2>/dev/null)"
  if [[ -z "$newest" ]]; then
    newest="$(find /var/lib/apt/lists -maxdepth 1 -type f -name '*Packages*' \
                -printf '%T@\n' 2>/dev/null | sort -n | tail -n1 | cut -d. -f1)"
  fi
  [[ -n "$newest" ]] || return 1
  printf '%s\n' $(( ( $(date +%s) - newest ) / 86400 ))
}

check_update() {
  local info typ inst_full inst_up up repo repo_up stale hint=""
  typ="$INSTALL_TYPE"
  info="$(piler_versions)"
  IFS='|' read -r inst_full inst_up _ <<< "$info"
  up="$(piler_upstream)"
  repo="$(piler_repo_version)"
  repo_up="$(sed -E 's/^[0-9]+://; s/-.*$//' <<< "${repo:-}")"

  stale="$(apt_lists_age_days)" || stale=""
  if [[ -n "$stale" ]] && (( stale > 7 )); then
    hint=" [apt-Listen sind $stale Tage alt - Paketstand evtl. veraltet]"
  fi

  if [[ -z "$inst_up" ]]; then
    emit 3 "Piler Update" "-" \
      "UNKNOWN - installierte Version nicht ermittelbar (Paket '$PILER_PKG' vorhanden?)"
    return 0
  fi
  if [[ -z "$up" ]]; then
    emit 3 "Piler Update" "-" \
      "UNKNOWN - Upstream-Version nicht abrufbar (github erreichbar?), installiert $inst_up"
    return 0
  fi

  local git_newer=0 repo_newer=0
  dpkg --compare-versions "$up" gt "$inst_up" 2>/dev/null && git_newer=1
  if [[ -n "$repo" ]]; then
    if [[ -n "$inst_full" ]]; then
      # Paketinstallation: volle Version vergleichen, damit auch eine reine
      # Paketrevision (1.4.8-1 -> 1.4.8-2) anschlaegt.
      dpkg --compare-versions "$repo" gt "$inst_full" 2>/dev/null && repo_newer=1
    else
      # Legacy/Quellinstallation: es gibt keine Paketrevision zum Vergleichen.
      dpkg --compare-versions "$repo_up" gt "$inst_up" 2>/dev/null && repo_newer=1
    fi
  fi

  local perf="update_upstream=$git_newer;1;;0;1 update_repo=$repo_newer;;1;0;1"

  local shown="${inst_full:-$inst_up}"
  [[ "$typ" == "legacy" ]] && shown="$inst_up (Legacy, kein Paket)"

  if (( repo_newer && git_newer )); then
    emit 2 "Piler Update" "$perf" \
      "CRIT - Update in beiden Quellen verfuegbar, aber nicht installiert: piler-Git $up, ${PILER_REPO_MATCH}-Repo $repo, installiert $shown$hint"
  elif (( repo_newer )); then
    emit 2 "Piler Update" "$perf" \
      "CRIT - Paket-Update im ${PILER_REPO_MATCH}-Repo verfuegbar: $repo, installiert $shown$hint"
  elif (( git_newer )); then
    emit 1 "Piler Update" "$perf" \
      "WARN - piler $up ist released, aber noch kein Paket im ${PILER_REPO_MATCH}-Repo (dort: ${repo:-keins}), installiert $shown$hint"
  else
    emit 0 "Piler Update" "$perf" \
      "OK - piler $inst_up ist aktuell (Upstream $up, Repo ${repo:-n/a}), installiert $shown$hint"
  fi
}

########################### Ausgabe sammeln ################################

collect() {
  echo "<<<local:sep(0)>>>"

  if ! have systemctl; then
    emit 2 "Piler" "-" "CRIT - systemctl nicht gefunden"
    return 0
  fi

  local tmpdir
  tmpdir="$(mktemp -d)" || return 0
  trap 'rm -rf "$tmpdir"' RETURN

  pool_init || SEM_FD=""

  # Der Update-Check spricht mit github und dominiert die Laufzeit, deshalb
  # laeuft er parallel zu den lokalen Checks.
  pool_run "$tmpdir/01.out" check_installation
  pool_run "$tmpdir/02.out" check_services
  pool_run "$tmpdir/03.out" check_archiving
  pool_run "$tmpdir/04.out" check_update
  wait

  local n
  for n in "$tmpdir"/*.out; do
    [[ -s "$n" ]] && cat "$n"
  done
  return 0
}

########################### Main ###########################################

collect

exit 0