Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d31173fa5e | ||
|
|
05885bfff1 | ||
|
|
168d10bf87 | ||
|
|
2dea198bcc |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-license"
|
name = "tesm-license"
|
||||||
version = "2.0.2"
|
version = "2.0.5"
|
||||||
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -336,6 +336,28 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
|||||||
key = master_key.load(key_path)
|
key = master_key.load(key_path)
|
||||||
|
|
||||||
with app.app_context(), extension.database.session() as conn:
|
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 = {
|
vendor = {
|
||||||
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
|
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
|
||||||
"email": str(extension.settings.get(conn, "vendor_email") or ""),
|
"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 "")
|
endpoint = str(extension.settings.get(conn, "master_endpoint") or "")
|
||||||
if not endpoint and not args.dry_run:
|
if not endpoint and not args.dry_run:
|
||||||
print(
|
print(
|
||||||
"Es ist kein Endpunkt hinterlegt (Einstellungen -> Anbieter). Ohne ihn "
|
"Es ist kein Endpunkt hinterlegt, und die alte Installation nennt "
|
||||||
"koennen uebernommene Lizenzen sich spaeter nicht aktivieren.",
|
"auch keinen. Bitte unter Einstellungen -> Anbieter eintragen: ohne "
|
||||||
|
"ihn koennen uebernommene Lizenzen sich spaeter nicht aktivieren.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ angefasst, nicht angehalten und nicht veraendert.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -110,6 +112,39 @@ def open_legacy(root: Path) -> sqlite3.Connection:
|
|||||||
return conn
|
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]:
|
def read_legacy_key(root: Path) -> tuple[str, str]:
|
||||||
"""Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``."""
|
"""Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``."""
|
||||||
path = Path(root) / "master_signing_key.json"
|
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:
|
except (OSError, ValueError) as exc:
|
||||||
raise LegacyImportError(f"Der alte Signaturschluessel ist unlesbar: {exc}") from 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 "")
|
private = str(data.get("private") or data.get("private_key") or "")
|
||||||
public = str(data.get("public") or data.get("public_key") or "")
|
public = str(data.get("public") or data.get("public_key") or "")
|
||||||
if not private or not public:
|
if not private or not public:
|
||||||
raise LegacyImportError("Der alte Signaturschluessel ist unvollstaendig.")
|
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(
|
raise LegacyImportError(
|
||||||
"Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht "
|
"Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht "
|
||||||
"zueinander. Die Datei ist beschaedigt."
|
"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))
|
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(
|
def import_all(
|
||||||
legacy: sqlite3.Connection,
|
legacy: sqlite3.Connection,
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
|
|||||||
@@ -295,6 +295,42 @@ install -o root -g root -m 0440 "$TMP_SUDO" "/etc/sudoers.d/$APP"
|
|||||||
rm -f "$TMP_SUDO"
|
rm -f "$TMP_SUDO"
|
||||||
info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen"
|
info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen"
|
||||||
|
|
||||||
|
# -- Anmeldebanner (MOTD) ----------------------------------------------------
|
||||||
|
# Das Banner liest die Datenbank dieser Installation und laeuft dabei mit dem
|
||||||
|
# System-Python, ohne das venv und ohne die Anwendung zu importieren. Der
|
||||||
|
# Vorgaenger importierte dafuer die ganze Flask-App -- bei jeder Anmeldung, und
|
||||||
|
# nach einem Wechsel des Quellverzeichnisses zeigte er stillschweigend die
|
||||||
|
# Zahlen einer Datenbank, die niemand mehr fuellte.
|
||||||
|
#
|
||||||
|
# "timeout" verhindert, dass ein haengender Dateizugriff die Anmeldung
|
||||||
|
# blockiert; "|| true" sorgt dafuer, dass ein Fehler hier den Login nie stoert.
|
||||||
|
step "Anmeldebanner"
|
||||||
|
if [[ -d /etc/update-motd.d ]]; then
|
||||||
|
install_if_changed "$ROOT/deploy/tesm-motd" /usr/local/lib/tesm/tesm-motd 0755 >/dev/null \
|
||||||
|
&& info "Bannerskript aktualisiert" || info "Bannerskript unveraendert"
|
||||||
|
TMP_MOTD="$(mktemp)"
|
||||||
|
{
|
||||||
|
echo "#!/bin/sh"
|
||||||
|
echo "# Von TESM erzeugt ($NAME). Aenderungen werden bei der naechsten"
|
||||||
|
echo "# Installation ueberschrieben."
|
||||||
|
echo "timeout 3 /usr/local/lib/tesm/tesm-motd --app $APP --root $ROOT 2>/dev/null || true"
|
||||||
|
} > "$TMP_MOTD"
|
||||||
|
install -o root -g root -m 0755 "$TMP_MOTD" "/etc/update-motd.d/50-$NAME"
|
||||||
|
rm -f "$TMP_MOTD"
|
||||||
|
info "Banner: /etc/update-motd.d/50-$NAME"
|
||||||
|
# Ein Banner der Vorgaengerfassung zeigt Zahlen aus einer Datenbank, die
|
||||||
|
# niemand mehr fuellt. Er wird nur entfernt, wenn er genau darauf zeigt --
|
||||||
|
# nichts anderes wird angefasst.
|
||||||
|
ALT_MOTD="/etc/update-motd.d/50-tesm"
|
||||||
|
if [[ "$ALT_MOTD" != "/etc/update-motd.d/50-$NAME" && -f "$ALT_MOTD" ]] \
|
||||||
|
&& grep -q "motd_banner.py" "$ALT_MOTD" 2>/dev/null; then
|
||||||
|
rm -f "$ALT_MOTD"
|
||||||
|
info "Banner der Vorgaengerfassung entfernt ($ALT_MOTD)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
info "Kein /etc/update-motd.d -- Banner uebersprungen"
|
||||||
|
fi
|
||||||
|
|
||||||
# -- Rechte ------------------------------------------------------------------
|
# -- Rechte ------------------------------------------------------------------
|
||||||
step "Dateirechte"
|
step "Dateirechte"
|
||||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR"
|
chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR"
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Live-Banner fuer die SSH-Anmeldung (MOTD).
|
||||||
|
|
||||||
|
Eingebunden ueber ``/etc/update-motd.d/50-<name>``, das ``install.sh``
|
||||||
|
erzeugt. Aufruf mit ``--app`` und ``--root``; alles andere steht in der
|
||||||
|
Datenbank der Installation.
|
||||||
|
|
||||||
|
**Nur Standardbibliothek, kein Flask, kein venv.** Der Vorgaenger importierte
|
||||||
|
dafuer die ganze Anwendung -- bei jeder SSH-Anmeldung. Das ist teuer, und es
|
||||||
|
faellt aus, sobald das venv nicht mehr passt: nach dem Umstieg auf diese
|
||||||
|
Fassung zeigte das alte Banner monatelang eingefrorene Zahlen aus einer
|
||||||
|
Datenbank, die niemand mehr fuellte. Hier wird gelesen, was auf der Platte
|
||||||
|
liegt, und zwar **nur lesend** (``mode=ro``).
|
||||||
|
|
||||||
|
Ein Banner darf eine Anmeldung niemals stoeren: jede Auskunft, die nicht zu
|
||||||
|
holen ist, wird zu einem kurzen Hinweis, und am Ende faengt ``main`` alles ab.
|
||||||
|
Der Aufrufer setzt zusaetzlich ein Zeitlimit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
AMBER = "\033[38;2;226;166;60m"
|
||||||
|
DIM = "\033[2m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
RESET = "\033[0m"
|
||||||
|
|
||||||
|
#: Kea schreibt seine Leases als CSV; die letzte Zeile je Adresse gilt.
|
||||||
|
#: Dieselbe Datei liest die Anwendung (``services/kea.py``: ``LEASE_FILE``).
|
||||||
|
LEASE_FILE = "/var/lib/kea/kea-leases4.csv"
|
||||||
|
|
||||||
|
#: Zeile unter der Wortmarke. Wortlaut wie in der Anwendung
|
||||||
|
#: (``apps/tesm/src/tesm/__init__.py``: ``AUTH_TAGLINE``) -- ein Hygienetest
|
||||||
|
#: haelt beides zusammen.
|
||||||
|
#:
|
||||||
|
#: Der Lizenzserver hat bewusst keine: die Funktionsliste von TESM waere dort
|
||||||
|
#: falsch, und eine erfundene waere schlimmer als keine.
|
||||||
|
TAGLINE = {
|
||||||
|
"tesm": "UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)",
|
||||||
|
"tesm-license": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
TITEL = {"tesm": "TimEShepManager", "tesm-license": "Lizenzserver"}
|
||||||
|
|
||||||
|
#: Wortgleich mit ``services/monitor.py``: ``DASHBOARD_COUNT_SQL``. Die
|
||||||
|
#: Statusuebersicht und dieses Banner sollen nicht zwei Wahrheiten haben; ein
|
||||||
|
#: Hygienetest vergleicht die beiden Zeichenketten.
|
||||||
|
DASHBOARD_COUNT_SQL = """
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
|
||||||
|
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='online' THEN 1 ELSE 0 END) AS online,
|
||||||
|
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='offline' THEN 1 ELSE 0 END) AS offline,
|
||||||
|
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='unknown' THEN 1 ELSE 0 END) AS unknown,
|
||||||
|
SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting
|
||||||
|
FROM devices d LEFT JOIN device_status st ON st.device_id = d.id
|
||||||
|
WHERE d.deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _zahl(anzahl: int, singular: str, plural: str) -> str:
|
||||||
|
"""Zahl mit passendem Wort. "1 Clients" liest man jeden Tag einmal zu oft."""
|
||||||
|
return f"{BOLD}{anzahl}{RESET} {singular if anzahl == 1 else plural}"
|
||||||
|
|
||||||
|
|
||||||
|
def _oeffnen(root: Path) -> sqlite3.Connection:
|
||||||
|
pfad = root / "data" / "app.db"
|
||||||
|
conn = sqlite3.connect(f"file:{pfad.as_posix()}?mode=ro", uri=True, timeout=1.0)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _flotte(conn: sqlite3.Connection) -> str:
|
||||||
|
row = conn.execute(DASHBOARD_COUNT_SQL).fetchone()
|
||||||
|
online = int(row["online"] or 0)
|
||||||
|
offline = int(row["offline"] or 0)
|
||||||
|
teile = [
|
||||||
|
_zahl(online, "Client online", "Clients online"),
|
||||||
|
f"{BOLD}{offline}{RESET} nicht erreichbar",
|
||||||
|
]
|
||||||
|
# Nur nennen, wenn es sie gibt: eine Null bei "startet neu" ist keine
|
||||||
|
# Auskunft, sondern Fuellmaterial.
|
||||||
|
restarting = int(row["restarting"] or 0)
|
||||||
|
if restarting:
|
||||||
|
teile.append(_zahl(restarting, "startet neu", "starten neu"))
|
||||||
|
unbekannt = int(row["unknown"] or 0)
|
||||||
|
if unbekannt:
|
||||||
|
teile.append(f"{DIM}{unbekannt} ohne Pruefung{RESET}")
|
||||||
|
return " · ".join(teile)
|
||||||
|
|
||||||
|
|
||||||
|
def _dhcp() -> str:
|
||||||
|
"""Aktive Leases aus der Kea-CSV. Laeuft als root, braucht keinen Helfer."""
|
||||||
|
datei = Path(LEASE_FILE)
|
||||||
|
if not datei.is_file():
|
||||||
|
return "keine Lease-Datei"
|
||||||
|
try:
|
||||||
|
zeilen = datei.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
except OSError:
|
||||||
|
return "Lease-Datei nicht lesbar"
|
||||||
|
if len(zeilen) < 2:
|
||||||
|
return "0 Leases aktiv"
|
||||||
|
kopf = [name.strip().strip('"') for name in zeilen[0].split(",")]
|
||||||
|
try:
|
||||||
|
i_adresse = kopf.index("address")
|
||||||
|
i_ablauf = kopf.index("expire")
|
||||||
|
i_zustand = kopf.index("state")
|
||||||
|
except ValueError:
|
||||||
|
return "Lease-Datei mit unerwarteten Spalten"
|
||||||
|
|
||||||
|
jetzt = int(time.time())
|
||||||
|
letzte: dict[str, tuple[int, str]] = {}
|
||||||
|
for zeile in zeilen[1:]:
|
||||||
|
felder = [feld.strip().strip('"') for feld in zeile.split(",")]
|
||||||
|
if len(felder) < len(kopf):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
letzte[felder[i_adresse]] = (int(felder[i_ablauf] or 0), felder[i_zustand] or "0")
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
aktiv = sum(1 for ablauf, zustand in letzte.values() if zustand == "0" and ablauf > jetzt)
|
||||||
|
return f"{aktiv} {'Lease' if aktiv == 1 else 'Leases'} aktiv"
|
||||||
|
|
||||||
|
|
||||||
|
def _lizenzen(conn: sqlite3.Connection) -> str:
|
||||||
|
kunden = int(conn.execute("SELECT COUNT(*) FROM license_customers").fetchone()[0] or 0)
|
||||||
|
lizenzen = int(
|
||||||
|
conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM licenses WHERE status IN ('issued','active')"
|
||||||
|
).fetchone()[0]
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
_zahl(kunden, "Kunde", "Kunden")
|
||||||
|
+ " · "
|
||||||
|
+ _zahl(lizenzen, "Lizenz ausgestellt", "Lizenzen ausgestellt")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="tesm-motd", description="Banner fuer die SSH-Anmeldung")
|
||||||
|
parser.add_argument("--app", required=True, choices=sorted(TITEL))
|
||||||
|
parser.add_argument("--root", required=True, help="Verzeichnis der Installation")
|
||||||
|
parser.add_argument("--no-color", action="store_true")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if args.no_color:
|
||||||
|
globals().update(AMBER="", DIM="", BOLD="", RESET="")
|
||||||
|
|
||||||
|
root = Path(args.root)
|
||||||
|
# Kein isatty()-Test: dieses Skript schreibt beim Login nicht auf ein
|
||||||
|
# Terminal, sondern ueber run-parts in eine Datei. Der Betrachter ist
|
||||||
|
# trotzdem ein echtes Terminal -- deshalb immer Farbe.
|
||||||
|
print(f"{BOLD}{AMBER}TESM{RESET} · {TITEL[args.app]}")
|
||||||
|
if TAGLINE[args.app]:
|
||||||
|
print(f"{DIM}{TAGLINE[args.app]}{RESET}")
|
||||||
|
print()
|
||||||
|
try:
|
||||||
|
conn = _oeffnen(root)
|
||||||
|
except sqlite3.Error:
|
||||||
|
print(f"{DIM}Datenbank nicht lesbar: {root}{RESET}")
|
||||||
|
print()
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
if args.app == "tesm":
|
||||||
|
print(f"{_flotte(conn)} · DHCP: {_dhcp()}")
|
||||||
|
else:
|
||||||
|
print(_lizenzen(conn))
|
||||||
|
except sqlite3.Error as fehler:
|
||||||
|
print(f"{DIM}Zustand nicht verfuegbar ({fehler}){RESET}")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
print()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(main())
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception: # noqa: BLE001 - ein Banner darf keinen Login stoeren
|
||||||
|
sys.exit(0)
|
||||||
@@ -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
|
||||||
@@ -141,6 +141,64 @@ etwa auf eine Spiegelung:
|
|||||||
|
|
||||||
Der erwartete Anhang heisst `<repo>-<tag>.tar.gz`.
|
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
|
## 4. HTTPS
|
||||||
@@ -193,6 +251,38 @@ würde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 4a. Anmeldebanner (MOTD)
|
||||||
|
|
||||||
|
`install.sh` legt `/etc/update-motd.d/50-<name>` an. Bei jeder SSH-Anmeldung
|
||||||
|
steht dann der Zustand der Installation im Login-Banner:
|
||||||
|
|
||||||
|
```
|
||||||
|
TESM · TimEShepManager
|
||||||
|
UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)
|
||||||
|
|
||||||
|
3 Clients online · 0 nicht erreichbar · DHCP: 2 Leases aktiv
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript liegt unter `/usr/local/lib/tesm/tesm-motd` und benutzt **nur die
|
||||||
|
Standardbibliothek**: kein venv, kein Import der Anwendung, die Datenbank wird
|
||||||
|
nur lesend geöffnet. Ein Lauf dauert Millisekunden. Der Vorgänger importierte
|
||||||
|
dafür die ganze Flask-Anwendung -- bei jeder Anmeldung, und nach einem Wechsel
|
||||||
|
des Quellverzeichnisses zeigte er stillschweigend die Zahlen einer Datenbank,
|
||||||
|
die niemand mehr füllte.
|
||||||
|
|
||||||
|
Von Hand prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /usr/local/lib/tesm/tesm-motd --app tesm --root /srv/tesm-opus
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Aufruf im Banner ist mit `timeout 3` und `|| true` abgesichert: ein
|
||||||
|
hängender oder fehlschlagender Bannerlauf darf eine Anmeldung nie blockieren.
|
||||||
|
Ein Banner der Vorgängerfassung (`50-tesm`, das `motd_banner.py` aufruft) wird
|
||||||
|
entfernt -- nur dieses, nichts anderes in dem Verzeichnis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 5. Dienste
|
## 5. Dienste
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.0.2"
|
version = "2.0.5"
|
||||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ def create_base_app(
|
|||||||
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
|
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
|
||||||
#: Funktionsliste von TESM waere dort schlicht falsch.
|
#: Funktionsliste von TESM waere dort schlicht falsch.
|
||||||
auth_tagline: str = "",
|
auth_tagline: str = "",
|
||||||
app_version: str = "2.0.2",
|
app_version: str = "2.0.5",
|
||||||
built_in_trusted_keys: tuple[str, ...] = (),
|
built_in_trusted_keys: tuple[str, ...] = (),
|
||||||
enable_license: bool = True,
|
enable_license: bool = True,
|
||||||
template_folder: str | None = None,
|
template_folder: str | None = None,
|
||||||
@@ -259,6 +259,17 @@ def create_base_app(
|
|||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def _finish_request(response): # type: ignore[no-untyped-def]
|
def _finish_request(response): # type: ignore[no-untyped-def]
|
||||||
|
# Ein Aktualisierungsdurchlauf, der mit Sitzungscookie kommt und
|
||||||
|
# unangemeldet endet, hat gerade seine Sitzung verloren. Bei einer
|
||||||
|
# geschuetzten Seite sagt das schon der Status (401); bei einer, die
|
||||||
|
# auch oeffentlich antwortet -- die Statusuebersicht etwa -- kommt eine
|
||||||
|
# 200 mit den oeffentlichen Kacheln zurueck, und der offene Tab merkt
|
||||||
|
# nichts: er zeigt weiter eine Oberflaeche, als sei alles in Ordnung.
|
||||||
|
# Deshalb ein Kopf, den der Browser unabhaengig vom Status auswerten
|
||||||
|
# kann.
|
||||||
|
if session_store.is_automatic(request) and not current_user.is_authenticated:
|
||||||
|
if request.cookies.get(app.config["SESSION_COOKIE_NAME"], ""):
|
||||||
|
response.headers["X-Session-Expired"] = "1"
|
||||||
hsts = ""
|
hsts = ""
|
||||||
if config.security.hsts_enabled:
|
if config.security.hsts_enabled:
|
||||||
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
|
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from flask import abort, flash, g, redirect, request, session, url_for
|
|||||||
from werkzeug.local import LocalProxy
|
from werkzeug.local import LocalProxy
|
||||||
|
|
||||||
from ..rbac.model import Action, PermissionTree
|
from ..rbac.model import Action, PermissionTree
|
||||||
|
from ..security.sessions import is_automatic
|
||||||
|
|
||||||
AUTH_SOURCES = ("local", "ldap")
|
AUTH_SOURCES = ("local", "ldap")
|
||||||
|
|
||||||
@@ -189,6 +190,14 @@ def _deny(message: str) -> Any:
|
|||||||
if not current_user.is_authenticated:
|
if not current_user.is_authenticated:
|
||||||
if request.method == "GET" and not request.headers.get("X-Requested-With"):
|
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
|
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:
|
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
|
||||||
abort(401, description="Anmeldung erforderlich.")
|
abort(401, description="Anmeldung erforderlich.")
|
||||||
flash("Bitte melden Sie sich an.", "info")
|
flash("Bitte melden Sie sich an.", "info")
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import Flask, Request, Response
|
from flask import Flask, Request, Response
|
||||||
|
from flask import request as aktuelle_anfrage
|
||||||
from flask.sessions import SessionInterface, SessionMixin
|
from flask.sessions import SessionInterface, SessionMixin
|
||||||
from werkzeug.datastructures import CallbackDict
|
from werkzeug.datastructures import CallbackDict
|
||||||
|
|
||||||
@@ -34,6 +35,79 @@ from ..db import Database
|
|||||||
|
|
||||||
SESSION_ID_BYTES = 32
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
#: Marke auf der Ansichtsfunktion. Steht an der Route, damit man sie dort sieht,
|
||||||
|
#: wo sie gilt.
|
||||||
|
FRAGMENT_ATTRIBUT = "_tesm_fragment"
|
||||||
|
|
||||||
|
|
||||||
|
def fragment(view: Any) -> Any:
|
||||||
|
"""Kennzeichnet eine Route als Aktualisierungsausschnitt.
|
||||||
|
|
||||||
|
Solche Routen ruft ein Zeitgeber auf, kein Mensch. Sie duerfen die Sitzung
|
||||||
|
benutzen, halten sie aber nicht am Leben -- sonst bleibt jeder offene Tab
|
||||||
|
unbegrenzt angemeldet.
|
||||||
|
|
||||||
|
Der Kopf ``X-Auto-Refresh`` allein genuegt dafuer nicht: er setzt voraus,
|
||||||
|
dass der Browser das aktuelle Skript faehrt. Ein Tab, der seit Stunden
|
||||||
|
offen ist, faehrt das alte -- und das ist genau der Tab, um den es geht.
|
||||||
|
"""
|
||||||
|
setattr(view, FRAGMENT_ATTRIBUT, True)
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
|
def _ist_fragment_route() -> bool:
|
||||||
|
"""Gehoert die laufende Anfrage zu einer als Ausschnitt markierten Route?
|
||||||
|
|
||||||
|
``request.endpoint`` steht hier nicht immer schon: Flask oeffnet die Sitzung
|
||||||
|
**vor** dem Routing (``RequestContext.push`` ruft ``open_session`` und erst
|
||||||
|
danach ``match_request``). In ``save_session`` ist der Endpunkt gesetzt, in
|
||||||
|
``open_session`` nicht -- und ohne diesen Nachtrag griff die Erkennung
|
||||||
|
genau dort nicht, wo sie gebraucht wird.
|
||||||
|
"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
try:
|
||||||
|
endpunkt = getattr(aktuelle_anfrage, "endpoint", None)
|
||||||
|
if not endpunkt:
|
||||||
|
# Selbst nachsehen. Ein zweiter Routenabgleich je Anfrage ist
|
||||||
|
# billiger als eine Abmeldung, die nicht stattfindet.
|
||||||
|
adapter = current_app.url_map.bind_to_environ(aktuelle_anfrage.environ)
|
||||||
|
endpunkt, _ = adapter.match(
|
||||||
|
aktuelle_anfrage.path, method=aktuelle_anfrage.method
|
||||||
|
)
|
||||||
|
ansicht = current_app.view_functions.get(str(endpunkt))
|
||||||
|
except Exception: # noqa: BLE001 - keine Route, Umleitung, kein App-Kontext
|
||||||
|
return False
|
||||||
|
return bool(getattr(ansicht, FRAGMENT_ATTRIBUT, False))
|
||||||
|
|
||||||
|
|
||||||
|
def is_automatic(request: Request) -> bool:
|
||||||
|
"""Kam die Anfrage von einem Zeitgeber statt von einem Menschen?
|
||||||
|
|
||||||
|
Zwei Kennzeichen, und eines genuegt: der Kopf, den das Skript setzt,
|
||||||
|
und die Route selbst. Das zweite traegt auch dann, wenn ein lange
|
||||||
|
offener Tab noch ein aelteres Skript faehrt -- und das ist der Tab,
|
||||||
|
um den es geht.
|
||||||
|
"""
|
||||||
|
if request.headers.get(AUTO_REFRESH_HEADER, "") == "1":
|
||||||
|
return True
|
||||||
|
return _ist_fragment_route()
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
@@ -154,8 +228,17 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
}
|
}
|
||||||
# Leerlauffenster verlaengern, aber hoechstens einmal pro Minute
|
# Leerlauffenster verlaengern, aber hoechstens einmal pro Minute
|
||||||
# schreiben -- sonst kostet jeder AJAX-Poll einen Schreibvorgang.
|
# 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"))
|
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):
|
with self.database.transaction(conn):
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?",
|
"UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?",
|
||||||
@@ -211,6 +294,31 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
session.new = False
|
session.new = False
|
||||||
|
# ``save_session`` bekommt die Anfrage nicht als Argument -- anders als
|
||||||
|
# ``open_session``, wo ein Parameter denselben Namen traegt. Hier ist es
|
||||||
|
# der Anfragekontext von Flask; die Antwort wird darin erzeugt.
|
||||||
|
elif is_automatic(aktuelle_anfrage):
|
||||||
|
# Die zweite Schreibstelle, und die eigentliche Ursache dafuer,
|
||||||
|
# dass die Abmeldung nie ausgeloest hat: hier wurde das
|
||||||
|
# Leerlauffenster bei **jeder** Anfrage verlaengert, die die
|
||||||
|
# Sitzung anfasst. Die Pruefung in open_session lief damit ins
|
||||||
|
# Leere -- ein Aktualisierungsdurchlauf einer Kachel fasst die
|
||||||
|
# Sitzung an (Flash-Speicher, CSRF), und schon stand das Fenster
|
||||||
|
# wieder 30 Minuten offen. Auf dem Lizenzserver fiel es nicht
|
||||||
|
# auf: dort pollt keine Kachel.
|
||||||
|
#
|
||||||
|
# Geaenderte Sitzungsdaten muessen trotzdem erhalten bleiben --
|
||||||
|
# sie zu verwerfen waere eine zweite Fehlerquelle. Nur die
|
||||||
|
# Fristen bleiben stehen.
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE user_sessions SET user_id=?, data=?, reauth_at=? WHERE id=?",
|
||||||
|
(
|
||||||
|
session.get("user_id"),
|
||||||
|
payload,
|
||||||
|
session.get("_reauth_at"),
|
||||||
|
session.sid,
|
||||||
|
),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
|
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
|
||||||
@@ -219,7 +327,11 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
session.get("user_id"),
|
session.get("user_id"),
|
||||||
payload,
|
payload,
|
||||||
_iso(now),
|
_iso(now),
|
||||||
_iso(now + timedelta(minutes=self.idle_minutes)),
|
# ``_idle(conn)`` und nicht ``idle_minutes``: die Zeit ist
|
||||||
|
# zur Laufzeit einstellbar, und diese Stelle hat die
|
||||||
|
# Einstellung ignoriert. Bei der Vorgabe von 30 Minuten
|
||||||
|
# fiel das nicht auf -- bei einer geaenderten schon.
|
||||||
|
_iso(now + timedelta(minutes=self._idle(conn))),
|
||||||
session.get("_reauth_at"),
|
session.get("_reauth_at"),
|
||||||
session.sid,
|
session.sid,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -45,22 +45,45 @@ function csrfToken() {
|
|||||||
return document.body?.dataset.csrf || "";
|
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 = {}) {
|
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 = {
|
opts.headers = {
|
||||||
"X-Requested-With": "fetch",
|
"X-Requested-With": "fetch",
|
||||||
"X-CSRF-Token": csrfToken(),
|
"X-CSRF-Token": csrfToken(),
|
||||||
|
...(auto ? { "X-Auto-Refresh": "1" } : {}),
|
||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
};
|
};
|
||||||
const response = await fetch(url, opts);
|
const response = await fetch(url, opts);
|
||||||
|
// Eine Seite, die auch oeffentlich antwortet -- die Statusuebersicht etwa --
|
||||||
|
// liefert nach dem Ablauf der Sitzung eine 200 mit den oeffentlichen
|
||||||
|
// Kacheln. Am Status ist der Verlust also nicht zu erkennen; an diesem Kopf
|
||||||
|
// schon. Behandelt wird er wie eine 401.
|
||||||
|
if (response.headers.get("X-Session-Expired") === "1") {
|
||||||
|
const abgelaufen = new Error("Die Sitzung ist abgelaufen.");
|
||||||
|
abgelaufen.status = 401;
|
||||||
|
throw abgelaufen;
|
||||||
|
}
|
||||||
const type = response.headers.get("content-type") || "";
|
const type = response.headers.get("content-type") || "";
|
||||||
const payload = type.includes("application/json") ? await response.json() : await response.text();
|
const payload = type.includes("application/json") ? await response.json() : await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message =
|
const message =
|
||||||
(payload && typeof payload === "object" && (payload.error || payload.message)) ||
|
(payload && typeof payload === "object" && (payload.error || payload.message)) ||
|
||||||
`Anfrage fehlgeschlagen (HTTP ${response.status})`;
|
`Anfrage fehlgeschlagen (HTTP ${response.status})`;
|
||||||
throw new Error(message);
|
const error = new Error(message);
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
@@ -556,7 +579,10 @@ function initAutoRefresh() {
|
|||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
if (stopped || document.hidden) return schedule();
|
if (stopped || document.hidden) return schedule();
|
||||||
try {
|
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
|
// Eine vollstaendige Seite gehoert nie in einen Ausschnitt. Zeigt die
|
||||||
// URL versehentlich auf eine ganze Seite, waere das Ergebnis eine
|
// URL versehentlich auf eine ganze Seite, waere das Ergebnis eine
|
||||||
// Seite in der Seite -- bei jedem Durchlauf eine Ebene tiefer.
|
// Seite in der Seite -- bei jedem Durchlauf eine Ebene tiefer.
|
||||||
@@ -588,7 +614,16 @@ function initAutoRefresh() {
|
|||||||
initRelativeTimes(region);
|
initRelativeTimes(region);
|
||||||
initCountdowns(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. */
|
/* Netzfehler: beim naechsten Intervall erneut versuchen. */
|
||||||
}
|
}
|
||||||
schedule();
|
schedule();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<td class="fs-sm">{{ row.actor }}</td>
|
<td class="fs-sm">{{ row.actor }}</td>
|
||||||
<td class="mono fs-xs">{{ row.action }}</td>
|
<td class="mono fs-xs">{{ row.action }}</td>
|
||||||
<td class="fs-sm">{{ row.target }}</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>
|
<td class="mono fs-xs faint">{{ row.hash[:12] }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
<td class="fs-sm fw-600">{{ row.actor }}</td>
|
<td class="fs-sm fw-600">{{ row.actor }}</td>
|
||||||
<td class="mono fs-xs">{{ row.action }}</td>
|
<td class="mono fs-xs">{{ row.action }}</td>
|
||||||
<td class="fs-sm">{{ row.target }}</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>
|
<td>
|
||||||
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
|
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
|
||||||
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
|
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ nichts, was die CSP aufweichen muesste.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import Flask, url_for
|
from flask import Flask, url_for
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup, escape
|
||||||
|
|
||||||
ICONS: dict[str, str] = {
|
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"/>',
|
"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
|
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:
|
def init_app(app: Flask) -> None:
|
||||||
app.jinja_env.globals.update(
|
app.jinja_env.globals.update(
|
||||||
icon=icon,
|
icon=icon,
|
||||||
@@ -293,6 +377,7 @@ def init_app(app: Flask) -> None:
|
|||||||
from_json=from_json,
|
from_json=from_json,
|
||||||
issuer_source=issuer_source,
|
issuer_source=issuer_source,
|
||||||
issuer_source_hint=issuer_source_hint,
|
issuer_source_hint=issuer_source_hint,
|
||||||
|
audit_detail=audit_detail,
|
||||||
)
|
)
|
||||||
app.jinja_env.trim_blocks = True
|
app.jinja_env.trim_blocks = True
|
||||||
app.jinja_env.lstrip_blocks = True
|
app.jinja_env.lstrip_blocks = True
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from .. import audit
|
|||||||
from ..auth.models import current_user, permission_required
|
from ..auth.models import current_user, permission_required
|
||||||
from ..auth.service import client_ip
|
from ..auth.service import client_ip
|
||||||
from ..extension import core, db, transaction
|
from ..extension import core, db, transaction
|
||||||
|
from ..security.sessions import fragment
|
||||||
|
|
||||||
bp = Blueprint("logs", __name__, url_prefix="/protokolle")
|
bp = Blueprint("logs", __name__, url_prefix="/protokolle")
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ def live():
|
|||||||
|
|
||||||
@bp.get("/laufend/inhalt")
|
@bp.get("/laufend/inhalt")
|
||||||
@permission_required("logs_live.view")
|
@permission_required("logs_live.view")
|
||||||
|
@fragment
|
||||||
def live_fragment():
|
def live_fragment():
|
||||||
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
|
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
|
||||||
needle = (request.args.get("q") or "").strip()
|
needle = (request.args.get("q") or "").strip()
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing-server"
|
name = "tesm-licensing-server"
|
||||||
version = "2.0.2"
|
version = "2.0.5"
|
||||||
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
|
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["tesm-licensing==2.0.2", "cryptography>=41"]
|
dependencies = ["tesm-licensing==2.0.5", "cryptography>=41"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing"
|
name = "tesm-licensing"
|
||||||
version = "2.0.2"
|
version = "2.0.5"
|
||||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["cryptography>=42"]
|
dependencies = ["cryptography>=42"]
|
||||||
|
|||||||
Reference in New Issue
Block a user