#!/usr/bin/env bash
#
# CheckMK local check: Samba Fileserver (Domain Member)
#
# cmk-cache-interval: 3600
#
#   ohne Parameter : erzeugt <<<local:sep(0)>>> Output
#   --detect       : exit 0 = Plugin wird auf dieser Maschine benoetigt
#
# Benoetigt: bash, samba-tool/net, testparm, wbinfo
#
# Achtung: kein "set -e" - viele Aufrufe duerfen fehlschlagen, ohne dass das
# Plugin abbricht. Fehler werden pro Service in einen Status uebersetzt.
set -uo pipefail

# Samba-Werkzeuge lokalisieren ihre Ausgaben. Ohne feste Locale sind Datums-
# und Statuszeilen je nach Systemsprache anders geschrieben und nicht mehr
# parsebar - deshalb alles unter LC_ALL=C ausfuehren.
export LC_ALL=C LANG=C

########################### Konfiguration ##################################
MAX_PARALLEL="${MAX_PARALLEL:-$(nproc 2>/dev/null || echo 4)}"
CMD_TIMEOUT="${CMD_TIMEOUT:-60}"

# smbd ist Pflicht, winbind praktisch immer. nmbd nur, wenn NetBIOS gebraucht
# wird - fehlt es, ist das kein Fehler.
MEMBER_UNITS="${MEMBER_UNITS:-smbd winbind}"
MEMBER_UNITS_OPTIONAL="${MEMBER_UNITS_OPTIONAL:-nmbd}"

# Zeitversatz zum DC. Kerberos bricht ab 300s zusammen; CRIT bei 240s laesst
# eine Minute Vorlauf.
TIME_WARN_SEC="${TIME_WARN_SEC:-120}"
TIME_CRIT_SEC="${TIME_CRIT_SEC:-240}"

# Alter des Maschinenkontopassworts. Samba erneuert es per Default alle 7 Tage
# (machine password timeout = 604800); bleibt es lange stehen, laeuft die
# Erneuerung nicht mehr und der Trust kippt irgendwann.
MACHINE_PW_WARN_DAYS="${MACHINE_PW_WARN_DAYS:-45}"
MACHINE_PW_CRIT_DAYS="${MACHINE_PW_CRIT_DAYS:-90}"

MEMBER_PORTS="${MEMBER_PORTS:-139 445}"

########################### 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"
}

tp() { testparm -s --parameter-name="$1" 2>/dev/null | tail -n1 | sed 's/[[:space:]]*$//'; }

is_container() {
  local v
  v="$(systemd-detect-virt -c 2>/dev/null)"
  [[ -n "$v" && "$v" != "none" ]] || return 1
  printf '%s\n' "$v"
}

# "net ads info" wird von mehreren Checks gebraucht (DC, Zeitversatz,
# Maschinenkonto) und kostet einen Netzwerk-Roundtrip - deshalb einmal holen
# und ueber eine Datei an die parallelen Jobs weiterreichen.
ADSINFO=""
ads_info() {
  [[ -n "$ADSINFO" && -r "$ADSINFO" ]] && { cat "$ADSINFO"; return 0; }
  timeout "$CMD_TIMEOUT" net ads info 2>/dev/null
}
ads_field() {  # $1 = Feldname
  ads_info | sed -nE "s/^$1:[[:space:]]*(.*)$/\1/p" | head -n1
}

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

# testparm meldet die BERECHNETE Rolle auf stderr ("Server role: ROLE_...").
# --parameter-name='server role' liefert dagegen nur den KONFIGURIERTEN Wert -
# und der steht bei einem Member ueblicherweise gar nicht in der smb.conf,
# dann kommt der Default "AUTO" zurueck. Die Rolle wird dort aus "security"
# abgeleitet, nicht aus dem Parameter.
server_role() {
  local r
  r="$(timeout "$CMD_TIMEOUT" testparm -s 2>&1 >/dev/null \
       | sed -nE 's/^Server role:[[:space:]]*(.*)$/\1/p' | head -n1)"
  [[ -n "$r" ]] || r="$(tp 'server role')"
  printf '%s\n' "$r"
}

is_member() {
  have testparm || return 1
  have smbd || [[ -x /usr/sbin/smbd ]] || return 1
  local role sec
  role="$(server_role)"
  case "${role^^}" in
    *ROLE_DOMAIN_MEMBER*|*"MEMBER SERVER"*)          return 0 ;;
    *ROLE_ACTIVE_DIRECTORY_DC*|*"DOMAIN CONTROLLER"*) return 1 ;;
    *ROLE_STANDALONE*|*STANDALONE*)                   return 1 ;;
  esac
  # Rolle nicht eindeutig (z.B. "AUTO"): ueber security/realm entscheiden.
  sec="$(tp security)"
  [[ "${sec^^}" == "ADS" && -n "$(tp realm)" ]]
}

if [[ "${1:-}" == "--detect" ]]; then
  is_member && 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() {
  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"; } &
}

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

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

  for u in $MEMBER_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|static|indirect|enabled-runtime)$ ]] || notenabled+=("$u:${enabled:-?}")
    else
      dead+=("$u:${active:-nicht-vorhanden}")
    fi
  done

  local perf="services_running=$running;;;0;$total services_failed=${#dead[@]};;1;0"
  if (( ${#dead[@]} > 0 )); then
    emit 2 "Samba Member Services" "$perf" "CRIT - $running/$total aktiv, nicht aktiv: ${dead[*]}"
  elif (( ${#notenabled[@]} > 0 )); then
    emit 1 "Samba Member Services" "$perf" \
      "WARN - alle $total aktiv, aber nicht fuer den Autostart aktiviert: ${notenabled[*]}"
  else
    emit 0 "Samba Member Services" "$perf" "OK - $total Dienste aktiv und aktiviert ($MEMBER_UNITS)"
  fi
}

check_config() {
  local out errs
  out="$(timeout "$CMD_TIMEOUT" testparm -s 2>&1 >/dev/null)"
  errs="$(grep -icE '^(ERROR|Unknown parameter)' <<< "$out")"
  if (( errs > 0 )); then
    emit 1 "Samba Member Config" "config_errors=$errs;1;;0" \
      "WARN - testparm meldet $errs Problem(e): $(grep -iE '^(ERROR|Unknown parameter)' <<< "$out" | head -n3 | paste -sd'; ' -)"
  else
    emit 0 "Samba Member Config" "config_errors=0;1;;0" "OK - smb.conf ohne Beanstandung"
  fi
}

# Der zentrale Check eines Members: haelt das Maschinenkonto noch?
# "net ads testjoin" prueft das Geheimnis gegen den DC, "wbinfo -t" die
# Vertrauensstellung ueber winbind - beide koennen unabhaengig kippen.
check_join() {
  local out rc wb wbrc
  out="$(timeout "$CMD_TIMEOUT" net ads testjoin 2>&1)"; rc=$?
  wb="$(timeout "$CMD_TIMEOUT" wbinfo -t 2>&1)"; wbrc=$?

  if (( rc != 0 )); then
    emit 2 "Samba Member Domain Join" "join_ok=0;;1;0;1" \
      "CRIT - net ads testjoin fehlgeschlagen: ${out%%$'\n'*} (Reparatur: net ads join -U <admin>)"
  elif (( wbrc != 0 )); then
    emit 2 "Samba Member Domain Join" "join_ok=0;;1;0;1" \
      "CRIT - testjoin ok, aber winbind meldet den Trust als defekt: ${wb%%$'\n'*}"
  else
    emit 0 "Samba Member Domain Join" "join_ok=1;;1;0;1" "OK - Domain-Join und Vertrauensstellung intakt"
  fi
}

check_winbind() {
  local ping onl rc
  if ! have wbinfo; then
    emit 3 "Samba Member Winbind" "-" "UNKNOWN - wbinfo nicht vorhanden"
    return 0
  fi
  ping="$(timeout "$CMD_TIMEOUT" wbinfo -p 2>&1)"; rc=$?
  if (( rc != 0 )); then
    emit 2 "Samba Member Winbind" "winbind_online=0;;1;0;1" \
      "CRIT - winbind antwortet nicht auf ping: ${ping%%$'\n'*}"
    return 0
  fi
  onl="$(timeout "$CMD_TIMEOUT" wbinfo --online-status 2>&1)"
  # Ein winbind im Offline-Modus bedient Anmeldungen aus dem Cache weiter -
  # es sieht also alles normal aus, bis der Cache nicht mehr reicht.
  if grep -qi 'offline' <<< "$onl"; then
    emit 2 "Samba Member Winbind" "winbind_online=0;;1;0;1" \
      "CRIT - winbind ist im OFFLINE-Modus, Anmeldungen laufen nur noch aus dem Cache: $(tr '\n' ' ' <<< "$onl")"
  else
    emit 0 "Samba Member Winbind" "winbind_online=1;;1;0;1" \
      "OK - winbind online: $(tr '\n' ' ' <<< "$onl" | cut -c1-200)"
  fi
}

# Prueft die komplette idmap-Kette Name -> SID -> GID, ohne die Domain
# aufzuzaehlen (was bei grossen Domains minutenlang dauert).
#
# Aufgeloest wird ueber die RID, NICHT ueber den Gruppennamen: "Domain Users"
# heisst in einer deutschen Domain "Domaenen-Benutzer", franzoesisch
# "Utilisateurs du domaine" - und kann zusaetzlich umbenannt worden sein. Die
# RID 513 ist dagegen in jeder Windows- und Samba-Domain dieselbe.
check_idmap() {
  local dom sid dom_sid gid name
  if ! have wbinfo; then
    emit 3 "Samba Member ID Mapping" "-" "UNKNOWN - wbinfo nicht vorhanden"
    return 0
  fi

  dom="$(tp workgroup)"
  # Domain-SID ueber winbind; --own-domain als Rueckfall, falls workgroup
  # nicht zum Kurznamen der Domain passt.
  dom_sid="$(timeout "$CMD_TIMEOUT" wbinfo -D "$dom" 2>/dev/null \
             | sed -nE 's/^SID[[:space:]]*:[[:space:]]*(S-[0-9-]+).*/\1/p' | head -n1)"
  if [[ -z "$dom_sid" ]]; then
    dom="$(timeout "$CMD_TIMEOUT" wbinfo --own-domain 2>/dev/null | tr -d '[:space:]')"
    dom_sid="$(timeout "$CMD_TIMEOUT" wbinfo -D "$dom" 2>/dev/null \
               | sed -nE 's/^SID[[:space:]]*:[[:space:]]*(S-[0-9-]+).*/\1/p' | head -n1)"
  fi
  if [[ -z "$dom_sid" ]]; then
    emit 2 "Samba Member ID Mapping" "-" \
      "CRIT - Domain-SID nicht ermittelbar (wbinfo -D ${dom:-?}) - winbind hat keine Verbindung zur Domain"
    return 0
  fi

  sid="${dom_sid}-513"                                  # RID 513 = Domain Users
  gid="$(timeout "$CMD_TIMEOUT" wbinfo -Y "$sid" 2>/dev/null | tr -d '[:space:]')"
  name="$(timeout "$CMD_TIMEOUT" wbinfo -s "$sid" 2>/dev/null | sed -E 's/[[:space:]]+[0-9]+$//')"

  if [[ -z "$gid" ]]; then
    emit 2 "Samba Member ID Mapping" "-" \
      "CRIT - SID $sid (${name:-Domain Users}) laesst sich nicht auf eine GID abbilden - idmap-Backend pruefen"
  else
    emit 0 "Samba Member ID Mapping" "-" \
      "OK - ${name:-Domain Users} -> $sid -> GID $gid"
  fi
}

check_dc() {
  local ldap kdc realm
  ldap="$(ads_field 'LDAP server name')"
  kdc="$(ads_field 'KDC server')"
  realm="$(ads_field 'Realm')"
  if [[ -z "$ldap" && -z "$kdc" ]]; then
    emit 2 "Samba Member DC" "-" \
      "CRIT - kein DC erreichbar (net ads info liefert nichts) - DNS/SRV-Records und Netzwerk pruefen"
  else
    emit 0 "Samba Member DC" "-" "OK - Realm ${realm:-?}, LDAP ${ldap:-?}, KDC ${kdc:-?}"
  fi
}

# Auf einem Member ist nicht der lokale NTP-Zustand massgeblich, sondern der
# Versatz zum DC - das ist die Zahl, an der Kerberos scheitert. "net ads info"
# liefert sie direkt und autoritativ.
check_time() {
  local off ct src="net ads info" state=0 txt="OK" abs int prefix="" hint="" note=""
  ct="$(is_container)" || ct=""
  off="$(ads_field 'Server time offset')"

  if [[ -z "$off" ]]; then
    emit 3 "Samba Member Time" "-" \
      "UNKNOWN - Zeitversatz zum DC nicht ermittelbar (net ads info liefert kein 'Server time offset')"
    return 0
  fi

  abs="${off#-}"; int="${abs%%.*}"; int="${int:-0}"
  if   (( int >= TIME_CRIT_SEC )); then state=2; txt="CRIT"
  elif (( int >= TIME_WARN_SEC )); then state=1; txt="WARN"
  fi

  if [[ -n "$ct" ]]; then
    # Im Container kommt die Uhr vom Host-Kernel; ein eigener Zeitdienst waere
    # hier sogar falsch. Bewertet wird trotzdem der Versatz zum DC, denn
    # Kerberos scheitert unabhaengig davon, wer die Uhr stellt.
    prefix="Container $ct, Systemuhr kommt vom Host-Kernel - "
    (( state > 0 )) && hint=" - Korrektur gehoert auf den LXC-Host, nicht in den Container"
  else
    # Kein Container: hier muss lokal ein Zeitdienst laufen, sonst laeuft die
    # Uhr frueher oder spaeter vom DC weg.
    local u found=0
    for u in chrony chronyd ntpsec ntp ntpd openntpd systemd-timesyncd; do
      [[ "$(systemctl is-active "$u.service" 2>/dev/null)" == "active" ]] && { found=1; break; }
    done
    if (( found == 0 )); then
      note=" - kein lokaler Zeitdienst aktiv, die Uhr wird zwangslaeufig wegdriften"
      (( state < 1 )) && { state=1; txt="WARN"; }
    fi
  fi

  emit "$state" "Samba Member Time" "time_offset=$abs;$TIME_WARN_SEC;$TIME_CRIT_SEC;0" \
    "$txt - ${prefix}Versatz zum DC ${off}s (via $src, Kerberos scheitert ab 300s)${note}${hint}"
}

check_machine_password() {
  local raw ts age_d state=0 txt="OK"
  raw="$(ads_field 'Last machine account password change')"
  if [[ -z "$raw" ]]; then
    emit 3 "Samba Member Machine Password" "-" "UNKNOWN - Zeitpunkt der letzten Aenderung nicht ermittelbar"
    return 0
  fi
  ts="$(date -d "$raw" +%s 2>/dev/null)" || ts=""
  if [[ -z "$ts" ]]; then
    emit 3 "Samba Member Machine Password" "-" "UNKNOWN - Datum '$raw' nicht lesbar"
    return 0
  fi
  age_d=$(( ( $(date +%s) - ts ) / 86400 ))
  if   (( age_d >= MACHINE_PW_CRIT_DAYS )); then state=2; txt="CRIT"
  elif (( age_d >= MACHINE_PW_WARN_DAYS )); then state=1; txt="WARN"
  fi
  emit "$state" "Samba Member Machine Password" \
    "machine_pw_age=$age_d;$MACHINE_PW_WARN_DAYS;$MACHINE_PW_CRIT_DAYS;0" \
    "$txt - Maschinenkonto zuletzt vor ${age_d}d geaendert ($raw)"
}

# Ein Share, dessen Pfad nicht existiert, ist die haeufigste stille Stoerung:
# smbd laeuft, die Freigabe ist sichtbar, der Zugriff scheitert. Typisch nach
# einem Reboot mit nicht eingehaengtem Datentraeger.
check_shares() {
  local line share path total=0
  local -a missing=()
  while IFS='|' read -r share path; do
    [[ -n "$share" && -n "$path" ]] || continue
    case "$share" in global|printers|print\$|IPC\$) continue ;; esac
    # Pfade mit Makros (%U, %H, ...) sind erst zur Laufzeit aufloesbar
    [[ "$path" == *%* ]] && continue
    total=$(( total + 1 ))
    [[ -d "$path" ]] || missing+=("$share:$path")
  done < <(
    timeout "$CMD_TIMEOUT" testparm -s 2>/dev/null | awk '
      /^\[.*\]$/ { s = substr($0, 2, length($0) - 2); next }
      /^[[:space:]]*path[[:space:]]*=/ {
        sub(/^[[:space:]]*path[[:space:]]*=[[:space:]]*/, "")
        if (s != "") print s "|" $0
      }'
  )

  if (( total == 0 )); then
    emit 0 "Samba Member Shares" "shares=0 shares_missing=0;;1;0" "OK - keine Freigaben mit festem Pfad definiert"
  elif (( ${#missing[@]} > 0 )); then
    emit 2 "Samba Member Shares" "shares=$total shares_missing=${#missing[@]};;1;0" \
      "CRIT - ${#missing[@]} von $total Freigabepfaden existieren nicht: ${missing[*]} (Datentraeger nicht eingehaengt?)"
  else
    emit 0 "Samba Member Shares" "shares=$total shares_missing=0;;1;0" \
      "OK - alle $total Freigabepfade vorhanden"
  fi
}

check_ports() {
  local p open=0 total=0
  local -a closed=()
  for p in $MEMBER_PORTS; do
    total=$(( total + 1 ))
    if timeout 3 bash -c ": < /dev/tcp/127.0.0.1/$p" 2>/dev/null; then
      open=$(( open + 1 ))
    else
      closed+=("$p")
    fi
  done
  if (( ${#closed[@]} > 0 )); then
    emit 2 "Samba Member Ports" "ports_open=$open;;;0;$total" \
      "CRIT - $open/$total offen, geschlossen: ${closed[*]}"
  else
    emit 0 "Samba Member Ports" "ports_open=$open;;;0;$total" "OK - alle $total SMB-Ports erreichbar"
  fi
}

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

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

  if ! is_member; then
    emit 3 "Samba Member" "-" "UNKNOWN - kein Samba Domain Member (server role passt nicht)"
    return 0
  fi

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

  # net ads info einmal holen und teilen - sonst macht jeder der drei
  # abhaengigen Checks einen eigenen Roundtrip zum DC.
  ADSINFO="$tmpdir/adsinfo"
  timeout "$CMD_TIMEOUT" net ads info > "$ADSINFO" 2>/dev/null

  local realm wg ver ct
  realm="$(tp realm)"; wg="$(tp workgroup)"
  ver="$(smbd --version 2>/dev/null | head -n1)"
  ct="$(is_container)" || ct=""
  emit 0 "Samba Member Status" "-" \
    "OK - Realm ${realm:-?}, Workgroup ${wg:-?}, ${ver:-Version unbekannt}${ct:+, Container $ct}"

  pool_init || SEM_FD=""

  pool_run "$tmpdir/01.out" check_services
  pool_run "$tmpdir/02.out" check_config
  pool_run "$tmpdir/03.out" check_join
  pool_run "$tmpdir/04.out" check_winbind
  pool_run "$tmpdir/05.out" check_idmap
  pool_run "$tmpdir/06.out" check_dc
  pool_run "$tmpdir/07.out" check_time
  pool_run "$tmpdir/08.out" check_machine_password
  pool_run "$tmpdir/09.out" check_shares
  pool_run "$tmpdir/10.out" check_ports
  wait

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

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

collect

exit 0