#!/usr/bin/env bash
#
# CheckMK local check: Caddy TLS-Zertifikate
#
# cmk-cache-interval: 3600
#
#   ohne Parameter : erzeugt <<<local:sep(0)>>> Output
#   --detect       : exit 0 = Plugin wird auf dieser Maschine benoetigt
#   --certs        : Debug - zeigt Datenverzeichnis und gefundene Zertifikate
#
# Benoetigt: bash, openssl; optional curl (Admin-API), jq
#
set -uo pipefail

########################### Konfiguration ##################################
CADDY_ADMIN="${CADDY_ADMIN:-localhost:2019}"
CADDY_UNIT="${CADDY_UNIT:-caddy}"

# Caddy erneuert ACME-Zertifikate, sobald zwei Drittel der Laufzeit vorbei
# sind - bei Let's Encrypt also 30 Tage vor Ablauf. Steht ein Zertifikat bei
# 25 Tagen, laufen die Erneuerungsversuche bereits seit Tagen ins Leere.
# Deshalb frueher als die ueblichen 21/7: hier ist der Ablauf nicht das
# Ereignis, sondern nur die Folge eines seit Tagen kaputten ACME.
CERT_WARN_DAYS="${CERT_WARN_DAYS:-25}"
CERT_CRIT_DAYS="${CERT_CRIT_DAYS:-10}"

# Zertifikate der internen CA (tls internal) leben nur Stunden bis Tage.
# Feste Tagesschwellen wuerden dort dauerhaft CRIT liefern, deshalb wird bei
# kurzer Gesamtlaufzeit relativ zur Laufzeit bewertet.
SHORTLIVED_TOTAL_DAYS="${SHORTLIVED_TOTAL_DAYS:-30}"

# Je Zertifikat ein eigener Service. Bei vielen Domains erzeugt das viele
# Services - Default ist deshalb die Zusammenfassung, die die betroffenen
# Domains im Text nennt.
CADDY_PER_CERT="${CADDY_PER_CERT:-0}"

# ACME-Fehler im Journal der letzten N Stunden
ACME_LOG_HOURS="${ACME_LOG_HOURS:-24}"

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

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

emit() {
  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"
}

# Das Datenverzeichnis haengt am HOME des Dienstbenutzers, nicht am aufrufenden
# Benutzer. "caddy environ" als root wuerde /root/.local/share/caddy melden,
# waehrend der Dienst unter /var/lib/caddy laeuft. Deshalb wird das HOME aus
# der Umgebung des laufenden Prozesses gelesen.
caddy_data_dir() {
  local pid home xdg d
  if have pgrep; then
    pid="$(pgrep -x caddy 2>/dev/null | head -n1)"
    if [[ -n "$pid" && -r "/proc/$pid/environ" ]]; then
      xdg="$(tr '\0' '\n' < "/proc/$pid/environ" | sed -n 's/^XDG_DATA_HOME=//p' | head -n1)"
      home="$(tr '\0' '\n' < "/proc/$pid/environ" | sed -n 's/^HOME=//p' | head -n1)"
      [[ -n "$xdg"  && -d "$xdg/caddy/certificates"  ]] && { printf '%s\n' "$xdg/caddy"; return 0; }
      [[ -n "$home" && -d "$home/.local/share/caddy/certificates" ]] && {
        printf '%s\n' "$home/.local/share/caddy"; return 0; }
    fi
  fi
  for d in /var/lib/caddy/.local/share/caddy /root/.local/share/caddy \
           "${XDG_DATA_HOME:-}/caddy" /home/caddy/.local/share/caddy; do
    [[ -n "$d" && -d "$d/certificates" ]] && { printf '%s\n' "$d"; return 0; }
  done
  return 1
}

# Zertifikatsinventar: "pfad|aussteller|domain"
caddy_certs() {
  local dir="$1" f issuer domain
  [[ -d "$dir/certificates" ]] || return 0
  while IFS= read -r f; do
    [[ -f "$f" ]] || continue
    domain="$(basename "$(dirname -- "$f")")"
    issuer="$(basename "$(dirname "$(dirname -- "$f")")")"
    printf '%s|%s|%s\n' "$f" "$issuer" "$domain"
  done < <(find "$dir/certificates" -type f -name '*.crt' 2>/dev/null | sort)
}

caddy_config() {
  have curl || return 1
  curl -s --max-time 5 "http://${CADDY_ADMIN}/config/" 2>/dev/null
}

# Hostnamen aus der laufenden Config. Ohne jq ueber die "host"- und
# "subjects"-Arrays, das deckt die uebliche Struktur ab.
config_hosts() {
  local json="$1"
  [[ -n "$json" && "$json" != "null" ]] || return 0
  if have jq; then
    printf '%s' "$json" | jq -r '
      [ .. | objects | (.host? // empty, .subjects? // empty) ] | flatten | .[]?' 2>/dev/null
  else
    printf '%s' "$json" \
      | grep -oE '"(host|subjects)"[[:space:]]*:[[:space:]]*\[[^]]*\]' \
      | grep -oE '"[^",[]+"' | tr -d '"'
  fi | grep -vE '^(host|subjects)$' | sort -u
}

########################### debug / detect #################################

if [[ "${1:-}" == "--certs" ]]; then
  d="$(caddy_data_dir)" || { echo "kein Caddy-Datenverzeichnis gefunden"; exit 0; }
  echo "Datenverzeichnis: $d"
  echo "== Zertifikate =="
  caddy_certs "$d" | while IFS='|' read -r f i dom; do printf '  %-40s %s\n' "$dom" "$i"; done
  echo "== Hosts aus der laufenden Config =="
  config_hosts "$(caddy_config)" | sed 's/^/  /'
  exit 0
fi

if [[ "${1:-}" == "--detect" ]]; then
  have caddy || exit 1
  caddy_data_dir >/dev/null 2>&1 && exit 0
  systemctl cat "$CADDY_UNIT.service" >/dev/null 2>&1 && exit 0
  exit 1
fi

########################### Checks #########################################

check_service() {
  local active enabled ver cfg
  active="$(systemctl is-active "$CADDY_UNIT.service" 2>/dev/null)"
  enabled="$(systemctl is-enabled "$CADDY_UNIT.service" 2>/dev/null)"
  ver="$(caddy version 2>/dev/null | head -n1)"
  cfg="$(caddy_config)"

  if [[ "$active" != "active" ]]; then
    emit 2 "Caddy Service" "-" "CRIT - $CADDY_UNIT ist $active (${ver:-Version unbekannt})"
  elif [[ -z "$cfg" || "$cfg" == "null" ]]; then
    # Der Dienst laeuft, die Admin-API antwortet aber nicht - dann ist auch
    # die Konfigurationspruefung unten blind.
    emit 1 "Caddy Service" "-" \
      "WARN - $CADDY_UNIT laeuft, Admin-API auf $CADDY_ADMIN antwortet aber nicht (${ver:-?})"
  elif [[ "$enabled" != "enabled" ]]; then
    emit 1 "Caddy Service" "-" "WARN - $CADDY_UNIT laeuft, ist aber nicht fuer den Autostart aktiviert ($enabled)"
  else
    emit 0 "Caddy Service" "-" "OK - $CADDY_UNIT aktiv, Admin-API erreichbar (${ver:-?})"
  fi
}

# Bewertet ein Zertifikat und gibt "state|resttage|text" zurueck.
rate_cert() {
  local f="$1" nb na nb_ts na_ts now total_d left_d state txt warn crit
  nb="$(openssl x509 -startdate -noout -in "$f" 2>/dev/null | cut -d= -f2)"
  na="$(openssl x509 -enddate   -noout -in "$f" 2>/dev/null | cut -d= -f2)"
  [[ -n "$na" ]] || { printf '3||Ablaufdatum nicht lesbar\n'; return 0; }
  na_ts="$(date -d "$na" +%s 2>/dev/null)" || { printf '3||Datum nicht lesbar\n'; return 0; }
  nb_ts="$(date -d "${nb:-$na}" +%s 2>/dev/null)" || nb_ts="$na_ts"
  now="$(date +%s)"
  left_d=$(( (na_ts - now) / 86400 ))
  total_d=$(( (na_ts - nb_ts) / 86400 ))

  if (( total_d > 0 && total_d <= SHORTLIVED_TOTAL_DAYS )); then
    # Kurzlebiges Zertifikat (interne CA): relativ zur Gesamtlaufzeit
    warn=$(( total_d / 3 )); crit=$(( total_d / 9 ))
    (( warn < 1 )) && warn=1
    (( crit < 1 )) && crit=1
  else
    warn="$CERT_WARN_DAYS"; crit="$CERT_CRIT_DAYS"
  fi

  if   (( left_d < crit )); then state=2; txt="CRIT"
  elif (( left_d < warn )); then state=1; txt="WARN"
  else                           state=0; txt="OK"
  fi
  printf '%s|%s|%s - laeuft in %s Tagen ab (%s, Laufzeit %sd, Schwellen %s/%s)\n' \
    "$state" "$left_d" "$txt" "$left_d" "$na" "$total_d" "$warn" "$crit"
}

check_certs() {
  local dir="$1" f issuer domain res state left text
  local total=0 worst=0 min_left="" 
  local -a bad=() warned=()

  while IFS='|' read -r f issuer domain; do
    [[ -n "$f" ]] || continue
    total=$(( total + 1 ))
    res="$(rate_cert "$f")"
    state="${res%%|*}"; res="${res#*|}"
    left="${res%%|*}"; text="${res#*|}"

    if [[ "$CADDY_PER_CERT" == "1" ]]; then
      emit "$state" "Caddy Cert $domain" \
        "days_left=${left:-0};$CERT_WARN_DAYS;$CERT_CRIT_DAYS;0" "$text ($issuer)"
    fi

    [[ -n "$left" ]] || continue
    [[ -z "$min_left" ]] && min_left="$left"
    (( left < min_left )) && min_left="$left"
    (( state == 2 )) && bad+=("$domain:${left}d")
    (( state == 1 )) && warned+=("$domain:${left}d")
    (( state > worst )) && worst="$state"
  done < <(caddy_certs "$dir")

  if (( total == 0 )); then
    emit 2 "Caddy Certificates" "certs=0" \
      "CRIT - keine Zertifikate unter $dir/certificates gefunden - ACME hat noch nie erfolgreich ausgestellt"
    return 0
  fi

  local perf="certs=$total certs_bad=$(( ${#bad[@]} + ${#warned[@]} ));1;;0 days_left=${min_left:-0};$CERT_WARN_DAYS;$CERT_CRIT_DAYS;0"
  if (( ${#bad[@]} > 0 )); then
    emit 2 "Caddy Certificates" "$perf" \
      "CRIT - $total Zertifikate, kritisch: ${bad[*]}${warned:+ / knapp: ${warned[*]}} - Erneuerung laeuft seit Tagen ins Leere"
  elif (( ${#warned[@]} > 0 )); then
    emit 1 "Caddy Certificates" "$perf" \
      "WARN - $total Zertifikate, unter Schwelle: ${warned[*]} - Caddy erneuert normalerweise 30 Tage vor Ablauf"
  else
    emit 0 "Caddy Certificates" "$perf" \
      "OK - $total Zertifikate, kuerzeste Restlaufzeit ${min_left:-?} Tage"
  fi
}

# Der eigentliche ACME-Frueherkenner: eine Domain, die Caddy ausliefern soll,
# fuer die aber kein Zertifikat existiert. Das faellt sonst erst auf, wenn ein
# Besucher eine Zertifikatswarnung sieht.
check_coverage() {
  local dir="$1" json h d covered
  local -a have_dom=() missing=()
  json="$(caddy_config)"
  if [[ -z "$json" || "$json" == "null" ]]; then
    emit 3 "Caddy Cert Coverage" "-" \
      "UNKNOWN - Admin-API auf $CADDY_ADMIN nicht erreichbar, Abgleich Config gegen Zertifikate nicht moeglich"
    return 0
  fi

  mapfile -t have_dom < <(caddy_certs "$dir" | cut -d'|' -f3)

  while IFS= read -r h; do
    [[ -n "$h" ]] || continue
    # Platzhalter und Nicht-Hostnamen ueberspringen
    [[ "$h" == *"*"* ]] && continue
    [[ "$h" =~ ^[A-Za-z0-9._-]+\.[A-Za-z]{2,}$ ]] || continue
    covered=0
    for d in ${have_dom[@]+"${have_dom[@]}"}; do
      [[ "$d" == "$h" ]] && { covered=1; break; }
      # Caddy legt Wildcards als "wildcard_.example.com" ab
      if [[ "$d" == wildcard_.* ]]; then
        [[ "$h" == *".${d#wildcard_.}" ]] && { covered=1; break; }
      fi
    done
    (( covered )) || missing+=("$h")
  done < <(config_hosts "$json")

  if (( ${#missing[@]} > 0 )); then
    emit 2 "Caddy Cert Coverage" "hosts_uncovered=${#missing[@]};;1;0" \
      "CRIT - ${#missing[@]} Host(s) aus der Config ohne Zertifikat: ${missing[*]}"
  else
    emit 0 "Caddy Cert Coverage" "hosts_uncovered=0;;1;0" \
      "OK - alle Hosts aus der laufenden Config haben ein Zertifikat"
  fi
}

check_acme_errors() {
  local n
  if ! have journalctl; then
    emit 3 "Caddy ACME" "-" "UNKNOWN - journalctl nicht vorhanden"
    return 0
  fi
  n="$(journalctl -u "$CADDY_UNIT" --since "-${ACME_LOG_HOURS}h" --no-pager 2>/dev/null \
       | grep -ciE 'could not get certificate|obtain certificate|renew.*fail|acme.*error' || true)"
  n="${n:-0}"
  if (( n > 0 )); then
    emit 1 "Caddy ACME" "acme_errors=$n;1;;0" \
      "WARN - $n ACME-Fehlermeldung(en) in den letzten ${ACME_LOG_HOURS}h (journalctl -u $CADDY_UNIT)"
  else
    emit 0 "Caddy ACME" "acme_errors=0;1;;0" "OK - keine ACME-Fehler in den letzten ${ACME_LOG_HOURS}h"
  fi
}

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

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

  local dir
  if ! dir="$(caddy_data_dir)"; then
    emit 3 "Caddy Certificates" "-" \
      "UNKNOWN - kein Caddy-Datenverzeichnis gefunden (laeuft der Dienst? CADDY_ADMIN/HOME pruefen)"
    check_service
    return 0
  fi

  check_service
  check_certs "$dir"
  check_coverage "$dir"
  check_acme_errors
  return 0
}

collect
exit 0