diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index 7ae702e..b649ec7 100644 --- a/apps/tesm/pyproject.toml +++ b/apps/tesm/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm" -version = "2.0.5" +version = "2.0.6" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/deploy/rename.sh b/deploy/rename.sh new file mode 100644 index 0000000..35ba529 --- /dev/null +++ b/deploy/rename.sh @@ -0,0 +1,359 @@ +#!/usr/bin/env bash +# ============================================================================= +# Benennt eine Installation um -- aus einer Instanz wird die Hauptinstallation +# (oder umgekehrt). +# +# sudo bash deploy/rename.sh --app tesm --from tesm-opus --to tesm +# sudo bash deploy/rename.sh --app tesm-license --from tesm-license-opus \ +# --to tesm-license +# +# Der Name steckt an mehr Stellen, als man beim Verschieben eines Verzeichnisses +# erwartet, und drei davon sind leicht zu vergessen: +# +# * ``SITE_KEY`` in der Umgebungsdatei -- daraus entsteht der Name des +# Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte +# Instanz. +# * ``web_cert_path``/``web_key_path``/``web_acme_webroot`` in der Datenbank. +# Sie enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx +# nach dem Umzug nicht mehr. +# * ``server_name`` der nginx-Site. install.sh schreibt die Datei neu, sobald +# sich die alias-Pfade aendern -- und die aendern sich hier zwangslaeufig. +# Ohne den Namen aus der Datenbank stuenden danach zwei Sites mit ``_`` auf +# Port 80 und die erste gewinnt. +# +# Belegt der Zielname noch eine Installation der Fassung 1.x, wird sie beiseite +# geschoben: Verzeichnis nach ``-``, ihre systemd-Units und ihre +# nginx-Site als Kopie hinein. Danach ist der Name frei und nichts auf dem +# System zeigt mehr ins Leere. +# +# Alle angemeldeten Benutzer werden abgemeldet: das Sitzungscookie heisst +# danach anders. +# ============================================================================= +set -euo pipefail + +APP="" +FROM="" +TO="" +SUFFIX="sonnet5" +ASSUME_YES=0 +DRY_RUN=0 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && 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: rename.sh --app --from --to [Optionen] + + --app Welche Anwendung (Pflicht) + --from Bisheriger Installationsname, z.B. tesm-opus (Pflicht) + --to Neuer Installationsname, z.B. tesm (Pflicht) + --suffix Namenszusatz fuer eine beiseite geschobene Installation + der Fassung 1.x (Vorgabe: sonnet5) + --dry-run Nur zeigen, was geschehen wuerde + --yes Keine Rueckfrage +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --app) APP="${2:?}"; shift 2 ;; + --from) FROM="${2:?}"; shift 2 ;; + --to) TO="${2:?}"; shift 2 ;; + --suffix) SUFFIX="${2:?}"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + --yes|-y) ASSUME_YES=1; shift ;; + -h|--help) usage; exit 0 ;; + *) usage; fail "unbekannte Option: $1" ;; + esac +done + +[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; } +[[ -n "$FROM" && -n "$TO" ]] || { usage; fail "--from und --to sind Pflicht"; } +[[ "$FROM" != "$TO" ]] || fail "--from und --to sind gleich" +[[ "$TO" =~ ^[a-z0-9-]{1,32}$ ]] || fail "ungueltiger Zielname: $TO" +[[ "$TO" == "$APP" || "$TO" == "$APP-"* ]] || fail "der Zielname muss mit '$APP' beginnen" +[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren" +[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript" +[[ -d "/srv/$FROM" ]] || fail "/srv/$FROM gibt es nicht" +[[ -f "/srv/$FROM/data/app.db" ]] || fail "/srv/$FROM sieht nicht nach einer Installation dieser Fassung aus" + +ENV_PREFIX="TESM_" +[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_" + +# Alles, was den Namen traegt. Reihenfolge: erst Daten, dann Konfiguration. +VERSCHIEBEN=( + "/srv/$FROM:/srv/$TO" + "/var/log/$FROM:/var/log/$TO" + "/etc/tesm/$FROM.env:/etc/tesm/$TO.env" + "/var/www/$FROM-acme:/var/www/$TO-acme" + "/etc/tesm/certs/$FROM:/etc/tesm/certs/$TO" +) +ENTFERNEN=( + "/etc/systemd/system/$FROM.service" + "/etc/systemd/system/$FROM-monitor.service" + "/etc/nginx/sites-enabled/$FROM" + "/etc/nginx/sites-available/$FROM" + "/usr/local/bin/$FROM-admin" + "/etc/logrotate.d/$FROM" + "/etc/update-motd.d/50-$FROM" +) + +# -- Webeinstellungen auslesen, bevor irgendetwas umzieht -------------------- # +# Mit dem System-Python und der Standardbibliothek: das venv zieht gleich mit +# um, und ein zusaetzliches Werkzeug (sqlite3) ist auf einem schlanken Server +# nicht garantiert. +lese_einstellung() { + python3 - "$1" "/srv/$FROM/data/app.db" <<'PY' +import sqlite3, sys +schluessel, datenbank = sys.argv[1], sys.argv[2] +try: + conn = sqlite3.connect(f"file:{datenbank}?mode=ro", uri=True) + row = conn.execute("SELECT value FROM settings WHERE key=?", (schluessel,)).fetchone() + conn.close() +except sqlite3.Error: + row = None +print((row[0] if row and row[0] is not None else "").strip()) +PY +} + +DOMAIN="$(lese_einstellung web_domain || true)" +HTTPS_AN="$(lese_einstellung web_https_enabled || true)" +HSTS_AN="$(lese_einstellung web_hsts_enabled || true)" +CERT_ALT="$(lese_einstellung web_cert_path || true)" + +step "Vorhaben" +info "Anwendung : $APP" +info "Alt : $FROM" +info "Neu : $TO" +info "Domain : ${DOMAIN:-(keine)}" +info "HTTPS : $([[ "$HTTPS_AN" == "1" ]] && echo "ja${CERT_ALT:+, Zertifikat $CERT_ALT}" || echo nein)" +for paar in "${VERSCHIEBEN[@]}"; do + quelle="${paar%%:*}"; ziel="${paar##*:}" + [[ -e "$quelle" ]] && info "verschieben: $quelle -> $ziel" +done +for pfad in "${ENTFERNEN[@]}"; do + [[ -e "$pfad" ]] && info "entfernen : $pfad (install.sh legt es unter dem neuen Namen neu an)" +done + +# -- Was belegt den Zielnamen? ----------------------------------------------- # +# Nicht nur das Verzeichnis unter /srv: eine Installation der Fassung 1.x +# hinterlaesst auch /var/log/, /etc/logrotate.d/ und den +# ACME-Pfad. Der Umzug bricht sonst mitten drin ab -- live genau so passiert. +BELEGT=() +for paar in "${VERSCHIEBEN[@]}"; do + ziel="${paar##*:}" + [[ -e "$ziel" ]] && BELEGT+=("$ziel") +done +for weiteres in "/etc/logrotate.d/$TO" "/etc/update-motd.d/50-$TO"; do + [[ -e "$weiteres" ]] && BELEGT+=("$weiteres") +done + +BEISEITE="" +if [[ ${#BELEGT[@]} -gt 0 ]]; then + if [[ -f "/srv/$TO/data/app.db" ]]; then + fail "/srv/$TO ist bereits eine Installation dieser Fassung. Welche gewinnt, entscheidet niemand automatisch." + fi + BEISEITE="/srv/$TO-$SUFFIX" + if [[ -e "$BEISEITE" ]]; then + # Ein vorhandenes Archiv derselben Verdraengung wird weiterbenutzt -- die + # Reste einer Installation gehoeren zusammen und nicht in ein zweites + # Verzeichnis. Alles andere unter diesem Namen bleibt unangetastet. + if [[ -f "$BEISEITE/sqlite.db" || -d "$BEISEITE/systemd-alt" ]]; then + info "Archiv $BEISEITE ist vorhanden und wird weiterbenutzt." + else + fail "$BEISEITE existiert und sieht nicht nach einem solchen Archiv aus -- bitte --suffix waehlen" + fi + fi + info "Der Zielname ist belegt. Alles davon wandert nach $BEISEITE:" + for pfad in "${BELEGT[@]}"; do info " $pfad"; done +fi + +if [[ $DRY_RUN -eq 1 ]]; then + step "--dry-run: hier ist Schluss." + exit 0 +fi +if [[ $ASSUME_YES -eq 0 ]]; then + read -r -p "Fortfahren? Alle angemeldeten Benutzer werden abgemeldet. [j/N] " answer + [[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; } +fi + +# -- 1. Dienste anhalten ----------------------------------------------------- # +step "Dienste anhalten" +for unit in "$FROM.service" "$FROM-monitor.service"; do + if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then + systemctl disable --now "$unit" >/dev/null 2>&1 || true + info "$unit gestoppt und deaktiviert" + fi +done + +# -- 2. Zielnamen freimachen ------------------------------------------------- # +if [[ -n "$BEISEITE" ]]; then + step "Zielnamen freimachen" + # Alles wandert ins Archiv, nichts wird geloescht: unter /var/log/ + # liegen Protokolle, unter /srv/ im Zweifel eine ganze Installation. + # Die Units und die Site kommen mit -- sie zeigen auf das alte Verzeichnis + # und waeren auf dem System nur noch Fehlerquellen, im Archiv aber ein Beleg. + mkdir -p "$BEISEITE" + for pfad in "${BELEGT[@]}"; do + ziel="$BEISEITE/$(basename "$pfad")" + if [[ "$pfad" == "/srv/$TO" ]]; then + # Das Verzeichnis selbst wird das Archiv -- sein Inhalt bleibt, wie er ist. + for eintrag in "$pfad"/* "$pfad"/.[!.]*; do + [[ -e "$eintrag" ]] || continue + mv "$eintrag" "$BEISEITE/" + done + rmdir "$pfad" + info "/srv/$TO -> $BEISEITE/" + continue + fi + if [[ -e "$ziel" ]]; then + ziel="$ziel-$(date +%Y%m%d-%H%M%S)" + warn "Im Archiv lag schon etwas mit diesem Namen -- ablegen als $(basename "$ziel")" + fi + mv "$pfad" "$ziel" + info "$pfad -> $ziel" + done + mkdir -p "$BEISEITE/systemd-alt" + for unit in "/etc/systemd/system/$TO.service" "/etc/systemd/system/$TO-check.service" \ + "/etc/systemd/system/$TO-check.timer" "/etc/systemd/system/$TO-check-restart.service" \ + "/etc/systemd/system/$TO-check-restart.timer"; do + [[ -f "$unit" ]] || continue + systemctl disable --now "$(basename "$unit")" >/dev/null 2>&1 || true + mv "$unit" "$BEISEITE/systemd-alt/" + info "$(basename "$unit") archiviert" + done + for site in "/etc/nginx/sites-enabled/$TO" "/etc/nginx/sites-available/$TO"; do + [[ -e "$site" ]] || continue + [[ -L "$site" ]] && { rm -f "$site"; continue; } + mv "$site" "$BEISEITE/systemd-alt/nginx-site" + info "nginx-Site archiviert" + done + for motd in "/etc/update-motd.d/50-$TO"; do + [[ -f "$motd" ]] && grep -q "motd_banner.py" "$motd" 2>/dev/null && rm -f "$motd" \ + && info "Banner der Fassung 1.x entfernt" + done + info "Alles Verdraengte liegt unter $BEISEITE" +fi + +# -- 3. Umziehen ------------------------------------------------------------- # +step "Umziehen" +for paar in "${VERSCHIEBEN[@]}"; do + quelle="${paar%%:*}"; ziel="${paar##*:}" + [[ -e "$quelle" ]] || continue + [[ -e "$ziel" ]] && fail "$ziel existiert bereits -- Abbruch, damit nichts vermischt wird" + mkdir -p "$(dirname "$ziel")" + mv "$quelle" "$ziel" + info "$quelle -> $ziel" +done + +# -- 4. Namen in den Dateien nachziehen -------------------------------------- # +step "Namen nachziehen" +ENV_FILE="/etc/tesm/$TO.env" +if [[ -f "$ENV_FILE" ]]; then + # Der SITE_KEY bestimmt den Namen des Sitzungscookies. Bleibt er alt, heisst + # das Cookie weiter wie die alte Instanz -- unauffaellig und falsch. + sed -i "s#^${ENV_PREFIX}SITE_KEY=.*#${ENV_PREFIX}SITE_KEY=$TO#" "$ENV_FILE" + info "SITE_KEY=$TO" +fi + +# Pfade in der Datenbank, die den Namen enthalten. +python3 - "$TO" "$FROM" "/srv/$TO/data/app.db" <<'PY' +import sqlite3, sys +neu, alt, datenbank = sys.argv[1], sys.argv[2], sys.argv[3] +schluessel = ("web_cert_path", "web_key_path", "web_acme_webroot") +try: + conn = sqlite3.connect(datenbank, timeout=5.0) +except sqlite3.Error as fehler: + print(f" Datenbank nicht schreibbar: {fehler}") + raise SystemExit(0) +try: + with conn: + for key in schluessel: + row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() + if not row or not row[0]: + continue + wert = str(row[0]) + ersetzt = wert.replace(alt, neu) + if ersetzt != wert: + conn.execute("UPDATE settings SET value=? WHERE key=?", (ersetzt, key)) + print(f" {key}: {wert} -> {ersetzt}") +finally: + conn.close() +PY + +# -- 5. Alte Konfiguration entfernen ----------------------------------------- # +step "Alte Konfiguration entfernen" +for pfad in "${ENTFERNEN[@]}"; do + [[ -e "$pfad" ]] || continue + rm -f "$pfad" + info "$pfad" +done +systemctl daemon-reload + +# -- 6. Unter dem neuen Namen einrichten ------------------------------------- # +# Das venv wird verworfen und neu gebaut. Es ist abgeleiteter Zustand, und +# seine Konsolenskripte tragen den absoluten Pfad im Shebang: nach dem Umzug +# zeigt "venv/bin/gunicorn" auf einen Interpreter, den es nicht mehr gibt, und +# systemd meldet nur "No such file or directory" -- fuer eine Datei, die +# sichtbar da ist. Live auf POETEST genau so passiert. +if [[ -d "/srv/$TO/venv" ]]; then + step "venv verwerfen (Shebangs tragen den alten Pfad)" + rm -rf "/srv/$TO/venv" + info "wird von install.sh neu gebaut" +fi + +step "Unter dem neuen Namen einrichten" +INSTALL_ARGS=(--app "$APP" --yes) +[[ "$TO" != "$APP" ]] && INSTALL_ARGS+=(--instance "${TO#"$APP"-}") +bash "$SCRIPT_DIR/install.sh" "${INSTALL_ARGS[@]}" + +# -- 7. nginx-Site aus den Einstellungen neu schreiben ----------------------- # +# install.sh schreibt die Site aus seiner Vorlage, sobald sich die alias-Pfade +# aendern -- und die aendern sich hier zwangslaeufig. Der Name der Site steht +# aber in der Datenbank, nicht in der Vorlage. Ohne diesen Schritt stuenden +# zwei Sites mit "_" auf Port 80 und die erste gewinnt. +if [[ -n "$DOMAIN" ]]; then + step "Webserver-Konfiguration erneuern" + # --apply nicht vergessen: ohne ihn speichert web-setup nur die Einstellungen + # und laesst die Site-Datei stehen, die install.sh aus seiner Vorlage + # geschrieben hat -- mit "server_name _" und ohne TLS. + WEB_ARGS=(web-setup --apply --domain "$DOMAIN") + if [[ "$HTTPS_AN" == "1" ]]; then + WEB_ARGS+=(--https) + [[ "$HSTS_AN" == "1" ]] && WEB_ARGS+=(--hsts) + # Das Zertifikat ist mitgezogen und sein Pfad in der Datenbank nachgefuehrt + # -- "web-setup --https" laesst es dann unangetastet. Ein neues erzeugen + # wuerde nur eine weitere Browserwarnung produzieren. Nur wenn nach dem + # Umzug wirklich keines da ist, wird eines erzeugt. + CERT_NEU="${CERT_ALT//$FROM/$TO}" + if [[ -n "$CERT_NEU" && -f "$CERT_NEU" ]]; then + info "HTTPS mit dem vorhandenen Zertifikat: $CERT_NEU" + else + WEB_ARGS+=(--self-signed) + warn "Zertifikat nicht gefunden (${CERT_NEU:-keins hinterlegt}) -- es wird ein neues" + warn "selbstsigniertes erzeugt. Der Browser warnt dann einmal erneut." + fi + fi + if sudo -u "$APP" "/usr/local/bin/$TO-admin" "${WEB_ARGS[@]}"; then + info "Site neu geschrieben: server_name $DOMAIN" + else + warn "web-setup ist fehlgeschlagen -- bitte in der Anwendung unter" + warn "'Verwaltung -> Webserver und TLS' die Domain eintragen und uebernehmen." + fi +fi + +# -- 8. Ergebnis ------------------------------------------------------------- # +step "Fertig" +info "Verzeichnis : /srv/$TO" +info "Dienst : systemctl status $TO.service" +info "Werkzeug : $TO-admin" +info "Aktualisieren: sudo bash /srv/$TO/deploy/update.sh $APP$([[ "$TO" != "$APP" ]] && echo " latest --instance ${TO#"$APP"-}")" +[[ -n "$BEISEITE" ]] && info "Fassung 1.x : $BEISEITE (Units und Site darin unter systemd-alt/)" +echo +warn "Alle Benutzer sind abgemeldet: das Sitzungscookie heisst jetzt anders." diff --git a/docs/BETRIEB.md b/docs/BETRIEB.md index 05d143c..1b60cec 100644 --- a/docs/BETRIEB.md +++ b/docs/BETRIEB.md @@ -283,6 +283,51 @@ entfernt -- nur dieses, nichts anderes in dem Verzeichnis. --- +## 4b. Installation umbenennen + +Aus einer Instanz die Hauptinstallation machen (oder umgekehrt): + +```bash +sudo bash deploy/rename.sh --app tesm --from tesm-opus --to tesm +sudo bash deploy/rename.sh --app tesm-license --from tesm-license-opus --to tesm-license +``` + +Der Name steckt an mehr Stellen als im Verzeichnisnamen, und drei davon sind +leicht zu übersehen: + +* **`SITE_KEY`** in der Umgebungsdatei -- daraus entsteht der Name des + Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte + Instanz. +* **`web_cert_path`, `web_key_path`, `web_acme_webroot`** in der Datenbank. Sie + enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx nach + dem Umzug nicht mehr. +* **`server_name`** der nginx-Site. `install.sh` schreibt die Datei aus seiner + Vorlage neu, sobald sich die `alias`-Pfade ändern -- und die ändern sich hier + zwangsläufig. Ohne den Namen aus der Datenbank stünden danach zwei Sites mit + `server_name _` auf Port 80, und die erste gewinnt. + +Ausserdem wird das **venv verworfen und neu gebaut**: seine Konsolenskripte +tragen den absoluten Pfad im Shebang, und nach dem Umzug zeigt +`venv/bin/gunicorn` auf einen Interpreter, den es nicht mehr gibt. systemd +meldet dann `No such file or directory` -- für eine Datei, die sichtbar da ist. + +Belegt der Zielname noch eine Installation der Fassung 1.x, wandert **alles**, +was ihn belegt, in ein Archiv `/srv/-sonnet5`: das Verzeichnis, die +Protokolle unter `/var/log/`, der ACME-Pfad, die logrotate-Datei, und die +systemd-Units samt nginx-Site unter `systemd-alt/`. Gelöscht wird nichts. + +Ports und HTTPS bleiben, wie sie waren; ein vorhandenes Zertifikat zieht mit +und wird weiterverwendet. **Alle angemeldeten Benutzer werden abgemeldet** -- +das Sitzungscookie heisst danach anders. + +Danach ist auch das Aktualisieren einfacher, weil der Instanzname wegfällt: + +```bash +sudo bash /srv/tesm/deploy/update.sh tesm +``` + +--- + ## 5. Dienste ```bash diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml index 52e7a42..629af80 100644 --- a/packages/tesm-core/pyproject.toml +++ b/packages/tesm-core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-core" -version = "2.0.5" +version = "2.0.6" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = [ diff --git a/packages/tesm-core/src/tesm_core/app_factory.py b/packages/tesm-core/src/tesm_core/app_factory.py index 878f960..efad88c 100644 --- a/packages/tesm-core/src/tesm_core/app_factory.py +++ b/packages/tesm-core/src/tesm_core/app_factory.py @@ -134,7 +134,7 @@ def create_base_app( #: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die #: Funktionsliste von TESM waere dort schlicht falsch. auth_tagline: str = "", - app_version: str = "2.0.5", + app_version: str = "2.0.6", built_in_trusted_keys: tuple[str, ...] = (), enable_license: bool = True, template_folder: str | None = None, diff --git a/packages/tesm-licensing/pyproject.toml b/packages/tesm-licensing/pyproject.toml index 1313b10..9c28542 100644 --- a/packages/tesm-licensing/pyproject.toml +++ b/packages/tesm-licensing/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-licensing" -version = "2.0.5" +version = "2.0.6" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = ["cryptography>=42"]