#!/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//environ). read_stdin_limited > "$creds" # Der aufrufende Dienst laeuft in einem eigenen Mount-Namensraum # (ProtectSystem, PrivateTmp, ReadWritePaths). Ein Einbinden *darin* # scheitert mit "mount error(1): Operation not permitted" -- gemessen: mit # denselben Optionen und denselben Zugangsdaten gelingt es aus einer # normalen Root-Shell sofort. Deshalb wird im Namensraum des Wirts # eingebunden; von dort wird der Mount in den Namensraum des Dienstes # weitergegeben (er haengt als "slave" darunter), die Anwendung sieht ihn # also. Die Zugangsdatei unter /run ist ueber die Grenze lesbar, /run ist # nicht privatisiert. im_wirt=(nsenter --mount=/proc/1/ns/mnt --) command -v nsenter >/dev/null 2>&1 || im_wirt=() "${im_wirt[@]}" mkdir -p "$target" if "${im_wirt[@]}" 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: der Mount liegt im Namensraum des Wirts, also # muss er auch dort geloest werden. Ein umount im Dienst-Namensraum wuerde # ihn nur dort ausblenden und die Freigabe bliebe eingebunden. im_wirt=(nsenter --mount=/proc/1/ns/mnt --) command -v nsenter >/dev/null 2>&1 || im_wirt=() "${im_wirt[@]}" umount -l "$1" || true "${im_wirt[@]}" 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 export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq "$@" ;; 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:-}" ;; esac