diff --git a/apps/tesm-license/pyproject.toml b/apps/tesm-license/pyproject.toml index b25a166..94d005f 100644 --- a/apps/tesm-license/pyproject.toml +++ b/apps/tesm-license/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-license" -version = "2.0.2" +version = "2.0.3" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm-license/src/tesm_license/cli.py b/apps/tesm-license/src/tesm_license/cli.py index a407759..4bb7fe9 100644 --- a/apps/tesm-license/src/tesm_license/cli.py +++ b/apps/tesm-license/src/tesm_license/cli.py @@ -336,6 +336,28 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int: key = master_key.load(key_path) with app.app_context(), extension.database.session() as conn: + # Anbieterdaten und Endpunkt zuerst: bei einem Upgrade ist die neue + # Installation frisch, dort steht also nichts, und die Pruefung + # weiter unten haette jedes Upgrade abgebrochen. Beides gehoert zur + # Anbieteridentitaet -- genau die soll ein Upgrade nicht wechseln. + # Uebernommen wird nur, was hier leer ist. + if not args.dry_run: + with extension.database.transaction(conn): + uebernommen = legacy_import.adopt_vendor_settings( + legacy, + lambda key: extension.settings.get(conn, key), + lambda key, wert: extension.settings.set( + conn, key, wert, actor="migration" + ), + ) + # Nicht "key" als Laufvariable: darueber steht der geladene + # Signaturschluessel mit demselben Namen, und ihn zu + # ueberschreiben lief in ein AttributeError mitten in der + # Uebernahme. + for einstellung, wert in uebernommen.items(): + gekuerzt = wert if len(wert) <= 60 else wert[:57] + "..." + print(f"Uebernommen: {einstellung} = {gekuerzt}") + vendor = { "name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"), "email": str(extension.settings.get(conn, "vendor_email") or ""), @@ -346,8 +368,9 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int: endpoint = str(extension.settings.get(conn, "master_endpoint") or "") if not endpoint and not args.dry_run: print( - "Es ist kein Endpunkt hinterlegt (Einstellungen -> Anbieter). Ohne ihn " - "koennen uebernommene Lizenzen sich spaeter nicht aktivieren.", + "Es ist kein Endpunkt hinterlegt, und die alte Installation nennt " + "auch keinen. Bitte unter Einstellungen -> Anbieter eintragen: ohne " + "ihn koennen uebernommene Lizenzen sich spaeter nicht aktivieren.", file=sys.stderr, ) return 2 diff --git a/apps/tesm-license/src/tesm_license/services/legacy_import.py b/apps/tesm-license/src/tesm_license/services/legacy_import.py index e5f22c6..585d7e3 100644 --- a/apps/tesm-license/src/tesm_license/services/legacy_import.py +++ b/apps/tesm-license/src/tesm_license/services/legacy_import.py @@ -25,6 +25,8 @@ angefasst, nicht angehalten und nicht veraendert. from __future__ import annotations +import base64 +import binascii import json import sqlite3 from dataclasses import dataclass, field @@ -110,6 +112,39 @@ def open_legacy(root: Path) -> sqlite3.Connection: return conn +def _standard_b64(wert: str, feld: str) -> str: + """Nimmt Standard- **und** URL-Alphabet an, gibt Standard-Base64 zurueck. + + Der Vorgaenger kodierte seine Schluessel mit ``urlsafe_b64encode``: dort + stehen ``-`` und ``_`` an den Stellen von ``+`` und ``/``. Das neue + Protokoll prueft Base64 streng -- richtig so, aber beim Lesen einer alten + Datei fuehrte es dazu, dass die Uebernahme mit einem Traceback ueber + "kein gueltiges Base64" abbrach, bei jedem echten Altbestand. + """ + roh = wert.strip() + if not roh: + raise LegacyImportError(f"Im alten Signaturschluessel fehlt {feld!r}.") + # Fehlende Polsterung ergaenzen -- der Vorgaenger schrieb sie mit, andere + # Werkzeuge lassen sie weg. + gepolstert = roh + "=" * (-len(roh) % 4) + # Erst das Standard-, dann das URL-Alphabet -- beide mit Pruefung. Ohne + # ``validate`` verschluckt der Decoder unbekannte Zeichen einfach, und aus + # "###" wuerde eine leere Ausgabe statt einer Fehlermeldung. + versuche = (gepolstert, gepolstert.replace("-", "+").replace("_", "/")) + for kandidat in versuche: + try: + rohbytes = base64.b64decode(kandidat, validate=True) + except (ValueError, binascii.Error): + continue + if len(rohbytes) != 32: + raise LegacyImportError( + f"{feld} im alten Signaturschluessel ist {len(rohbytes)} Byte lang, " + "erwartet werden 32 (Ed25519)." + ) + return base64.b64encode(rohbytes).decode("ascii") + raise LegacyImportError(f"{feld} im alten Signaturschluessel ist kein Base64.") + + def read_legacy_key(root: Path) -> tuple[str, str]: """Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``.""" path = Path(root) / "master_signing_key.json" @@ -120,12 +155,23 @@ def read_legacy_key(root: Path) -> tuple[str, str]: except (OSError, ValueError) as exc: raise LegacyImportError(f"Der alte Signaturschluessel ist unlesbar: {exc}") from exc - # Die Vorgaengerfassung nannte die Felder "private"/"public". + # Die Vorgaengerfassung nannte die Felder "private"/"public" und kodierte + # sie URL-sicher. private = str(data.get("private") or data.get("private_key") or "") public = str(data.get("public") or data.get("public_key") or "") if not private or not public: raise LegacyImportError("Der alte Signaturschluessel ist unvollstaendig.") - if licsrv.public_from_private(private) != public: + private = _standard_b64(private, "private") + public = _standard_b64(public, "public") + # Alles, was aus der Protokollschicht kommt, wird hier zu einer Meldung im + # Klartext. Ein Traceback ueber Base64 mitten in einer Uebernahme sagt dem + # Betreiber nichts -- und die Uebernahme bricht ohne --adopt-key gar nicht + # ab, sie merkt sich nur den alten Aussteller nicht. + try: + passt = licsrv.public_from_private(private) == public + except (ValueError, TypeError) as exc: + raise LegacyImportError(f"Der alte Signaturschluessel ist unbrauchbar: {exc}") from exc + if not passt: raise LegacyImportError( "Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht " "zueinander. Die Datei ist beschaedigt." @@ -187,6 +233,55 @@ def _modules(raw: Any) -> list[str]: return sorted(set(str(value) for value in values) & set(lic.ALL_MODULES)) + +#: Einstellungen, die zur Anbieteridentitaet gehoeren und in beiden Fassungen +#: gleich heissen. ``master_endpoint`` ist darunter das entscheidende: ohne ihn +#: findet eine uebernommene Lizenz ihren Server spaeter nicht wieder. +VENDOR_SETTINGS = ( + "master_endpoint", + "vendor_name", + "vendor_email", + "vendor_phone", + "vendor_address", + "vendor_logo_base64", +) + + +def read_vendor_settings(legacy: sqlite3.Connection) -> dict[str, str]: + """Liest die Anbietereinstellungen der alten Installation.""" + try: + rows = legacy.execute( + "SELECT key, value FROM settings WHERE key IN ({})".format( + ",".join("?" for _ in VENDOR_SETTINGS) + ), + VENDOR_SETTINGS, + ).fetchall() + except sqlite3.Error: # pragma: no cover - alte Fassung ohne settings + return {} + return {str(row["key"]): str(row["value"] or "") for row in rows if str(row["value"] or "")} + + +def adopt_vendor_settings( + legacy: sqlite3.Connection, + read: Any, + write: Any, +) -> dict[str, str]: + """Fuellt leere Anbietereinstellungen aus der alten Installation. + + ``read(key)`` und ``write(key, wert)`` kommen von aussen, damit diese + Funktion nichts ueber den Einstellungsspeicher wissen muss. Rueckgabe: was + tatsaechlich uebernommen wurde -- der Aufrufer soll es benennen koennen. + """ + alt = read_vendor_settings(legacy) + uebernommen: dict[str, str] = {} + for key, wert in alt.items(): + if str(read(key) or "").strip(): + continue # was hier schon steht, gilt + write(key, wert) + uebernommen[key] = wert + return uebernommen + + def import_all( legacy: sqlite3.Connection, conn: sqlite3.Connection, diff --git a/deploy/upgrade.sh b/deploy/upgrade.sh new file mode 100644 index 0000000..2feca0b --- /dev/null +++ b/deploy/upgrade.sh @@ -0,0 +1,288 @@ +#!/usr/bin/env bash +# ============================================================================= +# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme. +# +# Aufruf (aus dem entpackten Release-Verzeichnis): +# sudo bash deploy/upgrade.sh --app tesm +# sudo bash deploy/upgrade.sh --app tesm-license +# sudo bash deploy/upgrade.sh --app tesm --instance opus --source /srv/tesm +# +# Der Ablauf ist die Vorgabe und braucht keine Schalter: +# +# 1. **Sichern.** Vollstaendige Kopie der alten Installation nach +# /srv/-upgrade-/alt. Erst danach wird irgendetwas +# angefasst -- und aus dieser Kopie stammen auch alle weiteren Schritte. +# 2. **Altes System abschalten.** Dienst und Timer werden gestoppt und +# deaktiviert, damit sie nach einem Neustart nicht zurueckkommen. +# 3. **Neu installieren.** install.sh dieses Verzeichnisses. +# 4. **Exportieren.** Aus der Kopie entsteht eine Exportdatei im **alten** +# Format -- genau die, die der Vorgaenger selbst geschrieben haette. +# 5. **Uebersetzen.** Daraus eine Datei im neuen Format. +# 6. **Einspielen.** Diese Datei geht durch denselben Import wie eine +# Sicherung. +# +# Warum die Sicherung vor dem Export steht und nicht danach: laeuft das +# Upgrade **in dasselbe Verzeichnis** (also ohne --instance), ueberschreibt +# install.sh die alte Installation. Wer erst exportiert und dann sichert, +# sichert im schlechtesten Fall nichts mehr. Gelesen wird ausschliesslich aus +# der Kopie -- das Ergebnis ist dasselbe, die Reihenfolge ist die sichere. +# +# Der Lizenzserver kennt Schritt 4 und 5 nicht: seine Uebernahme liest die alte +# Datenbank unmittelbar und **signiert jede Lizenz neu**. Dabei wird der alte +# Signaturschluessel uebernommen -- sonst muesste jeder Kunde einen +# Ausstellerwechsel bestaetigen. Wer das nicht will: --new-key. Dort ist die +# Kopie aus Schritt 1 der Export. +# ============================================================================= +set -euo pipefail + +APP="" +INSTANCE="" +SOURCE="" +PASSPHRASE="" +ASSUME_YES=0 +DRY_RUN=0 +WITH_DATA=1 +STOP_OLD=1 +ADOPT_KEY=1 +EXTRA=() + +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: upgrade.sh --app [Optionen] + + --app Welche Anwendung (Pflicht) + --source Verzeichnis der alten Installation + (Vorgabe: /srv/tesm bzw. /srv/tesm-license) + --instance Neue Installation als benannte Instanz anlegen, statt + das alte Verzeichnis zu ueberschreiben. Damit bleibt die + alte Installation liegen und ist zurueckholbar. + --passphrase Passphrase der Zwischendateien. Ohne Angabe wird eine + erzeugt und in der Sicherung abgelegt. + --no-data Nur installieren, keine Datenuebernahme + --keep-old-running Alten Dienst nicht stoppen (nur mit --instance sinnvoll) + --new-key Nur Lizenzserver: einen neuen Signaturschluessel + erzeugen, statt den alten zu uebernehmen. Dann muessen + alle Kunden den Ausstellerwechsel bestaetigen -- die + Vorgabe ist deshalb die Uebernahme. + --dry-run Nur zeigen, was geschehen wuerde + --yes Keine Rueckfrage + -h, --help Diese Hilfe + +Alles weitere wird unveraendert an install.sh weitergegeben, etwa --https +oder --domain. + +Beispiele: + # In dasselbe Verzeichnis, alte Installation wird ersetzt: + sudo bash deploy/upgrade.sh --app tesm + + # Daneben, zum Vergleich, alte Installation bleibt unangetastet: + sudo bash deploy/upgrade.sh --app tesm --instance opus --keep-old-running +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --app) APP="${2:?}"; shift 2 ;; + --source) SOURCE="${2:?}"; shift 2 ;; + --instance) INSTANCE="${2:?}"; shift 2 ;; + --passphrase) PASSPHRASE="${2:?}"; shift 2 ;; + --no-data) WITH_DATA=0; shift ;; + --keep-old-running) STOP_OLD=0; shift ;; + --new-key) ADOPT_KEY=0; shift ;; + --adopt-key) ADOPT_KEY=1; shift ;; # Vorgabe, hier nur zur Deutlichkeit + --dry-run) DRY_RUN=1; shift ;; + --yes|-y) ASSUME_YES=1; shift ;; + -h|--help) usage; exit 0 ;; + *) EXTRA+=("$1"); shift ;; + esac +done + +[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; } +[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren" +[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript" + +[[ -n "$SOURCE" ]] || SOURCE="/srv/$APP" +[[ -d "$SOURCE" ]] || fail "Die alte Installation $SOURCE gibt es nicht (--source setzen)" +[[ -f "$SOURCE/sqlite.db" ]] || fail "In $SOURCE liegt keine sqlite.db -- das ist keine Installation der Vorgaengerfassung" + +NAME="$APP" +[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE" +ADMIN="$NAME-admin" +NEW_ROOT="/srv/$NAME" +IN_PLACE=0 +[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1 + +STAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP="/srv/$NAME-upgrade-$STAMP" +ALT_KOPIE="$BACKUP/alt" +EXPORT_ALT="$BACKUP/export-alt.json" +IMPORT_NEU="$BACKUP/import-neu.json" + +# Alte Einheiten der Vorgaengerfassung. Der Timer gehoert dazu: nur den Dienst +# zu stoppen liesse ihn beim naechsten Ablauf wieder anlaufen. +if [[ "$APP" == "tesm" ]]; then + ALTE_UNITS=(tesm.service tesm-check.timer tesm-check.service + tesm-check-restart.timer tesm-check-restart.service) +else + ALTE_UNITS=(tesm-license.service) +fi + +step "Vorhaben" +info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}" +info "Alt : $SOURCE" +info "Neu : $NEW_ROOT$([[ $IN_PLACE -eq 1 ]] && echo ' << dasselbe Verzeichnis')" +info "Sicherung : $ALT_KOPIE" +info "Datenuebernahme: $([[ $WITH_DATA -eq 1 ]] && echo ja || echo nein)" +info "Altes System : $([[ $STOP_OLD -eq 1 ]] && echo 'stoppen und deaktivieren' || echo 'laeuft weiter')" +if [[ $IN_PLACE -eq 1 && $STOP_OLD -eq 0 ]]; then + warn "--keep-old-running bei gleichem Verzeichnis: der alte Dienst laeuft auf Dateien," + warn "die gleich ersetzt werden. Das geht schief. Bitte --instance verwenden." +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? [j/N] " answer + [[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; } +fi + +# -- 1. Sichern -------------------------------------------------------------- # +step "Sichern" +mkdir -p "$ALT_KOPIE" +chmod 700 "$BACKUP" +# -a: Rechte und Zeiten bleiben, damit die Kopie auch als Rueckweg taugt. +# Das venv der alten Installation bleibt aussen vor -- es ist nachbaubar, +# gross, und seine Shebangs zeigen ohnehin auf den alten Pfad. +tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf - +info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)" +[[ -f "$ALT_KOPIE/sqlite.db" ]] || fail "In der Kopie fehlt die Datenbank -- Abbruch" + +# -- 2. Altes System abschalten ---------------------------------------------- # +if [[ $STOP_OLD -eq 1 ]]; then + step "Altes System abschalten" + for unit in "${ALTE_UNITS[@]}"; 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 +else + step "Altes System bleibt in Betrieb" +fi + +# -- 3. Neu installieren ----------------------------------------------------- # +step "Neu installieren" +INSTALL_ARGS=(--app "$APP" --yes) +[[ -n "$INSTANCE" ]] && INSTALL_ARGS+=(--instance "$INSTANCE") +bash "$SCRIPT_DIR/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"} + +if [[ $WITH_DATA -eq 0 ]]; then + step "Fertig -- ohne Datenuebernahme" + info "Sicherung der alten Installation: $ALT_KOPIE" + exit 0 +fi + +command -v "$ADMIN" >/dev/null 2>&1 || fail "$ADMIN nicht gefunden -- die Installation ist unvollstaendig" + +if [[ -z "$PASSPHRASE" ]]; then + PASSPHRASE="$(head -c 24 /dev/urandom | base64 | tr -d '=+/' | cut -c1-24)" + # Die Passphrase liegt neben den Dateien, die sie schuetzt. Das ist hier kein + # Widerspruch: das alte Format verlangt eine, das Verzeichnis gehoert root + # allein (0700), und ohne sie waeren die beiden Dateien spaeter unbrauchbar -- + # auch als Rueckweg. + printf '%s\n' "$PASSPHRASE" > "$BACKUP/passphrase.txt" + chmod 600 "$BACKUP/passphrase.txt" +fi + +if [[ "$APP" == "tesm" ]]; then + # -- 4. Exportieren -------------------------------------------------------- # + step "Daten der alten Installation lesen" + "$ADMIN" legacy-export --source "$ALT_KOPIE" --out "$EXPORT_ALT" \ + --passphrase "$PASSPHRASE" --force + chmod 600 "$EXPORT_ALT" + + # -- 5. Uebersetzen -------------------------------------------------------- # + step "Uebersetzen" + "$ADMIN" convert-legacy "$EXPORT_ALT" --ausgabe "$IMPORT_NEU" \ + --passphrase "$PASSPHRASE" --neue-passphrase "$PASSPHRASE" --force + chmod 600 "$IMPORT_NEU" + + # -- 6. Einspielen --------------------------------------------------------- # + step "Einspielen" + "$ADMIN" legacy-import "$IMPORT_NEU" --passphrase "$PASSPHRASE" --actor upgrade +else + # -- 4.-6. in einem: die Uebernahme liest die alte Datenbank unmittelbar --- # + step "Lizenzbestand uebernehmen" + # Der alte Signaturschluessel wird uebernommen. Fuer ein Upgrade ist das + # die einzige sinnvolle Vorgabe: es ist derselbe Server mit neuerer Software, + # und ohne den alten Schluessel muesste jeder Kunde den Ausstellerwechsel + # bestaetigen. Ueberschrieben wird dabei nichts -- die Uebernahme lehnt ab, + # wenn die neue Installation schon einen eigenen Schluessel hat. + MIGRATE_ARGS=(migrate-legacy --source "$ALT_KOPIE") + [[ $ADOPT_KEY -eq 1 ]] && MIGRATE_ARGS+=(--adopt-key) + "$ADMIN" "${MIGRATE_ARGS[@]}" + if [[ $ADOPT_KEY -eq 0 ]]; then + warn "--new-key: es gilt ein neuer Signaturschluessel. Die Kunden muessen den" + warn "Ausstellerwechsel einmal bestaetigen." + fi +fi + +# -- Abschluss --------------------------------------------------------------- # +step "Fertig" +info "Sicherung der alten Installation : $ALT_KOPIE" +if [[ "$APP" == "tesm" ]]; then + info "Export im alten Format : $EXPORT_ALT" + info "Uebersetzte Datei : $IMPORT_NEU" + [[ -f "$BACKUP/passphrase.txt" ]] && info "Passphrase beider Dateien : $BACKUP/passphrase.txt" +fi +cat <-.tar.gz`. +### Upgrade vom Stand 1.x (Branch `SONNET5`) + +Ein eigenes Skript, weil dabei mehr passiert als ein Dateiabgleich: die alte +Fassung hat ein anderes Schema, andere Rechteschlüssel und ein anderes +Exportformat. + +```bash +sudo bash deploy/upgrade.sh --app tesm +sudo bash deploy/upgrade.sh --app tesm-license +``` + +Der Ablauf ist die Vorgabe und braucht keine Schalter: + +| | | +|---|---| +| 1. Sichern | Vollständige Kopie der alten Installation nach `/srv/-upgrade-/alt` (ohne `venv`, Verzeichnis 0700) | +| 2. Abschalten | Alter Dienst **und seine Timer** werden gestoppt und deaktiviert | +| 3. Installieren | `install.sh` dieses Verzeichnisses | +| 4. Exportieren | Aus der Kopie entsteht `export-alt.json` im **alten** Format | +| 5. Übersetzen | Daraus `import-neu.json` im neuen Format | +| 6. Einspielen | Diese Datei geht durch denselben Import wie eine Sicherung | + +Warum die Sicherung **vor** dem Export steht: ohne `--instance` installiert +`install.sh` in dasselbe Verzeichnis und ersetzt die alte Installation. Wer +erst exportiert und dann sichert, sichert im schlechtesten Fall nichts mehr. +Gelesen wird ausschliesslich aus der Kopie -- die alte Installation wird nie +verändert. + +Die beiden Zwischendateien bleiben liegen; ihre Passphrase steht daneben in +`passphrase.txt` (0600, Verzeichnis 0700). `export-alt.json` ist ein echter +Export des Vorgängers und taugt deshalb auch als Rückweg. + +**Daneben statt darüber.** Mit `--instance` entsteht eine zweite Installation, +und die alte bleibt vollständig liegen: + +```bash +sudo bash deploy/upgrade.sh --app tesm --instance opus --keep-old-running --port 5100 --http-port 8080 +``` + +**Was nicht mitkommt** -- und warum: + +* **Lokale Konten.** Der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash + ist nicht umrechenbar; das ist sein Zweck. Erstes Konto mit + `tesm-admin create-admin`. +* **Gruppen und Rechte.** Die Rechteschlüssel wurden neu geschnitten. +* **LDAP, Webserver, Zertifikat.** Von Hand nachziehen. + +Beim **Lizenzserver** gibt es Schritt 4 und 5 nicht: die Übernahme liest die +alte Datenbank unmittelbar, holt Endpunkt und Anbieterdaten aus ihren +Einstellungen und **signiert jede Lizenz neu**. Der alte Signaturschlüssel wird +dabei übernommen -- sonst müsste jeder Kunde einen Ausstellerwechsel +bestätigen; `--new-key` erzwingt das Gegenteil. Der Bericht nennt namentlich, +wer eine neue Lizenzdatei braucht, denn die Bindung an eine Maschine gilt nur +für die alte Datei. + +Nur die Anwendung, ohne Daten: `--no-data`. Nur zeigen, was geschähe: +`--dry-run`. + --- ## 4. HTTPS diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml index 7849ca5..be37739 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.2" +version = "2.0.3" 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 91fd9ba..3a6d738 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.2", + app_version: str = "2.0.3", built_in_trusted_keys: tuple[str, ...] = (), enable_license: bool = True, template_folder: str | None = None, diff --git a/packages/tesm-core/src/tesm_core/auth/models.py b/packages/tesm-core/src/tesm_core/auth/models.py index fba7265..3d29990 100644 --- a/packages/tesm-core/src/tesm_core/auth/models.py +++ b/packages/tesm-core/src/tesm_core/auth/models.py @@ -10,6 +10,7 @@ from flask import abort, flash, g, redirect, request, session, url_for from werkzeug.local import LocalProxy from ..rbac.model import Action, PermissionTree +from ..security.sessions import is_automatic AUTH_SOURCES = ("local", "ldap") @@ -189,6 +190,14 @@ def _deny(message: str) -> Any: if not current_user.is_authenticated: if request.method == "GET" and not request.headers.get("X-Requested-With"): session["_after_login"] = request.full_path if request.query_string else request.path + # Eine automatische Aktualisierung fragt einen Ausschnitt ab und + # erwartet HTML. Eine Umleitung auf die Anmeldeseite wuerde sie + # dankbar entgegennehmen und eine ganze Seite in eine Kachel setzen -- + # der Tab bliebe stumm stehen, obwohl die Sitzung abgelaufen ist. 401 + # sagt es deutlich; der Browser laedt daraufhin die Seite neu und der + # Mensch sieht die Anmeldemaske. + if is_automatic(request): + abort(401, description="Sitzung abgelaufen.") if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html: abort(401, description="Anmeldung erforderlich.") flash("Bitte melden Sie sich an.", "info") diff --git a/packages/tesm-core/src/tesm_core/security/sessions.py b/packages/tesm-core/src/tesm_core/security/sessions.py index 7c1c111..ae86fd6 100644 --- a/packages/tesm-core/src/tesm_core/security/sessions.py +++ b/packages/tesm-core/src/tesm_core/security/sessions.py @@ -34,6 +34,25 @@ from ..db import Database SESSION_ID_BYTES = 32 +#: Kopfzeile, mit der der Browser eine Anfrage als **automatisch** kennzeichnet: +#: ein Aktualisierungsdurchlauf einer Kachel, kein Klick eines Menschen. +#: +#: Ohne diese Unterscheidung ist die automatische Abmeldung wirkungslos, und das +#: war sie auch: ein offener Tab fragt seine Bereiche alle 15 Sekunden neu ab, +#: jede Abfrage verlaengerte das Leerlauffenster, und niemand wurde je +#: abgemeldet -- nachweisbar an einer Sitzung, deren ``last_seen_at`` eine +#: Minute alt war, obwohl seit zehn Stunden niemand am Rechner sass. +#: +#: Die Entscheidung gehoert zum Client: nur er weiss, ob ein Zeitgeber oder ein +#: Mensch die Anfrage ausgeloest hat. Ein Angreifer koennte den Kopf setzen -- +#: er wuerde damit seine *eigene* Sitzung schneller ablaufen lassen. +AUTO_REFRESH_HEADER = "X-Auto-Refresh" + + +def is_automatic(request: Request) -> bool: + """Kam die Anfrage von einem Zeitgeber statt von einem Menschen?""" + return request.headers.get(AUTO_REFRESH_HEADER, "") == "1" + def _now() -> datetime: return datetime.now(timezone.utc) @@ -154,8 +173,17 @@ class SqliteSessionInterface(SessionInterface): } # Leerlauffenster verlaengern, aber hoechstens einmal pro Minute # schreiben -- sonst kostet jeder AJAX-Poll einen Schreibvorgang. + # + # Eine automatische Abfrage verlaengert gar nichts. Sie darf die + # Sitzung benutzen, solange diese lebt, aber sie haelt sie nicht am + # Leben: sonst bleibt jeder offene Tab bis in alle Ewigkeit + # angemeldet, und "Abmeldung nach 30 Minuten Leerlauf" ist eine + # Zusage, die die Anwendung nicht einhaelt. Damit bleibt + # ``last_seen_at`` ausserdem, was der Name sagt: der letzte + # Zeitpunkt, an dem ein Mensch da war. last_seen = _parse(row.get("last_seen_at")) - if last_seen is None or (now - last_seen) > timedelta(seconds=60): + veraltet = last_seen is None or (now - last_seen) > timedelta(seconds=60) + if veraltet and not is_automatic(request): with self.database.transaction(conn): conn.execute( "UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?", diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js b/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js index ca478bf..97f1fa5 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js @@ -45,12 +45,24 @@ function csrfToken() { return document.body?.dataset.csrf || ""; } -/** fetch mit CSRF-Header und JSON-Erwartung. */ +/** + * fetch mit CSRF-Header und JSON-Erwartung. + * + * `auto: true` kennzeichnet eine Anfrage, die ein Zeitgeber ausgeloest hat und + * kein Mensch. Der Server verlaengert das Leerlauffenster der Sitzung dann + * nicht -- ohne diese Unterscheidung haelt jeder offene Tab die Anmeldung + * unbegrenzt am Leben, und die automatische Abmeldung findet nie statt. + * + * Der HTTP-Status haengt am geworfenen Fehler, damit der Aufrufer 401 + * ("Sitzung abgelaufen") von einem Netzfehler unterscheiden kann. + */ export async function api(url, options = {}) { - const opts = { credentials: "same-origin", ...options }; + const { auto = false, ...rest } = options; + const opts = { credentials: "same-origin", ...rest }; opts.headers = { "X-Requested-With": "fetch", "X-CSRF-Token": csrfToken(), + ...(auto ? { "X-Auto-Refresh": "1" } : {}), ...(options.headers || {}), }; const response = await fetch(url, opts); @@ -60,7 +72,9 @@ export async function api(url, options = {}) { const message = (payload && typeof payload === "object" && (payload.error || payload.message)) || `Anfrage fehlgeschlagen (HTTP ${response.status})`; - throw new Error(message); + const error = new Error(message); + error.status = response.status; + throw error; } return payload; } @@ -556,7 +570,10 @@ function initAutoRefresh() { const tick = async () => { if (stopped || document.hidden) return schedule(); try { - const html = await api(region.dataset.refreshUrl, { headers: { Accept: "text/html" } }); + const html = await api(region.dataset.refreshUrl, { + auto: true, + headers: { Accept: "text/html" }, + }); // Eine vollstaendige Seite gehoert nie in einen Ausschnitt. Zeigt die // URL versehentlich auf eine ganze Seite, waere das Ergebnis eine // Seite in der Seite -- bei jedem Durchlauf eine Ebene tiefer. @@ -588,7 +605,16 @@ function initAutoRefresh() { initRelativeTimes(region); initCountdowns(region); } - } catch { + } catch (error) { + // 401 heisst: die Sitzung ist abgelaufen. Weiterpollen waere sinnlos, + // und stehenbleiben waere irrefuehrend -- die Kacheln zeigten dann + // beliebig alte Zahlen, als sei alles in Ordnung. Neu laden bringt die + // Anmeldemaske mit ihrem Hinweis. + if (error && error.status === 401) { + stopped = true; + window.location.reload(); + return; + } /* Netzfehler: beim naechsten Intervall erneut versuchen. */ } schedule(); diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_archive.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_archive.html index 12f280d..7ac18b9 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_archive.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_archive.html @@ -19,7 +19,7 @@ {{ row.actor }} {{ row.action }} {{ row.target }} - {{ row.detail }} + {{ row.detail | audit_detail }} {{ row.hash[:12] }} {% endfor %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_audit.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_audit.html index ec55e75..364e7ea 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_audit.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_audit.html @@ -64,7 +64,7 @@ {{ row.actor }} {{ row.action }} {{ row.target }} - {{ row.detail }} + {{ row.detail | audit_detail }} {# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos diff --git a/packages/tesm-core/src/tesm_core/ui.py b/packages/tesm-core/src/tesm_core/ui.py index cc0b0a3..bb88e5a 100644 --- a/packages/tesm-core/src/tesm_core/ui.py +++ b/packages/tesm-core/src/tesm_core/ui.py @@ -8,12 +8,13 @@ nichts, was die CSP aufweichen muesste. from __future__ import annotations import math +import re from datetime import datetime, timezone from pathlib import Path from typing import Any from flask import Flask, url_for -from markupsafe import Markup +from markupsafe import Markup, escape ICONS: dict[str, str] = { "grid": '', @@ -263,6 +264,89 @@ def asset_url(app: Flask) -> Any: return build + +# --------------------------------------------------------------------------- # +# Auditeintraege: Dateinamen anklickbar machen +# --------------------------------------------------------------------------- # + +#: Name einer Mitschrift, wie ``terminal.py`` sie bildet: Zeitstempel, Benutzer, +#: Ziel. Der Ausdruck laesst bewusst keinen Schraegstrich zu -- der Name kommt +#: aus einem Auditeintrag, und ein Eintrag ist Text, kein Pfad. +MITSCHRIFT = re.compile(r"\b(\d{8}-\d{6}-[A-Za-z0-9._-]{1,140}\.log)\b") + +#: Unterverzeichnisse unter dem Protokollverzeichnis, in denen nachgesehen wird. +#: Der leere Eintrag steht fuer das Verzeichnis selbst. +PROTOKOLL_ORTE = ("terminal", "") + + +def _mitschrift_ziel(name: str) -> str | None: + """Der Name relativ zum Protokollverzeichnis -- oder ``None``. + + Ein Verweis auf eine Datei, die es nicht mehr gibt, ist schlechter als + keiner: er sieht nach einem Angebot aus und endet in 404. Protokolle werden + aufgeraeumt, Auditeintraege bleiben -- der Fall ist die Regel, nicht die + Ausnahme. + """ + from .extension import core + + try: + wurzel = core().config.log_dir + except Exception: # noqa: BLE001 - ausserhalb eines App-Kontexts + return None + for ort in PROTOKOLL_ORTE: + relativ = f"{ort}/{name}" if ort else name + try: + if (wurzel / relativ).is_file(): + return relativ + except OSError: # pragma: no cover - Verzeichnis nicht lesbar + continue + return None + + +def audit_detail(text: Any) -> Markup: + """Auditeintrag als Text -- Dateinamen darin als Verweis. + + Der Eintrag zur Konsole nennt seine Mitschrift beim Namen. Bisher stand + dort nur die Zeichenkette, und wer sehen wollte, was passiert ist, musste + sie von Hand in die Protokollansicht uebertragen. Es ist derselbe Weg wie + zuvor, nur einen Klick weit. + + Verlinkt wird ausschliesslich, wenn die Datei existiert **und** der + Betrachter Protokolldateien lesen darf -- sonst bliebe ein Verweis, der in + 403 endet. Der Rest des Eintrags wird maskiert; ein Auditeintrag enthaelt + Fremdeingaben (Geraetenamen etwa) und darf niemals als HTML gelten. + """ + from .auth.models import current_user + + roh = "" if text is None else str(text) + if not roh or not MITSCHRIFT.search(roh): + return Markup(escape(roh)) + try: + erlaubt = bool(current_user.is_authenticated and current_user.has("logs_history.view")) + except Exception: # noqa: BLE001 - ausserhalb einer Anfrage + erlaubt = False + if not erlaubt: + return Markup(escape(roh)) + + def verweis(name: str) -> str: + ziel = _mitschrift_ziel(name) + if ziel is None: + return str(escape(name)) + try: + url = url_for("logs.history_file", name=ziel) + except Exception: # noqa: BLE001 - Route fehlt in dieser Anwendung + return str(escape(name)) + return f'{escape(name)}' + + teile: list[str] = [] + ende = 0 + for treffer in MITSCHRIFT.finditer(roh): + teile.append(str(escape(roh[ende : treffer.start()]))) + teile.append(verweis(treffer.group(1))) + ende = treffer.end() + teile.append(str(escape(roh[ende:]))) + return Markup("".join(teile)) + def init_app(app: Flask) -> None: app.jinja_env.globals.update( icon=icon, @@ -293,6 +377,7 @@ def init_app(app: Flask) -> None: from_json=from_json, issuer_source=issuer_source, issuer_source_hint=issuer_source_hint, + audit_detail=audit_detail, ) app.jinja_env.trim_blocks = True app.jinja_env.lstrip_blocks = True diff --git a/packages/tesm-licensing-server/pyproject.toml b/packages/tesm-licensing-server/pyproject.toml index a2b34c3..f469c56 100644 --- a/packages/tesm-licensing-server/pyproject.toml +++ b/packages/tesm-licensing-server/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-licensing-server" -version = "2.0.2" +version = "2.0.3" description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten" requires-python = ">=3.11" -dependencies = ["tesm-licensing==2.0.2", "cryptography>=41"] +dependencies = ["tesm-licensing==2.0.3", "cryptography>=41"] [tool.setuptools.packages.find] where = ["src"] diff --git a/packages/tesm-licensing/pyproject.toml b/packages/tesm-licensing/pyproject.toml index 2c4107b..cd0db6c 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.2" +version = "2.0.3" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = ["cryptography>=42"]