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>
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user