TESM-Lizenzserver 2.0.0 -- Neubau
Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm-license (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Die verwaltete Anwendung liegt in ihrem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger: * Keine doppelte licensing.py -- ein Paket, das beide Anwendungen installieren, statt zweier Dateien, die byte-identisch bleiben sollen. * 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. * Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann. * Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als "Aktivierung offen" markiert, bis sie zurueckkommt. * Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung, seq gegen das Zurueckrollen auf eine aeltere Lizenz. * Kein Hostname im Maschinen-Fingerabdruck. * Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user