Compare commits

1 Commits
Author SHA1 Message Date
alientimandClaude Opus 5 2dea198bcc Upgrade von 1.x, Leerlaufabmeldung, Mitschrift im Audit
upgrade.sh -- ein Weg vom Stand SONNET5 auf diese Fassung, mit Daten. Der
Ablauf braucht keine Schalter: alte Installation sichern, abschalten, neu
installieren, exportieren, uebersetzen, einspielen. Die Sicherung steht
bewusst vor dem Export -- ohne --instance installiert install.sh in dasselbe
Verzeichnis, und wer erst exportiert und dann sichert, sichert im
schlechtesten Fall nichts mehr. Gelesen wird nur aus der Kopie.

Dafuer zwei neue Befehle: "legacy-export" liest eine alte Installation
unmittelbar (der Vorgaenger kann nur ueber die Weboberflaeche exportieren --
fuer ein Upgrade der falsche Weg, und unmoeglich, wenn die alte Anwendung nicht
mehr startet) und schreibt eine Datei im alten Format, die deshalb auch als
Rueckweg taugt. "legacy-import" liest eine Datei im neuen Format ein, ueber
transfer.apply_import -- dieselbe Funktion wie die Seite "Sicherung", kein
zweiter Schreibpfad.

Am Lizenzserver hat der Lauf gegen echte Altdaten zwei Fehler gezeigt, die
jede Uebernahme unmoeglich machten:

* Der alte Signaturschluessel ist URL-sicher kodiert (- und _). Das neue
  Protokoll prueft Base64 streng -- richtig so, aber beim Lesen einer alten
  Datei brach die Uebernahme mit einem Traceback ueber "kein gueltiges Base64"
  ab. Umkodiert wird jetzt an der Grenze zum Altformat; der kanonische Decoder
  bleibt streng.
* Der Anbieterblock wurde aus den Einstellungen der *neuen* Installation
  gebaut, und ohne Endpunkt brach die Uebernahme ab. Bei einem Upgrade ist die
  neue Installation frisch -- dort steht nichts. Endpunkt und Anbieterdaten
  kommen jetzt aus der alten Installation, aber nur dort, wo neu nichts steht.
  Der alte Signaturschluessel wird uebernommen: sonst muesste jeder Kunde
  einen Ausstellerwechsel bestaetigen.

Leerlaufabmeldung: sie hat nie ausgeloest. Ein offener Tab fragt seine
Kacheln alle 15 Sekunden neu ab, und jede Abfrage verlaengerte das
Leerlauffenster -- nachweisbar an einer Sitzung, deren last_seen_at eine
Minute alt war, obwohl seit zehn Stunden niemand am Rechner sass. Der Client
kennzeichnet automatische Anfragen jetzt mit einem Kopf, der Server
verlaengert daraufhin nichts, und last_seen_at bleibt, was der Name sagt.
Faellt die Sitzung waehrend des Pollens, antwortet der Server mit 401 statt
mit einer Umleitung: der Tab laedt neu und zeigt die Anmeldemaske, statt
stumm mit alten Zahlen stehenzubleiben.

Im Audit ist der Name der Mitschrift jetzt ein Verweis auf die Datei -- es ist
derselbe Weg wie zuvor, nur einen Klick weit. Verlinkt wird nur, wenn die
Datei existiert und der Betrachter Protokolle lesen darf; der Rest des
Eintrags wird maskiert, denn ein Auditeintrag enthaelt Fremdeingaben.

Ausserdem: Version 2.0.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 11:43:04 +02:00
15 changed files with 631 additions and 19 deletions
+1 -1
View File
@@ -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 = [
+25 -2
View File
@@ -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
@@ -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,
+288
View File
@@ -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/<name>-upgrade-<zeitstempel>/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 <tesm|tesm-license> [Optionen]
--app <name> Welche Anwendung (Pflicht)
--source <pfad> Verzeichnis der alten Installation
(Vorgabe: /srv/tesm bzw. /srv/tesm-license)
--instance <name> Neue Installation als benannte Instanz anlegen, statt
das alte Verzeichnis zu ueberschreiben. Damit bleibt die
alte Installation liegen und ist zurueckholbar.
--passphrase <text> 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 <<TEXT
Von Hand nachzuziehen -- das laesst sich nicht uebersetzen:
* Lokale Konten: der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto anlegen mit
sudo -u $APP $ADMIN create-admin
* Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.
* Verzeichnisdienst (LDAP), Webserver und Zertifikat.
TEXT
# Der Rueckweg sieht in den beiden Faellen verschieden aus. Ein Hinweis, der
# das alte Verzeichnis wiederherstellen laesst, obwohl es nie angefasst wurde,
# waere schlimmer als keiner: er wuerde die Daten der neuen Installation mit
# einem alten Stand ueberschreiben.
if [[ $IN_PLACE -eq 1 ]]; then
cat <<TEXT
Zurueck auf den alten Stand -- das Verzeichnis wurde ersetzt, die Kopie
bringt es zurueck:
sudo systemctl disable --now $NAME
sudo rm -rf $SOURCE && sudo mkdir -p $SOURCE
sudo tar -C $ALT_KOPIE -cf - . | sudo tar -C $SOURCE -xf -
# venv der alten Installation neu bauen:
sudo python3 -m venv $SOURCE/venv
sudo $SOURCE/venv/bin/python -m pip install -r $SOURCE/requirements.txt
sudo systemctl enable --now ${ALTE_UNITS[0]}
TEXT
else
cat <<TEXT
Zurueck auf den alten Stand -- $SOURCE wurde nicht angefasst, es genuegt,
die neue Instanz abzuschalten:
sudo systemctl disable --now $NAME $NAME-monitor
sudo rm -f /etc/nginx/sites-enabled/$NAME && sudo systemctl reload nginx
sudo systemctl enable --now ${ALTE_UNITS[0]}
TEXT
fi
+58
View File
@@ -141,6 +141,64 @@ etwa auf eine Spiegelung:
Der erwartete Anhang heisst `<repo>-<tag>.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/<name>-upgrade-<zeitstempel>/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
+1 -1
View File
@@ -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 = [
@@ -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,
@@ -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")
@@ -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=?",
@@ -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();
@@ -19,7 +19,7 @@
<td class="fs-sm">{{ row.actor }}</td>
<td class="mono fs-xs">{{ row.action }}</td>
<td class="fs-sm">{{ row.target }}</td>
<td class="fs-sm muted">{{ row.detail }}</td>
<td class="fs-sm muted">{{ row.detail | audit_detail }}</td>
<td class="mono fs-xs faint">{{ row.hash[:12] }}</td>
</tr>
{% endfor %}
@@ -64,7 +64,7 @@
<td class="fs-sm fw-600">{{ row.actor }}</td>
<td class="mono fs-xs">{{ row.action }}</td>
<td class="fs-sm">{{ row.target }}</td>
<td class="fs-sm muted break-all">{{ row.detail }}</td>
<td class="fs-sm muted break-all">{{ row.detail | audit_detail }}</td>
<td>
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
+86 -1
View File
@@ -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": '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
@@ -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'<a href="{escape(url)}" title="Mitschrift ansehen">{escape(name)}</a>'
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
@@ -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"]
+1 -1
View File
@@ -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"]