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>
This commit is contained in:
2026-09-02 18:00:06 +02:00
co-authored by Claude Opus 5
parent a193b9d6e9
commit 7354ff352b
227 changed files with 37605 additions and 21187 deletions
+558
View File
@@ -0,0 +1,558 @@
#!/usr/bin/env bash
# =============================================================================
# Installation und Aktualisierung von TESM und TESM-Lizenzserver.
#
# Aufruf (aus dem entpackten Release-Verzeichnis):
# sudo bash deploy/install.sh --app tesm
# sudo bash deploy/install.sh --app tesm-license
# sudo bash deploy/install.sh --app tesm --instance test --port 5100
#
# Die im Vorgaengerprojekt real aufgetretenen Fallen sind hier von vornherein
# vermieden -- jeweils mit Kommentar an der Stelle, wo es zaehlt:
#
# * Die nginx-Site-Datei wird bei jedem Lauf **inhaltlich verglichen** und bei
# Abweichung erneuert. Der Vorgaenger schrieb sie nur, wenn sie fehlte -- ein
# einmal falscher alias-Pfad blieb dadurch ueber Jahre stehen und lieferte
# alle statischen Dateien als 404.
# * pip laeuft immer ueber "python3 -m pip", nie ueber den venv-Shim: dessen
# Shebang baeckt einen absoluten Pfad ein, der bei einer Verzeichnis-
# umbenennung bricht (live auf POETEST reproduziert).
# * Vor jedem Update ein vollstaendiges Backup -- unabhaengig davon, ob sich
# das Schema geaendert hat. Ein reiner Code-Fehler soll durch dasselbe Netz
# laufen.
# * Nach dem Update ein Health-Check mit automatischem Rollback.
# * Instanzdaten (Datenbank, Schluessel, Lizenz) sind vom Abgleich
# ausgeschlossen und werden nie ueberschrieben.
# =============================================================================
set -euo pipefail
APP=""
INSTANCE=""
PORT=""
HTTP_PORT=""
SKIP_PACKAGES=0
ASSUME_YES=0
HTTPS=0
HTTPS_PORT=""
DOMAIN=""
ACME_EMAIL=""
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
info() { printf ' %s\n' "$*"; }
usage() {
cat <<'USAGE'
Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
--app <name> Welche Anwendung installiert wird (Pflicht)
--instance <name> Zusaetzliche Instanz neben der Hauptinstallation.
Erzeugt /srv/<app>-<name>, <app>-<name>.service usw.
Damit laesst sich eine neue Version testen, ohne die
laufende anzufassen.
--port <nummer> Port des Anwendungsservers (Standard: 5000 bzw. 5001)
--http-port <nr> Port, auf dem nginx diese Instanz anbietet (Standard 80).
Fuer eine Testinstanz neben einer laufenden Produktion:
auf Port 80 kann nur eine Site die Vorgabe sein.
--https HTTPS einrichten: Port 443 mit TLS, Port 80 leitet
dorthin um (ausser dem ACME-Pfad), HSTS aktiv,
Sitzungscookie mit Secure-Flag. Ohne --domain wird ein
selbstsigniertes Zertifikat erzeugt (geschlossene Netze);
mit --domain wird zusaetzlich versucht, ein
Let's-Encrypt-Zertifikat zu holen.
--https-port <nr> Wie --https, aber auf abweichendem Port (Testinstanz).
--domain <name> Oeffentlicher Name dieser Installation.
--acme-email <a> Kontaktadresse fuer Let's Encrypt.
--skip-packages Keine Systempakete installieren
--yes Rueckfragen ueberspringen
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--app) APP="${2:?}"; shift 2 ;;
--instance) INSTANCE="${2:?}"; shift 2 ;;
--port) PORT="${2:?}"; shift 2 ;;
--http-port) HTTP_PORT="${2:?}"; shift 2 ;;
--https) HTTPS=1; shift ;;
--https-port) HTTPS_PORT="${2:?}"; HTTPS=1; shift 2 ;;
--domain) DOMAIN="${2:?}"; shift 2 ;;
--acme-email) ACME_EMAIL="${2:?}"; shift 2 ;;
--skip-packages) SKIP_PACKAGES=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) fail "unbekannte Option: $1" ;;
esac
done
[[ -n "$APP" ]] || { usage; fail "--app fehlt"; }
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || fail "--app muss tesm oder tesm-license sein"
# Seit die beiden Anwendungen in getrennten Repositorien liegen, bringt ein
# Auscheckout nur die eigene mit. Ohne diese Pruefung waere die Meldung ein
# Folgefehler tief im Installationslauf statt eines klaren Satzes hier.
[[ -d "$REPO_DIR/apps/$APP" ]] || fail "In diesem Verzeichnis liegt '$APP' nicht ($REPO_DIR/apps/$APP fehlt). Vorhanden: $(
ls -1 "$REPO_DIR/apps" 2>/dev/null | tr '
' ' ' || echo 'nichts')"
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
[[ -z "$INSTANCE" || "$INSTANCE" =~ ^[a-z0-9-]{1,16}$ ]] || fail "ungueltiger Instanzname"
# -- Namensraum --------------------------------------------------------------
NAME="$APP"
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
ROOT="/srv/$NAME"
LOG_DIR="/var/log/$NAME"
SERVICE_USER="$APP"
UNIT="$NAME.service"
MONITOR_UNIT="$NAME-monitor.service"
NGINX_SITE="/etc/nginx/sites-available/$NAME"
ENV_PREFIX="TESM_"
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
if [[ -z "$PORT" ]]; then
PORT=5000
[[ "$APP" == "tesm-license" ]] && PORT=5001
[[ -n "$INSTANCE" ]] && PORT=$((PORT + 100))
fi
if [[ -z "$HTTP_PORT" ]]; then
HTTP_PORT=80
[[ -n "$INSTANCE" ]] && HTTP_PORT=8080
fi
if [[ -z "$HTTPS_PORT" ]]; then
HTTPS_PORT=443
[[ -n "$INSTANCE" ]] && HTTPS_PORT=8443
fi
# Das Secure-Flag am Sitzungscookie darf erst gesetzt werden, wenn es
# tatsaechlich HTTPS gibt -- sonst kommt das Cookie nie beim Browser an und die
# Anmeldung schlaegt ohne erkennbaren Grund fehl.
# Bei einer Aktualisierung zaehlt, was schon gilt: wer HTTPS eingerichtet hat,
# darf es durch ein Update ohne --https nicht verlieren. Ein zurueckgesetztes
# Secure-Flag wuerde die Anmeldung stillschweigend unmoeglich machen -- der
# Browser sendet das Cookie dann weiter nur ueber HTTPS, die Anwendung setzt es
# aber ohne das Flag neu, und beide reden aneinander vorbei.
COOKIE_SECURE=0
if [[ -f "/etc/tesm/$NAME.env" ]] && grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "/etc/tesm/$NAME.env"; then
COOKIE_SECURE=1
fi
[[ $HTTPS -eq 1 ]] && COOKIE_SECURE=1
[[ -z "$DOMAIN" || "$DOMAIN" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] || fail "ungueltige Domain: $DOMAIN"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP_DIR="/srv/$NAME-backup-$TIMESTAMP"
FAILED_DIR="/srv/$NAME-failed-$TIMESTAMP"
# Einziges Signal fuer "schon installiert": die Datenbank ist da.
IS_UPDATE=0
[[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1
step "TESM-Installation"
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
info "Ziel : $ROOT"
info "Dienst : $UNIT auf 127.0.0.1:$PORT"
if [[ $HTTPS -eq 1 ]]; then
info "nginx : Port $HTTPS_PORT (HTTPS), Port $HTTP_PORT leitet um"
elif [[ "$COOKIE_SECURE" == "1" ]]; then
info "nginx : HTTPS ist bereits eingerichtet und bleibt unveraendert"
if [[ -n "$DOMAIN" ]]; then
info "Zertifikat : Lets Encrypt fuer $DOMAIN, sonst selbstsigniert"
else
info "Zertifikat : selbstsigniert (keine Domain angegeben)"
fi
else
info "nginx : Port $HTTP_PORT (nur HTTP)"
fi
info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)"
if [[ $ASSUME_YES -eq 0 ]]; then
read -r -p "Fortfahren? [j/N] " answer
[[ "$answer" =~ ^[jJyY]$ ]] || fail "abgebrochen"
fi
# -- Systempakete ------------------------------------------------------------
if [[ $SKIP_PACKAGES -eq 0 ]]; then
step "Systempakete"
PACKAGES=(python3 python3-venv python3-pip nginx sqlite3 rsync logrotate ca-certificates curl)
if [[ "$APP" == "tesm" ]]; then
PACKAGES+=(iputils-ping openssh-client cifs-utils)
fi
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq "${PACKAGES[@]}"
info "${#PACKAGES[@]} Pakete geprueft"
else
info "Systempakete uebersprungen"
fi
# -- Dienstbenutzer ----------------------------------------------------------
step "Dienstbenutzer"
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
useradd --system --home-dir "$ROOT" --shell /usr/sbin/nologin "$SERVICE_USER"
info "Benutzer $SERVICE_USER angelegt"
else
info "Benutzer $SERVICE_USER existiert"
fi
# Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte
# gibt es ausschliesslich ueber den Helfer.
# -- Backup ------------------------------------------------------------------
if [[ $IS_UPDATE -eq 1 ]]; then
step "Sicherung vor der Aktualisierung"
systemctl stop "$UNIT" 2>/dev/null || true
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
cp -a "$ROOT" "$BACKUP_DIR"
info "Sicherung: $BACKUP_DIR"
fi
# -- Dateien abgleichen ------------------------------------------------------
step "Anwendungsdateien"
mkdir -p "$ROOT"
rsync -a --delete \
--exclude 'venv' \
--exclude 'data' \
--exclude '*.pyc' \
--exclude '__pycache__' \
--exclude '.git' \
--exclude 'tests' \
"$REPO_DIR/" "$ROOT/"
mkdir -p "$ROOT/data" "$LOG_DIR"
info "Dateien abgeglichen (Instanzdaten unter data/ bleiben unangetastet)"
# -- venv --------------------------------------------------------------------
step "Python-Umgebung"
if [[ ! -x "$ROOT/venv/bin/python3" ]]; then
python3 -m venv "$ROOT/venv"
info "venv erzeugt"
fi
# Immer ueber "python3 -m pip" -- der pip-Shim im venv baeckt einen absoluten
# Pfad in seinen Shebang, der nach einer Umbenennung des Verzeichnisses bricht.
"$ROOT/venv/bin/python3" -m pip install --quiet --upgrade pip setuptools wheel
"$ROOT/venv/bin/python3" -m pip install --quiet \
"$ROOT/packages/tesm-licensing" \
"$ROOT/packages/tesm-core" \
"$ROOT/apps/$APP" \
gunicorn
if [[ "$APP" == "tesm" ]]; then
"$ROOT/venv/bin/python3" -m pip install --quiet flask-sock
fi
info "Abhaengigkeiten installiert"
# -- Statische Dateien fuer nginx --------------------------------------------
step "Statische Dateien"
rm -rf "$ROOT/static" "$ROOT/static-core"
CORE_STATIC="$("$ROOT/venv/bin/python3" -c \
'import tesm_core, pathlib; print(pathlib.Path(tesm_core.__file__).parent / "static" / "tesm_core")')"
APP_MODULE="$APP"
[[ "$APP" == "tesm-license" ]] && APP_MODULE="tesm_license"
APP_STATIC="$("$ROOT/venv/bin/python3" -c \
"import $APP_MODULE, pathlib; print(pathlib.Path($APP_MODULE.__file__).parent / 'static')")"
cp -a "$CORE_STATIC" "$ROOT/static-core"
[[ -d "$APP_STATIC" ]] && cp -a "$APP_STATIC" "$ROOT/static" || mkdir -p "$ROOT/static"
info "nginx liefert /core-assets/ und /assets/ direkt aus"
# -- Privilegierter Helfer ---------------------------------------------------
step "Privilegierter Helfer und sudoers"
install -d -o root -g root -m 0755 /usr/local/lib/tesm
install_if_changed() {
local src="$1" dst="$2" mode="$3" owner="${4:-root:root}"
if [[ -f "$dst" ]] && cmp -s "$src" "$dst"; then
return 1
fi
install -o "${owner%%:*}" -g "${owner##*:}" -m "$mode" "$src" "$dst"
return 0
}
install_if_changed "$ROOT/deploy/tesm-helper" /usr/local/lib/tesm/tesm-helper 0755 && \
info "Helfer aktualisiert" || info "Helfer unveraendert"
# visudo -c prueft die Datei, bevor sie aktiv wird -- eine kaputte sudoers-Datei
# waere sonst ein Totalausfall der Administration.
TMP_SUDO="$(mktemp)"
cp "$ROOT/deploy/sudoers/$APP" "$TMP_SUDO"
visudo -c -f "$TMP_SUDO" >/dev/null || fail "sudoers-Datei ist fehlerhaft"
install -o root -g root -m 0440 "$TMP_SUDO" "/etc/sudoers.d/$APP"
rm -f "$TMP_SUDO"
info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen"
# -- Rechte ------------------------------------------------------------------
step "Dateirechte"
chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR"
chown -R root:root "$ROOT/packages" "$ROOT/apps" "$ROOT/deploy" 2>/dev/null || true
chmod 0750 "$ROOT/data"
chmod 0755 "$LOG_DIR"
find "$ROOT/data" -maxdepth 1 -name '*.key*' -o -name 'data.keys' -o -name '*signing_key*' \
| xargs -r chmod 0600
info "Datenverzeichnis 0750, Schluesseldateien 0600"
# -- Umgebungsdatei ----------------------------------------------------------
step "Umgebung"
# Eine Quelle fuer beide Seiten: die systemd-Unit liest sie ueber
# EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das
# wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere
# Datenbank im Arbeitsverzeichnis anlegen.
install -d -o root -g root -m 0755 /etc/tesm
ENV_FILE="/etc/tesm/$NAME.env"
TMP_ENV="$(mktemp)"
{
echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten."
echo "${ENV_PREFIX}BASE_DIR=$ROOT"
echo "${ENV_PREFIX}DATA_DIR=$ROOT/data"
echo "${ENV_PREFIX}LOG_DIR=$LOG_DIR"
# Name dieser Installation auf dem Host. Alles ausserhalb von /srv/<name>
# -- nginx-Site, netplan-Datei, logrotate-Regel, Zertifikatsverzeichnis,
# Sitzungscookie -- laeuft darueber. Ohne das wuerde eine Testinstanz beim
# Speichern der Webserver-Einstellungen die Site der Produktion
# ueberschreiben und beide teilten sich dasselbe Cookie.
echo "${ENV_PREFIX}SITE_KEY=$NAME"
# Genau die Adresse, auf die gunicorn hoert. nginx nimmt sie von hier als
# proxy_pass-Ziel -- so koennen Dienst und Reverse Proxy nicht auseinander
# laufen. Vorher stand die 5000 fest im Code: eine Instanz auf 5100 erzeugte
# damit eine Site, die auf die andere Installation zeigte.
echo "${ENV_PREFIX}BIND=127.0.0.1:$PORT"
echo "${ENV_PREFIX}COOKIE_SECURE=$COOKIE_SECURE"
echo "${ENV_PREFIX}TRUSTED_PROXY_HOPS=1"
echo "PYTHONUNBUFFERED=1"
} > "$TMP_ENV"
install_if_changed "$TMP_ENV" "$ENV_FILE" 0640 "root:$SERVICE_USER" >/dev/null && info "Umgebungsdatei $ENV_FILE geschrieben" || info "Umgebungsdatei unveraendert"
rm -f "$TMP_ENV"
if [[ "$COOKIE_SECURE" == "1" ]]; then
info "COOKIE_SECURE=1 -- die Anmeldung funktioniert damit nur ueber HTTPS."
else
info "Hinweis: COOKIE_SECURE=0 bis HTTPS eingerichtet ist. Danach: install.sh --https"
info " erneut aufrufen oder in $ENV_FILE auf 1 setzen und Dienst neu starten."
fi
# Wrapper, damit die Werkzeuge dieselbe Umgebung sehen wie der Dienst.
ADMIN_BIN="tesm-admin"
[[ "$APP" == "tesm-license" ]] && ADMIN_BIN="tesm-license-admin"
TMP_WRAPPER="$(mktemp)"
cat > "$TMP_WRAPPER" <<WRAPPER
#!/bin/sh
# Von install.sh erzeugt: laedt die Instanzumgebung und ruft das Werkzeug auf.
set -eu
set -a
. "$ENV_FILE"
set +a
exec "$ROOT/venv/bin/$ADMIN_BIN" "\$@"
WRAPPER
install_if_changed "$TMP_WRAPPER" "/usr/local/bin/$NAME-admin" 0755 >/dev/null && info "Werkzeug: $NAME-admin" || true
rm -f "$TMP_WRAPPER"
# -- systemd -----------------------------------------------------------------
step "systemd"
UNITS_CHANGED=0
render_unit() {
local src="$1" dst="$2"
local tmp; tmp="$(mktemp)"
sed -e "s#/srv/$APP#$ROOT#g" \
-e "s#/var/log/$APP#$LOG_DIR#g" \
-e "s#127.0.0.1:5000#127.0.0.1:$PORT#" \
-e "s#127.0.0.1:5001#127.0.0.1:$PORT#" \
-e "/^WorkingDirectory=/a EnvironmentFile=$ENV_FILE" \
"$src" > "$tmp"
install_if_changed "$tmp" "$dst" 0644 && UNITS_CHANGED=1
rm -f "$tmp"
}
render_unit "$ROOT/deploy/systemd/$APP.service" "/etc/systemd/system/$UNIT"
if [[ "$APP" == "tesm" ]]; then
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
fi
[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|| info "Units unveraendert"
# -- logrotate ---------------------------------------------------------------
TMP_ROTATE="$(mktemp)"
sed -e "s#/var/log/$APP/#$LOG_DIR/#" "$ROOT/deploy/logrotate/$APP" > "$TMP_ROTATE"
install_if_changed "$TMP_ROTATE" "/etc/logrotate.d/$NAME" 0644 >/dev/null && info "logrotate aktualisiert"
rm -f "$TMP_ROTATE"
# -- nginx -------------------------------------------------------------------
step "nginx"
TMP_SITE="$(mktemp)"
sed -e "s#/srv/$APP/#$ROOT/#g" \
-e "s#127.0.0.1:5000#127.0.0.1:$PORT#g" \
-e "s#127.0.0.1:5001#127.0.0.1:$PORT#g" \
-e "s#listen 80;#listen $HTTP_PORT;#" \
-e "s#listen \[::\]:80;#listen [::]:$HTTP_PORT;#" \
-e "s#/var/www/$APP-acme#/var/www/$NAME-acme#g" \
"$ROOT/deploy/nginx/$APP.conf" > "$TMP_SITE"
# Auf Port 80 kann nur eine Site die Vorgabe sein. Eine Instanz neben der
# Produktion bekommt darum ueber --http-port einen eigenen Port -- sonst
# verdeckt sie die laufende Installation (nginx meldet dann
# "conflicting server name" und ignoriert eine der beiden).
if [[ -n "$INSTANCE" && "$HTTP_PORT" == "80" ]]; then
warn "Instanz $INSTANCE auf Port 80: sie verdeckt moeglicherweise die Hauptinstallation."
fi
# Inhaltsvergleich statt reiner Existenzpruefung -- genau der Punkt, an dem der
# Vorgaenger einen falschen alias-Pfad jahrelang mitschleppte. Die Anwendung
# schreibt diese Datei spaeter ohnehin selbst neu (Verwaltung -> Webserver);
# hier geht es nur um einen funktionierenden Erststart.
if [[ -f "$NGINX_SITE" ]] && ! cmp -s "$TMP_SITE" "$NGINX_SITE"; then
if grep -qE "alias\s+$ROOT/(static|static-core)/;" "$NGINX_SITE"; then
info "Vorhandene Site-Konfiguration behalten (alias-Pfade stimmen)"
else
warn "Vorhandene Site-Konfiguration zeigt auf falsche Pfade -- wird erneuert"
cp "$NGINX_SITE" "$NGINX_SITE.bak-$TIMESTAMP"
install -m 0644 "$TMP_SITE" "$NGINX_SITE"
fi
elif [[ ! -f "$NGINX_SITE" ]]; then
install -m 0644 "$TMP_SITE" "$NGINX_SITE"
info "Site-Konfiguration angelegt"
fi
TMP_SITE_KEEP="$TMP_SITE"
ln -sfn "$NGINX_SITE" "/etc/nginx/sites-enabled/$NAME"
rm -f /etc/nginx/sites-enabled/default
mkdir -p "/var/www/$NAME-acme"
chown -R www-data:www-data "/var/www/$NAME-acme"
# "alias-Pfade stimmen" heisst noch nicht "nginx akzeptiert die Datei". Eine
# von Hand nachgebesserte Site kann syntaktisch kaputt sein -- und dann
# scheitert nicht nur diese Installation, sondern jeder Reload des Webservers,
# auch fuer alle anderen Sites. Darum: pruefen, und im Zweifel die frisch
# erzeugte Vorlage nehmen.
if ! nginx -t >/dev/null 2>&1; then
warn "nginx lehnt die vorhandene Site-Konfiguration ab -- sie wird durch die Vorlage ersetzt."
[[ -f "$NGINX_SITE" ]] && cp "$NGINX_SITE" "$NGINX_SITE.bak-$TIMESTAMP"
install -m 0644 "$TMP_SITE_KEEP" "$NGINX_SITE"
nginx -t >/dev/null 2>&1 || fail "nginx lehnt auch die Vorlage ab -- bitte 'nginx -t' pruefen. Sicherung: $NGINX_SITE.bak-$TIMESTAMP"
fi
rm -f "$TMP_SITE_KEEP"
systemctl reload nginx
info "nginx neu geladen"
# -- Datenbank und Start -----------------------------------------------------
step "Anwendung starten"
# Das Schema entsteht beim ersten Start durch die Migrationen -- es gibt kein
# separates Init-Skript mehr.
systemctl enable --now "$UNIT" >/dev/null
if [[ "$APP" == "tesm" ]]; then
systemctl enable --now "$MONITOR_UNIT" >/dev/null
fi
# -- Health-Check und Rollback ----------------------------------------------
step "Gesundheitspruefung"
HEALTHY=0
for attempt in $(seq 1 20); do
if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then
HEALTHY=1
info "Antwort nach ${attempt}x"
break
fi
sleep 2
done
if [[ $HEALTHY -eq 0 ]]; then
warn "Die Anwendung antwortet nicht."
if [[ $IS_UPDATE -eq 1 ]]; then
warn "Rollback wird ausgefuehrt."
systemctl stop "$UNIT" 2>/dev/null || true
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
mv "$ROOT" "$FAILED_DIR"
cp -a "$BACKUP_DIR" "$ROOT"
systemctl start "$UNIT" || true
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" || true
fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt.
Fehlgeschlagene Version : $FAILED_DIR
Sicherung : $BACKUP_DIR
Protokoll : journalctl -u $UNIT -n 100"
fi
fail "Neuinstallation fehlgeschlagen. Protokoll: journalctl -u $UNIT -n 100"
fi
URL_HINT="http://<host>:$HTTP_PORT/"
[[ "$HTTP_PORT" == "80" ]] && URL_HINT="http://<host>/"
# HTTPS war schon eingerichtet und wird durch dieses Update nicht angefasst.
[[ $HTTPS -eq 0 && "$COOKIE_SECURE" == "1" ]] && URL_HINT="https://<host>/ (unveraendert)"
# -- HTTPS -------------------------------------------------------------------
# Bewusst erst hier: die Anwendung muss laufen, denn sie erzeugt die
# TLS-Konfiguration selbst (tesm-admin web-setup). Das Installationsskript
# baut sich keine zweite, konkurrierende Vorlage -- genau daran ist der
# Vorgaenger gescheitert.
if [[ $HTTPS -eq 1 ]]; then
step "HTTPS einrichten"
WEB_ARGS=(web-setup --https --apply --self-signed
--http-port "$HTTP_PORT" --https-port "$HTTPS_PORT")
if [[ -n "$DOMAIN" ]]; then
# HSTS nur mit einem Zertifikat, dem Browser auch trauen. Bei einem
# selbstsignierten waere es eine Falle: der Browser merkt sich fuer Monate,
# dass er die Seite nur ueber HTTPS aufrufen darf -- und zeigt bei jedem
# Aufruf eine Warnung, die sich nicht mehr wegklicken laesst.
WEB_ARGS+=(--domain "$DOMAIN")
if [[ -n "$ACME_EMAIL" ]]; then
# Let's Encrypt verlangt ohnehin eine Kontaktadresse. Sie ist damit das
# eindeutige Signal "hol ein oeffentliches Zertifikat" -- ohne sie bleibt
# es bei einem selbstsignierten, auch wenn eine Domain gesetzt ist
# (interne Namen wie lizenz.firma.local kann keine CA bestaetigen).
WEB_ARGS+=(--acme --acme-email "$ACME_EMAIL" --hsts)
info "Versuche Lets Encrypt fuer $DOMAIN; bei Misserfolg selbstsigniert."
else
info "Domain $DOMAIN, aber keine --acme-email: selbstsigniert, ohne HSTS."
fi
else
info "Ohne Domain: selbstsigniertes Zertifikat, ohne HSTS."
fi
if sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" "${WEB_ARGS[@]}"; then
info "HTTPS aktiv."
URL_HINT="https://<host>/"
[[ "$HTTPS_PORT" == "443" ]] || URL_HINT="https://<host>:$HTTPS_PORT/"
else
warn "HTTPS-Einrichtung fehlgeschlagen -- die Installation laeuft weiter ueber HTTP."
warn "Nachtraeglich: sudo -u $SERVICE_USER $NAME-admin web-setup --https --self-signed --hsts --apply"
# Ein Secure-Cookie ohne HTTPS macht die Anmeldung unmoeglich. Lieber
# zurueckdrehen als eine Installation hinterlassen, an der niemand mehr
# ankommt.
if grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "$ENV_FILE"; then
sed -i "s/^${ENV_PREFIX}COOKIE_SECURE=1/${ENV_PREFIX}COOKIE_SECURE=0/" "$ENV_FILE"
systemctl restart "$UNIT"
warn "COOKIE_SECURE auf 0 zurueckgesetzt, damit die Anmeldung weiter funktioniert."
fi
fi
fi
if [[ $IS_UPDATE -eq 1 ]]; then
rm -rf "$BACKUP_DIR"
info "Sicherung entfernt (Aktualisierung erfolgreich)"
fi
# -- Abschluss ---------------------------------------------------------------
step "Fertig"
ADMIN_CMD="tesm-admin"
[[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin"
cat <<SUMMARY
Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}
Verzeichnis : $ROOT
Protokolle : $LOG_DIR
Dienst : systemctl status $UNIT
Erreichbar : $URL_HINT
Naechste Schritte:
SUMMARY
if [[ "$APP" == "tesm-license" ]]; then
cat <<SUMMARY
1. Signaturschluessel erzeugen und sofort sichern:
sudo -u $SERVICE_USER $NAME-admin init-key
2. Eigene Master-Lizenz ausstellen:
sudo -u $SERVICE_USER $NAME-admin bootstrap
3. Administrator anlegen:
sudo -u $SERVICE_USER $NAME-admin create-admin
SUMMARY
else
cat <<SUMMARY
1. Administrator anlegen:
sudo -u $SERVICE_USER $NAME-admin create-admin
2. Lizenzdatei einspielen unter Verwaltung -> Lizenz
SUMMARY
fi
cat <<'SUMMARY'
Danach in der Anwendung unter "Verwaltung -> Webserver und TLS" die Domain
eintragen, ein Zertifikat anfordern und die Konfiguration uebernehmen --
damit laeuft der Zugriff ueber HTTPS auf Port 443 mit Umleitung von 80.
SUMMARY
+14
View File
@@ -0,0 +1,14 @@
# Von TESM installiert.
# copytruncate, weil keiner der schreibenden Prozesse (Web, Ueberwachung, Kea)
# ein Reopen-Signal verarbeitet.
/var/log/tesm/*.log {
weekly
rotate 4
compress
delaycompress
missingok
notifempty
copytruncate
su tesm tesm
create 0640 tesm tesm
}
+14
View File
@@ -0,0 +1,14 @@
# Von TESM installiert.
# copytruncate, weil keiner der schreibenden Prozesse (Web, Ueberwachung, Kea)
# ein Reopen-Signal verarbeitet.
/var/log/tesm-license/*.log {
weekly
rotate 4
compress
delaycompress
missingok
notifempty
copytruncate
su tesm-license tesm-license
create 0640 tesm-license tesm-license
}
+43
View File
@@ -0,0 +1,43 @@
# Erststart-Konfiguration. Ab der ersten Uebernahme in der Anwendung
# (Verwaltung -> Webserver und TLS) wird diese Datei vollstaendig neu
# geschrieben -- inklusive HTTPS auf 443, Umleitung von 80 und HSTS.
server {
listen 80;
listen [::]:80;
server_name _;
location /.well-known/acme-challenge/ {
alias /var/www/tesm-license-acme/.well-known/acme-challenge/;
default_type "text/plain";
}
client_max_body_size 160m;
location /assets/ {
alias /srv/tesm-license/static/;
expires 7d;
access_log off;
}
location /core-assets/ {
alias /srv/tesm-license/static-core/;
expires 7d;
access_log off;
}
location / {
# $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
# abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst
# fehlschlagen.
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:5001;
proxy_http_version 1.1;
proxy_read_timeout 120s;
}
}
+61
View File
@@ -0,0 +1,61 @@
# Erststart-Konfiguration. Ab der ersten Uebernahme in der Anwendung
# (Verwaltung -> Webserver und TLS) wird diese Datei vollstaendig neu
# geschrieben -- inklusive HTTPS auf 443, Umleitung von 80 und HSTS.
server {
listen 80;
listen [::]:80;
server_name _;
location /.well-known/acme-challenge/ {
alias /var/www/tesm-acme/.well-known/acme-challenge/;
default_type "text/plain";
}
client_max_body_size 160m;
location /assets/ {
alias /srv/tesm/static/;
expires 7d;
access_log off;
}
location /core-assets/ {
alias /srv/tesm/static-core/;
expires 7d;
access_log off;
}
location /ws/ {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
# abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst
# fehlschlagen.
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
location / {
# $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
# abweichendem Port wuerde die Origin-Pruefung der Anwendung sonst
# fehlschlagen.
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_read_timeout 120s;
}
}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Paketiert ein Release aus einem vorhandenen Tag.
#
# Auf Windows-Buildmaschinen ist -c core.autocrlf=false zwingend -- sonst landen
# CRLF-Zeilenenden im Tarball und die Shell-Skripte scheitern auf dem Ziel mit
# "bad interpreter". Das ist real passiert.
#
# Der Name kommt aus dem Repository, nicht aus einer festen Zeichenkette: die
# beiden Anwendungen liegen in getrennten Repositorien, und ein
# Lizenzserver-Release, das "tesm-..." heisst, verwechselt man genau einmal.
set -euo pipefail
TAG="${1:?Verwendung: release.sh vX.Y.Z [name]}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# Vorgabe: der Name der einzigen Anwendung im Baum, sonst der Verzeichnisname.
if [[ -n "${2:-}" ]]; then
NAME="$2"
else
mapfile -t APPS < <(ls -1 "$REPO_DIR/apps" 2>/dev/null || true)
if [[ ${#APPS[@]} -eq 1 ]]; then NAME="${APPS[0]}"; else NAME="$(basename "$REPO_DIR")"; fi
fi
OUT="$NAME-$TAG.tar.gz"
git -c core.autocrlf=false -C "$REPO_DIR" archive --format=tar.gz --prefix="$NAME/" -o "$OUT" "$TAG"
echo "Erzeugt: $OUT"
file "$OUT"
echo "Pruefsumme:"; sha256sum "$OUT"
+9
View File
@@ -0,0 +1,9 @@
# Von TESM installiert. Der Dienstbenutzer darf genau ein Programm als root
# aufrufen -- den Helfer. Dessen Argumentpruefung ist die eigentliche
# Sicherheitsgrenze; sie liegt in /usr/local/lib/tesm/tesm-helper und gehoert
# root (Modus 0755).
#
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
# Problem, indem die gesamte Anwendung als root lief.
Defaults:tesm !requiretty
tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
+9
View File
@@ -0,0 +1,9 @@
# Von TESM installiert. Der Dienstbenutzer darf genau ein Programm als root
# aufrufen -- den Helfer. Dessen Argumentpruefung ist die eigentliche
# Sicherheitsgrenze; sie liegt in /usr/local/lib/tesm/tesm-helper und gehoert
# root (Modus 0755).
#
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
# Problem, indem die gesamte Anwendung als root lief.
Defaults:tesm-license !requiretty
tesm-license ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
+78
View File
@@ -0,0 +1,78 @@
[Unit]
Description=TESM-Lizenzserver
Documentation=file:/srv/tesm-license/docs/BETRIEB.md
After=network-online.target
Wants=network-online.target
[Service]
Type=notify-reload
User=tesm-license
Group=tesm-license
WorkingDirectory=/srv/tesm-license
Environment=TESM_LICENSE_BASE_DIR=/srv/tesm-license
Environment=TESM_LICENSE_DATA_DIR=/srv/tesm-license/data
Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license
Environment=TESM_LICENSE_WEB_PROCESS=1
Environment=TESM_LICENSE_COOKIE_SECURE=1
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/tesm-license/venv/bin/gunicorn \
--workers 1 --worker-class gthread --threads 8 \
--bind 127.0.0.1:5001 \
--access-logfile - --error-logfile - \
tesm_license.wsgi:app
Restart=always
RestartSec=5
# -- Haertung ---------------------------------------------------------------
# Der Dienst braucht kein Root, keine neuen Privilegien und keinen Zugriff auf
# das System ausserhalb seiner eigenen Verzeichnisse. Was er trotzdem braucht
# (systemctl, nginx, netplan), laeuft ueber den Helfer und sudo.
# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer
# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der
# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus
# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der
# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument
# gegen eine Positivliste). Siehe docs/SICHERHEIT.md.
PrivateTmp=yes
PrivateDevices=yes
# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum**
# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx,
# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert
# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc
# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt
# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit
# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser
# Schalter.
ProtectSystem=full
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictSUIDSGID=yes
RestrictRealtime=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
LockPersonality=yes
MemoryDenyWriteExecute=no
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung
# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die
# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter.
UMask=0027
# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr.
# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben
# scheitert der Helfer mit "Read-only file system", obwohl er root ist
# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die
# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu.
ReadWritePaths=/srv/tesm-license/data /var/log/tesm-license /etc/nginx /etc/netplan /etc/logrotate.d /etc/tesm /run
[Install]
WantedBy=multi-user.target
+62
View File
@@ -0,0 +1,62 @@
[Unit]
Description=TESM -- Ueberwachung der PoE-Endgeraete
After=network-online.target tesm.service
Wants=network-online.target
[Service]
Type=simple
User=tesm
Group=tesm
WorkingDirectory=/srv/tesm
Environment=TESM_BASE_DIR=/srv/tesm
Environment=TESM_DATA_DIR=/srv/tesm/data
Environment=TESM_LOG_DIR=/var/log/tesm
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/tesm/venv/bin/tesm-monitor
Restart=always
RestartSec=10
# Das Pruefintervall liest der Prozess bei jedem Durchlauf frisch aus der
# Datenbank -- der naechtliche Neustart-Timer des Vorgaengers entfaellt damit.
# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer
# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der
# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus
# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der
# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument
# gegen eine Positivliste). Siehe docs/SICHERHEIT.md.
PrivateTmp=yes
# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum**
# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx,
# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert
# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc
# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt
# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit
# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser
# Schalter.
ProtectSystem=full
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
LockPersonality=yes
SystemCallArchitectures=native
UMask=0027
# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr.
# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben
# scheitert der Helfer mit "Read-only file system", obwohl er root ist
# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die
# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu.
ReadWritePaths=/srv/tesm/data /var/log/tesm /etc/tesm /run
# ping braucht CAP_NET_RAW -- als einzige Ausnahme, und nur diese.
AmbientCapabilities=CAP_NET_RAW
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung
# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die
# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter.
[Install]
WantedBy=multi-user.target
+78
View File
@@ -0,0 +1,78 @@
[Unit]
Description=TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten
Documentation=file:/srv/tesm/docs/BETRIEB.md
After=network-online.target
Wants=network-online.target
[Service]
Type=notify-reload
User=tesm
Group=tesm
WorkingDirectory=/srv/tesm
Environment=TESM_BASE_DIR=/srv/tesm
Environment=TESM_DATA_DIR=/srv/tesm/data
Environment=TESM_LOG_DIR=/var/log/tesm
Environment=TESM_WEB_PROCESS=1
Environment=TESM_COOKIE_SECURE=1
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/tesm/venv/bin/gunicorn \
--workers 1 --worker-class gthread --threads 16 --timeout 0 \
--bind 127.0.0.1:5000 \
--access-logfile - --error-logfile - \
tesm.wsgi:app
Restart=always
RestartSec=5
# -- Haertung ---------------------------------------------------------------
# Der Dienst braucht kein Root, keine neuen Privilegien und keinen Zugriff auf
# das System ausserhalb seiner eigenen Verzeichnisse. Was er trotzdem braucht
# (systemctl, nginx, netplan), laeuft ueber den Helfer und sudo.
# Bewusst **kein** NoNewPrivileges: der Dienst ruft den privilegierten Helfer
# ueber sudo auf, und sudo ist setuid. Unter NoNewPrivileges verweigert der
# Kernel die Rechteerhoehung, sudo bricht ab -- und damit jede Systemaktion aus
# der Oberflaeche. Die Sicherheitsgrenze liegt nicht hier, sondern in der
# sudoers-Regel (genau ein erlaubter Pfad) und im Helfer selbst (jedes Argument
# gegen eine Positivliste). Siehe docs/SICHERHEIT.md.
PrivateTmp=yes
PrivateDevices=yes
# ProtectSystem=full statt strict: "strict" sperrt den **ganzen Prozessbaum**
# schreibgeschuetzt -- auch den Helfer, der als root gerade /etc/nginx,
# /etc/netplan und /etc/kea schreiben soll. Gemessen: unter "strict" scheitert
# schon "nginx -t". Mit "full" bleiben /usr und /boot schreibgeschuetzt, /etc
# ist im Namensraum wieder erreichbar -- der unprivilegierte Dienst kommt
# trotzdem nicht heran, denn die Dateien gehoeren root. Die Grenze ist damit
# die gewoehnliche Dateiberechtigung plus die sudoers-Regel, nicht dieser
# Schalter.
ProtectSystem=full
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
RestrictSUIDSGID=yes
RestrictRealtime=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
LockPersonality=yes
MemoryDenyWriteExecute=no
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
# kann auch der Helfer nicht mehr erlangen -- gemessen: jede Einschraenkung
# fuehrt zu "sudo: unable to change to root gid". Die Grenze ist die
# sudoers-Regel und die Argumentpruefung im Helfer, nicht dieser Schalter.
UMask=0027
# Genau die Pfade, die der privilegierte Helfer schreibt -- nicht mehr.
# ProtectSystem=full sperrt /usr, /boot **und /etc**; ohne diese Freigaben
# scheitert der Helfer mit "Read-only file system", obwohl er root ist
# (gemessen). Der unprivilegierte Dienst kommt hier trotzdem nicht heran: die
# Dateien gehoeren root, und sudo laesst nur den einen Helferpfad zu.
ReadWritePaths=/srv/tesm/data /var/log/tesm /mnt/tesm-shares /etc/nginx /etc/netplan /etc/kea /etc/logrotate.d /etc/tesm /var/lib/kea /var/log/kea /run
[Install]
WantedBy=multi-user.target
+351
View File
@@ -0,0 +1,351 @@
#!/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
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# =============================================================================
# Bootstrapper: laedt ein Release herunter und ruft dessen install.sh auf.
# Enthaelt selbst keinerlei Installationslogik.
#
# Zwei Fallen des Vorgaengers sind hier bewusst vermieden:
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Assets.
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert -- faengt
# denselben Fehler ein zweites Mal ab.
# =============================================================================
set -euo pipefail
APP="${1:-}"
TAG="${2:-latest}"
BASE="${TESM_RELEASE_BASE:-https://gitea.example.invalid}"
OWNER="${TESM_RELEASE_OWNER:-wis}"
REPO="${TESM_RELEASE_REPO:-tesm}"
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
echo "Verwendung: update.sh <tesm|tesm-license> [tag]" >&2
exit 2
}
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
if [[ "$TAG" == "latest" ]]; then
echo "==> Ermittle aktuellen Release-Tag ..."
TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$REPO/releases/latest" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')"
echo " $TAG"
fi
ASSET="$REPO-$TAG.tar.gz"
echo "==> Lade $ASSET ..."
curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
echo "==> Entpacke ..."
tar -xzf "$WORK/$ASSET" -C "$WORK"
SRC="$(find "$WORK" -maxdepth 2 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)"
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; }
SRC="$(cd "$SRC" && pwd)"
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
if file "$SRC/deploy/install.sh" | grep -q CRLF; then
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
echo "'git -c core.autocrlf=false archive'." >&2
exit 1
fi
read -r -p "Version $TAG fuer $APP installieren? [j/N] " answer
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes