Files
tesm/deploy/tesm-helper
T
alientimandClaude Opus 5 7354ff352b TESM 2.0.0 -- Neubau
Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert
im Zweig SONNET5 erhalten.

Aufbau: apps/tesm (Anwendung), packages/tesm-core (gemeinsamer Kern),
packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd,
privilegierter Helfer), docs, tests. Der Lizenzserver liegt in seinem eigenen
Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich
jedes allein installieren laesst.

Die wichtigsten Unterschiede zum Vorgaenger, jeweils an der Stelle im Code
kommentiert, an der der Fehler entstanden ist:

* Der Webprozess laeuft unprivilegiert. Alles, was Root braucht, geht ueber
  einen einzigen Helfer mit Positivlisten fuer jedes Argument.
* CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen.
* Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet.
* Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert.
* Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft (nginx, Kea).
* Kein Hostname im Lizenz-Fingerabdruck.
* Zwei Installationen auf einem Host stoeren sich nicht (eigener SITE_KEY).
* Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 18:00:06 +02:00

352 lines
12 KiB
Bash

#!/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"
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}"
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
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:-<leer>}"
;;
esac