#!/bin/bash
# =============================================================================
# tesm-helper -- die einzige Stelle, an der TESM Root-Rechte bekommt.
#
# Der Webprozess laeuft unprivilegiert. Alles, was Root braucht, geht durch
# dieses Skript: es gehoert root, ist per sudoers mit NOPASSWD nur fuer genau
# diesen Pfad freigegeben und prueft jedes Argument gegen eine Positivliste.
#
# Damit ist die Sicherheitsgrenze klein, an einer Stelle und pruefbar -- statt
# ueber tausende Zeilen Anwendungscode verteilt. Der Vorgaenger liess die
# komplette Flask-Anwendung als root laufen; eine einzige Luecke in irgendeinem
# Pfad haette den Host bedeutet.
#
# Regeln fuer Aenderungen an dieser Datei:
#   * Jedes neue Verb braucht eine Argumentpruefung. Ohne Pruefung kein Verb.
#   * Nie "$@" ungeprueft an eine Shell weiterreichen.
#   * Nie einen Pfad akzeptieren, der nicht gegen eine feste Liste geprueft ist.
# =============================================================================
set -euo pipefail
umask 022

APP_KEYS_ALLOWED=("tesm" "tesm-license")
SERVICES_ALLOWED=(
  "tesm.service" "tesm-monitor.service"
  "tesm-license.service"
  "kea-dhcp4-server" "kea-dhcp4-server.service"
  "nginx" "nginx.service"
)
PACKAGES_ALLOWED=("kea-dhcp4-server" "kea-common" "cifs-utils" "certbot" "expect"
                  "samba-common-bin")

fail() { echo "tesm-helper: $*" >&2; exit 2; }

require_root() {
  [[ "$(id -u)" -eq 0 ]] || fail "muss als root laufen (ueber sudo aufrufen)"
}

# -- Argumentpruefungen ------------------------------------------------------

check_in_list() {
  local needle="$1"; shift
  local item
  for item in "$@"; do
    [[ "$needle" == "$item" ]] && return 0
  done
  return 1
}

# Name einer Installation auf diesem Host: entweder die Hauptinstallation
# ("tesm") oder eine benannte Instanz daneben ("tesm-opus"). Mehr als diese
# beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen
# Pfad ausserhalb des vorgesehenen Namensraums erzeugen.
check_app_key() {
  local key="$1" app
  for app in "${APP_KEYS_ALLOWED[@]}"; do
    [[ "$key" == "$app" ]] && return 0
    [[ "$key" == "$app-"* && "${key#"$app"-}" =~ ^[a-z0-9][a-z0-9-]{0,15}$ ]] && return 0
  done
  fail "unbekannter App-Schluessel: $key"
}

check_service() {
  check_in_list "$1" "${SERVICES_ALLOWED[@]}" || fail "Dienst nicht erlaubt: $1"
}

check_hostname() {
  [[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]] || fail "ungueltiger Hostname"
}

check_timezone() {
  [[ "$1" =~ ^[A-Za-z]+(/[A-Za-z0-9_+-]+){0,2}$ ]] || fail "ungueltige Zeitzone"
  [[ -f "/usr/share/zoneinfo/$1" ]] || fail "unbekannte Zeitzone"
}

check_domain() {
  [[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] || fail "ungueltige Domain"
}

check_email() {
  [[ "$1" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]] || fail "ungueltige E-Mail"
}

check_unc() {
  [[ "$1" =~ ^//[A-Za-z0-9._-]+/[^[:space:]]+$ || "$1" =~ ^\\\\[A-Za-z0-9._-]+\\[^[:space:]]+$ ]] \
    || fail "ungueltiger Freigabepfad"
}

check_mount_target() {
  # Nur unterhalb der bekannten Einbindungswurzeln, kein ".." im Pfad.
  [[ "$1" == /mnt/tesm-shares/* ]] || fail "Einbindungsziel liegt ausserhalb von /mnt/tesm-shares"
  [[ "$1" != *".."* ]] || fail "unzulaessiger Pfad"
}

check_readable_path() {
  # Positivliste der Dateien, die die Anwendung lesen darf, obwohl sie ihr
  # nicht gehoeren. Ohne die ".."-Sperre koennte ein Muster wie
  # /etc/nginx/sites-available/../../../root/tesm die Liste unterlaufen: der
  # Basisname waere "tesm" und damit gueltig, gelesen wuerde aber /root/tesm.
  [[ "$1" != *".."* ]] || fail "unzulaessiger Pfad"
  case "$1" in
    /var/log/kea/kea-dhcp4.log) ;;
    /var/lib/kea/kea-leases4.csv) ;;
    /etc/netplan/90-*.yaml)
      check_app_key "$(basename "$1" .yaml | sed 's/^90-//')" ;;
    /etc/nginx/sites-available/*)
      check_app_key "$(basename "$1")" ;;
    *) fail "Datei nicht in der Leseliste: $1" ;;
  esac
}

read_stdin_limited() {
  # Maximal 1 MiB aus stdin -- verhindert, dass ein Fehler die Platte fuellt.
  head -c 1048576
}

write_atomic() {
  local target="$1" mode="$2" owner="$3"
  local tmp
  tmp="$(mktemp "${target}.XXXXXX")"
  read_stdin_limited > "$tmp"
  chmod "$mode" "$tmp"
  [[ -n "$owner" ]] && chown "$owner" "$tmp"
  mv -f "$tmp" "$target"
}

# -- Verben ------------------------------------------------------------------

verb="${1:-}"; shift || true
require_root

case "$verb" in

  service-status)
    check_service "${1:?Dienst fehlt}"
    systemctl is-active "$1" || true
    systemctl is-enabled "$1" || true
    ;;

  service-start|service-stop|service-restart|service-enable|service-disable)
    check_service "${1:?Dienst fehlt}"
    systemctl "${verb#service-}" "$1"
    ;;

  nginx-test)
    nginx -t
    ;;

  nginx-reload)
    nginx -t && systemctl reload nginx
    ;;

  nginx-write)
    check_app_key "${1:?App-Schluessel fehlt}"
    target="/etc/nginx/sites-available/$1"
    write_atomic "$target" 0644 "root:root"
    ln -sfn "$target" "/etc/nginx/sites-enabled/$1"
    rm -f /etc/nginx/sites-enabled/default
    ;;

  nginx-apply)
    # Schreiben, pruefen, neu laden -- als *eine* Operation, mit Ruecknahme.
    #
    # Getrennte Verben waren hier ein echter Fehler: "nginx-write" verlinkt die
    # Datei sofort nach sites-enabled. Schlug das anschliessende "nginx -t"
    # fehl, blieb eine kaputte Konfiguration aktiviert liegen. Der laufende
    # nginx merkte davon nichts -- aber der naechste Reload oder Neustart durch
    # irgendwen (auch fuer eine voellig andere Site) schlug fehl. Aus einem
    # abgelehnten Formular wurde so ein Ausfall des ganzen Webservers.
    check_app_key "${1:?App-Schluessel fehlt}"
    target="/etc/nginx/sites-available/$1"
    link="/etc/nginx/sites-enabled/$1"
    backup="$(mktemp /run/tesm-nginx.XXXXXX)"
    had_file=0; had_link=0
    [[ -f "$target" ]] && { cp -a "$target" "$backup"; had_file=1; }
    [[ -L "$link" || -e "$link" ]] && had_link=1

    write_atomic "$target" 0644 "root:root"
    ln -sfn "$target" "$link"

    if nginx -t 2>&1; then
      rm -f /etc/nginx/sites-enabled/default
      systemctl reload nginx
      rm -f "$backup"
      echo "uebernommen"
    else
      # Genau in den Zustand zurueck, der vorher galt.
      if [[ $had_file -eq 1 ]]; then
        cp -a "$backup" "$target"
      else
        rm -f "$target"
      fi
      [[ $had_link -eq 1 ]] || rm -f "$link"
      rm -f "$backup"
      # Nach der Ruecknahme muss die Konfiguration wieder sauber sein. Ist sie
      # es nicht, war sie schon vorher kaputt -- das gehoert ins Protokoll.
      nginx -t >/dev/null 2>&1 || echo "Warnung: nginx-Konfiguration war schon vorher fehlerhaft." >&2
      fail "nginx hat die Konfiguration abgelehnt -- alter Stand wiederhergestellt"
    fi
    ;;

  netplan-write)
    check_app_key "${1:?App-Schluessel fehlt}"
    # Eigene Datei je Anwendung: der Vorgaenger schrieb aus beiden Apps nach
    # /etc/netplan/90-tesm.yaml und ueberschrieb sich damit gegenseitig.
    write_atomic "/etc/netplan/90-$1.yaml" 0600 "root:root"
    ;;

  netplan-apply)
    netplan apply
    ;;

  hostname-set)
    check_hostname "${1:?Hostname fehlt}"
    hostnamectl set-hostname "$1"
    ;;

  timezone-set)
    check_timezone "${1:?Zeitzone fehlt}"
    timedatectl set-timezone "$1"
    ;;

  kea-write)
    install -d -o root -g root -m 0755 /etc/kea
    # Kea laeuft als eigener, unprivilegierter Benutzer und muss seine
    # Lease-Datei und sein Protokoll schreiben koennen. Vor dem Test anlegen,
    # sonst beanstandet Kea die fehlenden Verzeichnisse.
    install -d -o _kea -g _kea -m 0750 /var/lib/kea /var/log/kea 2>/dev/null || true

    # Erst pruefen, dann uebernehmen. Vorher wurde jede Konfiguration
    # ungeprueft abgelegt -- eine fehlerhafte fiel erst beim naechsten Start
    # auf, und dann lag der Dienst, bis jemand ins Journal sah.
    tmp="$(mktemp /etc/kea/kea-dhcp4.conf.XXXXXX)"
    chmod 0640 "$tmp"; chown root:_kea "$tmp"
    read_stdin_limited > "$tmp"
    if command -v kea-dhcp4 >/dev/null 2>&1; then
      if ! pruefung="$(kea-dhcp4 -t "$tmp" 2>&1)"; then
        rm -f "$tmp"
        # Nur die aussagekraeftige Zeile zurueckgeben, nicht das ganze Protokoll.
        fail "Konfiguration abgelehnt: $(printf '%s' "$pruefung" | grep -m1 -i "error" || printf '%s' "$pruefung" | tail -1)"
      fi
    fi
    mv -f "$tmp" /etc/kea/kea-dhcp4.conf
    ;;

  logrotate-write)
    check_app_key "${1:?App-Schluessel fehlt}"
    write_atomic "/etc/logrotate.d/$1" 0644 "root:root"
    ;;

  mount-cifs)
    check_unc "${1:?Freigabe fehlt}"
    check_mount_target "${2:?Ziel fehlt}"
    share="${1//\\//}"
    target="$2"
    creds="$(mktemp /run/tesm-cifs.XXXXXX)"
    chmod 600 "$creds"
    # Zugangsdaten kommen ueber stdin in eine 0600-Datei -- nie ueber die
    # Kommandozeile (Prozessliste) und nie ueber die Umgebung
    # (/proc/<pid>/environ).
    read_stdin_limited > "$creds"
    # Eingebunden wird im Mount-Namensraum des aufrufenden Dienstes -- absichtlich:
    # nur dieser Dienst liest die Dateien, und mit ihm verschwinden die
    # Einbindungen wieder. Damit der Syscall durchkommt, braucht die Unit
    # "@mount" im SystemCallFilter. Ohne das scheitert es mit
    # "mount error(1): Operation not permitted"; gemessen war der Syscall-Filter
    # die Ursache, nicht der Namensraum. Ein Umweg ueber nsenter in den
    # Namensraum des Wirts ist keine Option -- "RestrictNamespaces=yes"
    # verbietet setns, und das Recht dafuer wieder aufzugeben waere teurer als
    # der Gewinn.
    mkdir -p "$target"
    if mount -t cifs "$share" "$target" \
         -o "credentials=$creds,uid=$(id -u tesm 2>/dev/null || echo 0),gid=$(id -g tesm 2>/dev/null || echo 0),file_mode=0640,dir_mode=0750,vers=3.1.1,seal"
    then
      shred -u "$creds" 2>/dev/null || rm -f "$creds"
    else
      shred -u "$creds" 2>/dev/null || rm -f "$creds"
      fail "Einbindung fehlgeschlagen"
    fi
    ;;

  umount)
    check_mount_target "${1:?Ziel fehlt}"
    # Gegenstueck zu mount-cifs, und deshalb im selben Namensraum: loesen kann
    # nur, wer die Einbindung auch sieht. Ein Aufruf aus einem anderen Dienst
    # greift ins Leere -- genau deshalb laeuft die Aufraeumaufgabe fuer
    # abgelaufene Einbindungen im Webprozess und nicht im Ueberwachungsdienst.
    umount -l "$1" || true
    rmdir "$1" 2>/dev/null || true
    ;;

  apt-install)
    [[ $# -gt 0 ]] || fail "kein Paket angegeben"
    for package in "$@"; do
      check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package"
    done
    # apt laeuft ausserhalb des Sandkastens des aufrufenden Dienstes. Der wird
    # sonst an dpkg und dessen Maintainer-Skripte weitervererbt, und dort
    # scheitert etwas voellig Beilaeufiges: mit "ProtectKernelLogs=yes" gibt
    # "dmesg" ein EPERM zurueck, ein Postinst-Skript bricht daran ab, und dpkg
    # endet mit Code 1 -- die Installation schlaegt fehl, obwohl mit dem Paket
    # alles in Ordnung ist. Gemessen und mit systemd-run reproduzierbar
    # behoben: PID 1 startet den Prozess, also ohne unsere Haertung.
    export DEBIAN_FRONTEND=noninteractive
    if command -v systemd-run >/dev/null 2>&1 && [[ -S /run/dbus/system_bus_socket ]]; then
      # Zwei getrennte Aufrufe, damit keine Shell dazwischen steht: die
      # Paketnamen gehen als eigene Argumente an apt-get, nicht durch eine
      # Kommandozeile, die sie erst wieder zerlegen muesste.
      ausserhalb=(systemd-run --wait --pipe --collect --quiet
                  --setenv=DEBIAN_FRONTEND=noninteractive)
      "${ausserhalb[@]}" apt-get update -qq
      "${ausserhalb[@]}" apt-get install -y -qq "$@"
    else
      # Kein systemd (Container, Entwicklungsrechner): direkt, wie bisher.
      apt-get update -qq
      apt-get install -y -qq "$@"
    fi
    ;;

  certbot-issue)
    check_domain "${1:?Domain fehlt}"
    check_email "${2:?E-Mail fehlt}"
    webroot="${3:?Webroot fehlt}"
    [[ "$webroot" == /var/www/* && "$webroot" != *".."* ]] || fail "ungueltiger Webroot"
    mkdir -p "$webroot"
    # Bewusst --webroot statt des nginx-Plugins: das Plugin wuerde die von der
    # Anwendung erzeugte Site-Datei selbst umschreiben.
    certbot certonly --webroot -w "$webroot" -d "$1" \
      --email "$2" --agree-tos --non-interactive --keep-until-expiring
    ;;

  selfsigned-issue)
    # Fuer Installationen ohne oeffentliche Domain: ein selbstsigniertes
    # Zertifikat, damit Port 443 ueberhaupt bedient werden kann. Bewusst
    # getrennt vom certbot-Pfad -- hier wird nichts nach draussen gesprochen.
    check_app_key "${1:?App-Schluessel fehlt}"
    subject="${2:?Common Name fehlt}"
    check_domain "$subject"
    dir="/etc/tesm/certs/$1"
    install -d -m 0755 /etc/tesm/certs
    # 0755 auf dem Verzeichnis, nicht 0750: die Anwendung muss das Zertifikat
    # lesen koennen, um Aussteller und Ablauf anzuzeigen. Geheim ist nur der
    # private Schluessel -- der bleibt 0640 root:root und wird ausschliesslich
    # vom nginx-Masterprozess (root) gelesen. Dieselbe Aufteilung wie bei
    # Let's Encrypt: live/ ist begehbar, privkey.pem nicht lesbar.
    install -d -m 0755 "$dir"

    # Alternative Namen: alles, was ab Argument 3 kommt -- jeweils geprueft und
    # nach DNS- bzw. IP-Eintrag sortiert. Kein Argument geht ungeprueft weiter.
    alt="DNS:$subject"
    shift 2
    for name in "$@"; do
      if [[ "$name" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]]; then
        alt="$alt,IP:$name"
      elif [[ "$name" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]]; then
        [[ "$name" == "$subject" ]] || alt="$alt,DNS:$name"
      else
        fail "ungueltiger alternativer Name: $name"
      fi
    done

    openssl req -x509 -newkey rsa:3072 -sha256 -days 825 -nodes       -keyout "$dir/privkey.pem" -out "$dir/fullchain.pem"       -subj "/CN=$subject" -addext "subjectAltName=$alt"       -addext "basicConstraints=critical,CA:FALSE"       -addext "keyUsage=critical,digitalSignature,keyEncipherment"       -addext "extendedKeyUsage=serverAuth" >/dev/null 2>&1       || fail "openssl ist fehlgeschlagen"
    chown root:root "$dir/privkey.pem" "$dir/fullchain.pem"
    chmod 0640 "$dir/privkey.pem"
    # Das Zertifikat selbst ist oeffentlich -- es geht bei jedem
    # TLS-Handschlag an jeden Client.
    chmod 0644 "$dir/fullchain.pem"
    # nginx laeuft als root und liest die Datei selbst -- die Anwendung braucht
    # den privaten Schluessel nie. Nur der Pfad wird zurueckgegeben.
    echo "$dir/fullchain.pem"
    echo "$dir/privkey.pem"
    ;;

  file-read)
    check_readable_path "${1:?Pfad fehlt}"
    [[ -f "$1" ]] || exit 0
    cat "$1"
    ;;

  *)
    fail "unbekanntes Verb: ${verb:-<leer>}"
    ;;
esac
