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:
2026-09-03 11:43:02 +02:00
co-authored by Claude Opus 5
parent a8bd5763fb
commit 51ae39dd53
15 changed files with 944 additions and 16 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm"
version = "2.0.2"
version = "2.0.3"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11"
dependencies = [
+165
View File
@@ -243,6 +243,131 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int:
return 0
def cmd_legacy_export(args: argparse.Namespace) -> int:
"""Liest eine alte Installation und schreibt deren Daten als Exportdatei.
Der Vorgaenger kann nur ueber die Weboberflaeche exportieren: anmelden,
Kategorien anhaken, Datei herunterladen. Fuer ein Upgrade ist das der
falsche Weg -- es soll ohne Browser laufen und auch dann noch, wenn die
alte Anwendung nicht mehr startet. Gelesen wird ausschliesslich; die alte
Installation bleibt unberuehrt.
Herauskommt eine Datei im **alten** Format. Damit ist sie genau das, was
der Vorgaenger selbst exportiert haette -- und taugt deshalb auch als
Rueckweg.
"""
from getpass import getpass
from pathlib import Path
from .services import legacy_transfer as legacy
quelle = Path(args.source)
ziel = Path(args.out)
if ziel.exists() and not args.force:
print(f"{ziel} existiert bereits. Mit --force ueberschreiben.")
return 2
passphrase = args.passphrase or getpass("Passphrase fuer die Exportdatei: ")
try:
nutzlast, bericht = legacy.read_installation(quelle)
inhalt = legacy.seal_legacy(nutzlast, passphrase)
except legacy.LegacyTransferError as exc:
print(f"Fehlgeschlagen: {exc}", file=sys.stderr)
return 1
print(bericht.as_text("Gelesen"))
if args.dry_run:
print()
print("--dry-run: keine Datei geschrieben.")
return 0
try:
ziel.write_text(inhalt, encoding="utf-8", newline="\n")
except OSError as exc:
print(f"Schreiben fehlgeschlagen: {exc}", file=sys.stderr)
return 1
print()
print(f"Geschrieben: {ziel}")
return 0
def cmd_legacy_import(args: argparse.Namespace) -> int:
"""Liest eine Datei im neuen Format ein -- derselbe Weg wie in der Oberflaeche.
Gebraucht wird das fuer das Upgrade: dort laeuft niemand durch einen
Browser. Geschrieben wird ueber ``transfer.apply_import`` -- dieselbe
Funktion, die die Seite "Sicherung" benutzt. Ein zweiter Schreibpfad fuer
Fremddaten waere die Stelle, die niemand pflegt.
"""
from getpass import getpass
from pathlib import Path
from .services import transfer
quelle = Path(args.datei)
if not quelle.is_file():
print(f"Datei nicht gefunden: {quelle}", file=sys.stderr)
return 2
app, extension = _app_and_core()
passphrase = args.passphrase or getpass("Passphrase der Datei: ")
try:
nutzlast, kopf = transfer.unseal(quelle.read_bytes(), passphrase)
except transfer.TransferError as exc:
print(f"Fehlgeschlagen: {exc}", file=sys.stderr)
return 1
vorhanden = [eintrag["key"] for eintrag in transfer.summarize(nutzlast)]
gewaehlt = (
[key for key in args.categories.split(",") if key.strip()]
if args.categories
else vorhanden
)
unbekannt = sorted(set(gewaehlt) - set(transfer.CATEGORY_BY_KEY))
if unbekannt:
print(f"Unbekannte Kategorie(n): {', '.join(unbekannt)}", file=sys.stderr)
return 2
print(f"Datei vom {kopf.get('created_at') or 'unbekannt'}:")
for eintrag in transfer.summarize(nutzlast):
marke = "x" if eintrag["key"] in gewaehlt else " "
print(f" [{marke}] {eintrag['label']}: {eintrag['count']}")
if args.dry_run:
print()
print("--dry-run: nichts geschrieben.")
return 0
with app.app_context(), extension.database.session() as conn:
with extension.database.transaction(conn):
bericht = transfer.apply_import(
conn,
extension.keystore,
nutzlast,
categories=gewaehlt,
set_setting=lambda key, value: extension.settings.set(
conn, key, value, actor=args.actor
),
)
audit.log(
conn,
actor=args.actor,
action="backup.imported",
detail="; ".join(
f"{key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
f"{zahlen['skipped']} uebersprungen"
for key, zahlen in bericht.items()
)
+ " (Kommandozeile)",
severity="warning",
)
print()
for key, zahlen in sorted(bericht.items()):
print(
f" {key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
f"{zahlen['skipped']} uebersprungen"
)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="tesm-admin", description="Verwaltungswerkzeuge fuer TESM"
@@ -288,6 +413,46 @@ def build_parser() -> argparse.ArgumentParser:
legacy.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben")
legacy.set_defaults(func=cmd_convert_legacy)
export_alt = sub.add_parser(
"legacy-export",
help="Daten einer alten Installation als Exportdatei sichern",
description=(
"Liest die Datenbank einer Installation der Vorgaengerfassung und schreibt "
"Clients, Switche, Zugangsdaten und DHCP in eine Datei im alten Format. "
"Die alte Installation wird nur gelesen. Ohne Browser und auch dann noch "
"moeglich, wenn die alte Anwendung nicht mehr startet."
),
)
export_alt.add_argument(
"--source", default="/srv/tesm", help="Verzeichnis der alten Installation"
)
export_alt.add_argument("--out", required=True, help="Zieldatei")
export_alt.add_argument("--passphrase", help="Passphrase der Zieldatei (sonst wird gefragt)")
export_alt.add_argument("--dry-run", action="store_true", help="Nur den Bericht zeigen")
export_alt.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben")
export_alt.set_defaults(func=cmd_legacy_export)
import_neu = sub.add_parser(
"legacy-import",
help="Datei im Format dieser Anwendung einlesen",
description=(
"Liest eine Sicherungs- oder uebersetzte Datei ein -- derselbe Weg wie unter "
"Verwaltung -> Sicherung, nur ohne Browser. Ohne --categories werden alle "
"Kategorien uebernommen, die in der Datei stehen."
),
)
import_neu.add_argument("datei", help="Datei im Format dieser Anwendung")
import_neu.add_argument("--passphrase", help="Passphrase der Datei (sonst wird gefragt)")
import_neu.add_argument(
"--categories", help="Kommaliste, z.B. devices,switches,credentials,dhcp"
)
import_neu.add_argument(
"--actor", default="upgrade", help="Name, der im Auditlog erscheint"
)
import_neu.add_argument("--dry-run", action="store_true", help="Nur zeigen, was drin steht")
import_neu.set_defaults(func=cmd_legacy_import)
add_web_setup(sub, cmd_web_setup)
return parser
+271 -2
View File
@@ -27,6 +27,7 @@ from __future__ import annotations
import base64
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
#: Runden der alten Schluesselableitung. Steht so im Vorgaenger und darf sich
@@ -84,8 +85,13 @@ class Report:
if text not in self.notes:
self.notes.append(text)
def as_text(self) -> str:
zeilen = ["Uebersetzt:"]
def as_text(self, titel: str = "Uebersetzt") -> str:
"""Bericht als Text. Der Titel gehoert dem Aufrufer.
Beim Lesen einer alten Installation ist noch nichts uebersetzt --
"Uebersetzt: devices 4" waere an dieser Stelle einfach falsch.
"""
zeilen = [f"{titel}:"]
zeilen += [f" {key}: {value}" for key, value in sorted(self.counts.items())] or [
" nichts"
]
@@ -415,3 +421,266 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
"Beim Export im alten TESM diese Kategorien mitwaehlen."
)
return payload, report
def _eine(conn: Any, sql: str, args: tuple[Any, ...] = ()) -> Any:
"""Eine Zeile oder ``None`` -- die alte Datenbank kennt kein Database."""
return conn.execute(sql, args).fetchone()
# --------------------------------------------------------------------------- #
# Alte Installation lesen
# --------------------------------------------------------------------------- #
#: Dateinamen der Vorgaengerfassung, relativ zu ihrem Verzeichnis.
LEGACY_DB_NAME = "sqlite.db"
LEGACY_FERNET_NAME = "fernet.key"
#: Genau die Abfragen des alten Exports. Sie stehen hier als Text und nicht als
#: Nachbau: was der Vorgaenger exportiert hat, ist das, was uebersetzbar ist.
_ABFRAGEN: dict[str, str] = {
"devices": """
SELECT devices.mac, devices.ip, devices.port, devices.name,
devices.switch_hostname, devices.is_active, devices.ssh_port,
credentials.name AS credential_name
FROM devices LEFT JOIN credentials ON credentials.id = devices.credential_id
WHERE devices.deleted_at IS NULL
""",
"switches": """
SELECT switches.hostname, switches.ip, switches.ssh_port,
credentials.name AS credential_name
FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id
WHERE switches.deleted_at IS NULL
""",
}
def _oeffne_alt(pfad: Path) -> Any:
"""Oeffnet die alte Datenbank **nur lesend**.
Der alte Dienst laeuft womoeglich noch. Ein Schreibzugriff waere hier in
jedem Fall falsch, und ``mode=ro`` macht daraus einen Fehler statt einer
Moeglichkeit.
"""
import sqlite3
if not pfad.is_file():
raise LegacyTransferError(f"Keine Datenbank gefunden: {pfad}")
conn = sqlite3.connect(f"file:{pfad.as_posix()}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
return conn
def _tabellen(conn: Any) -> set[str]:
return {
str(row[0])
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
}
def _entschluessler(root: Path, report: Report) -> Any:
"""Gibt eine Funktion zurueck, die ein altes Passwort entschluesselt.
Fehlt der Schluessel, bleibt der Datensatz erhalten und das Passwort leer.
Ein Zugangsdatensatz ohne Passwort ist nachtragbar; ein abgebrochenes
Upgrade ist es nicht.
"""
schluesseldatei = root / LEGACY_FERNET_NAME
if not schluesseldatei.is_file():
report.note(
f"{LEGACY_FERNET_NAME} nicht gefunden -- die Passwoerter der Zugangsdaten "
"bleiben leer und sind nachzutragen."
)
return lambda _token: ""
from cryptography.fernet import Fernet, InvalidToken
try:
fernet = Fernet(schluesseldatei.read_bytes().strip())
except (ValueError, TypeError):
report.note(
f"{LEGACY_FERNET_NAME} ist unlesbar -- die Passwoerter der Zugangsdaten "
"bleiben leer und sind nachzutragen."
)
return lambda _token: ""
gescheitert: list[int] = []
def entschluesseln(token: Any) -> str:
if not token:
return ""
try:
return fernet.decrypt(str(token).encode("utf-8")).decode("utf-8")
except (InvalidToken, ValueError):
gescheitert.append(1)
if len(gescheitert) == 1:
report.note(
"Mindestens ein Passwort liess sich mit dem gefundenen Schluessel "
"nicht entschluesseln -- es bleibt leer."
)
return ""
return entschluesseln
def _alt_dhcp(conn: Any, vorhanden: set[str]) -> dict[str, Any] | None:
"""Baut den DHCP-Abschnitt so, wie der alte Export ihn geschrieben hat."""
if "dhcp_subnets" not in vorhanden:
return None
einstellungen: dict[str, Any] = {}
if "settings" in vorhanden:
# Nur die Schluessel holen, die der Uebersetzer kennt -- der alte
# Einstellungsbestand ist gross und heisst groesstenteils anders.
for key in DHCP_SETTING_KEYS:
row = _eine(conn, "SELECT value FROM settings WHERE key=?", (key,))
if row and str(row["value"] or ""):
einstellungen[key] = row["value"]
subnets = [
dict(row)
for row in conn.execute(
"SELECT interface, range_start, range_end, gateway, dns, sort_order, enabled "
"FROM dhcp_subnets"
).fetchall()
]
reservations = (
[
dict(row)
for row in conn.execute(
"SELECT mac, ip, name FROM dhcp_manual_reservations"
).fetchall()
]
if "dhcp_manual_reservations" in vorhanden
else []
)
options: list[dict[str, Any]] = []
if "dhcp_option_defs" in vorhanden:
for definition in conn.execute(
"SELECT id, code, name, type, description, is_standard FROM dhcp_option_defs"
).fetchall():
werte = (
conn.execute(
"SELECT device_mac, value FROM dhcp_option_values WHERE option_def_id=?",
(definition["id"],),
).fetchall()
if "dhcp_option_values" in vorhanden
else []
)
if not werte and definition["is_standard"]:
continue
options.append(
{
"code": definition["code"],
"name": definition["name"],
"type": definition["type"],
"description": definition["description"],
"is_standard": definition["is_standard"],
"values": [
{"device_mac": w["device_mac"], "value": w["value"]} for w in werte
],
}
)
if not (einstellungen or subnets or reservations or options):
return None
return {
"settings": einstellungen,
"subnets": subnets,
"reservations": reservations,
"options": options,
}
def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
"""Liest eine alte Installation und gibt deren Nutzlast zurueck.
Ergebnis ist dieselbe Struktur, die ``open_legacy`` aus einer Exportdatei
liefert -- der Uebersetzer dahinter bleibt also derselbe. Das ist der
Unterschied zum Weg ueber die Oberflaeche: fuer ein Upgrade darf niemand
sich anmelden, eine Kategorie anhaken und eine Datei herunterladen muessen.
Gelesen wird ausschliesslich; die alte Installation bleibt unberuehrt.
"""
root = Path(root)
report = Report()
conn = _oeffne_alt(root / LEGACY_DB_NAME)
try:
vorhanden = _tabellen(conn)
payload: dict[str, Any] = {}
for abschnitt, sql in _ABFRAGEN.items():
tabelle = abschnitt
if tabelle not in vorhanden:
report.note(f"Tabelle {tabelle!r} fehlt in der alten Datenbank.")
continue
payload[abschnitt] = [dict(row) for row in conn.execute(sql).fetchall()]
if "credentials" in vorhanden:
entschluesseln = _entschluessler(root, report)
payload["credentials"] = [
{
"name": row["name"],
"username": row["username"],
"password": entschluesseln(row["password"]),
"category": row["category"],
}
for row in conn.execute(
"SELECT name, username, password, category FROM credentials "
"WHERE deleted_at IS NULL"
).fetchall()
]
else:
report.note("Tabelle 'credentials' fehlt in der alten Datenbank.")
dhcp = _alt_dhcp(conn, vorhanden)
if dhcp:
payload["dhcp"] = dhcp
finally:
conn.close()
payload = {key: value for key, value in payload.items() if value}
if not payload:
raise LegacyTransferError(
f"In {root} stehen keine uebernehmbaren Daten (Clients, Switche, "
"Zugangsdaten, DHCP)."
)
for abschnitt, inhalt in payload.items():
report.took(abschnitt, len(inhalt) if isinstance(inhalt, list) else 1)
return payload, report
def seal_legacy(payload: dict[str, Any], passphrase: str) -> str:
"""Schreibt eine Nutzlast im **alten** Umschlag.
Damit ist die Datei genau das, was der Vorgaenger selbst exportiert haette
-- lesbar fuer ``open_legacy`` und fuer den Import des alten TESM. Das ist
beim Upgrade der Unterschied zwischen einem Zwischenschritt und einem
Rueckweg: geht danach etwas schief, laesst sich diese Datei in die alte
Installation zurueckspielen.
"""
import secrets
from datetime import datetime
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
if len(passphrase) < 8:
raise LegacyTransferError("Die Passphrase braucht mindestens 8 Zeichen.")
salt = secrets.token_bytes(16)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(), length=32, salt=salt, iterations=LEGACY_KDF_ITERATIONS
)
key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))
token = Fernet(key).encrypt(json.dumps(payload).encode("utf-8"))
umschlag = {
"app": LEGACY_APP_TAG,
"version": 2,
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"salt": base64.urlsafe_b64encode(salt).decode("utf-8"),
"payload": token.decode("utf-8"),
}
return json.dumps(umschlag, indent=2, ensure_ascii=False) + "\n"
@@ -115,7 +115,7 @@
{% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %}
</td>
<td class="numeric fs-sm nowrap">{{ event.duration_ms | millis }}</td>
<td class="fs-xs muted break-all">{{ event.detail }}</td>
<td class="fs-xs muted break-all">{{ event.detail | audit_detail }}</td>
</tr>
{% endfor %}
</tbody>