Files
tesm-license/deploy/tesm-helper
T
alientimandClaude Opus 5 4bc8637a74 Freigaben einbinden: Mount im Namensraum des Wirts
Eine Freigabe wurde nie eingebunden -- "mount error(1): Operation not
permitted". Gemessen: mit denselben Optionen und Zugangsdaten gelingt die
Einbindung aus einer normalen Root-Shell sofort. Ursache ist der eigene
Mount-Namensraum des Dienstes (ProtectSystem, ProtectHome, PrivateTmp,
ReadWritePaths); darin laesst sich kein neues Dateisystem einbinden.

Nachgebaut und belegt: derselbe Aufruf scheitert in einem transienten Unit mit
dieser Haertung und gelingt, sobald er ueber nsenter im Namensraum des Wirts
laeuft. Von dort wird der Mount in den Namensraum des Dienstes weitergegeben,
die Anwendung sieht ihn also -- geprueft, inklusive Lesbarkeit fuer den
Dienstbenutzer.

mount-cifs und umount laufen deshalb ueber nsenter. Ohne nsenter im System
bleibt das Verhalten unveraendert. Die Haertung des Dienstes bleibt vollstaendig
erhalten.

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

368 lines
14 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"
# 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:-<leer>}"
;;
esac