From 05885bfff1c1c1e8505bccc86cb63594f86c1e5b Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Thu, 3 Sep 2026 12:44:24 +0200 Subject: [PATCH] Anmeldebanner wieder in Betrieb -- ohne die Anwendung zu starten Das Banner der Vorgaengerfassung hing an /srv/tesm/motd_banner.py und importierte dafuer die ganze Flask-Anwendung samt deren venv. Nach dem Umstieg zeigte es damit die Zahlen einer Datenbank, die niemand mehr fuellt -- eingefroren, aber unverdaechtig. Aufgefallen ist es erst, als der alte Ueberwachungsdienst abgeschaltet wurde. deploy/tesm-motd loest das anders: **nur Standardbibliothek**, kein venv, kein Import der Anwendung, die Datenbank wird nur lesend geoeffnet (mode=ro). Ein Lauf dauert Millisekunden statt eines Flask-Starts. install.sh legt das Skript nach /usr/local/lib/tesm/ und schreibt /etc/update-motd.d/50-, abgesichert mit "timeout 3" und "|| true" -- ein Banner darf eine Anmeldung nie blockieren. Ein Banner der Vorgaengerfassung wird entfernt, aber nur, wenn es genau auf motd_banner.py zeigt. Damit es nicht zwei Wahrheiten ueber "online" gibt, steht die Zaehlung der Statusuebersicht jetzt als Konstante DASHBOARD_COUNT_SQL in monitor.py; das Banner traegt sie wortgleich, und ein Hygienetest vergleicht beide. Dasselbe fuer die Zeile unter der Wortmarke. Ein weiterer Test verbietet dem Banner jeden Import ausserhalb der Standardbibliothek -- genau dieser Import war die Ursache. Der Lizenzserver bekommt sein eigenes Banner (Kunden und ausgestellte Lizenzen). Eine Tagline hat er bewusst nicht: die Funktionsliste von TESM waere dort falsch, und eine erfundene waere schlimmer als keine. Version 2.0.5. Co-Authored-By: Claude Opus 5 --- apps/tesm-license/pyproject.toml | 2 +- deploy/install.sh | 36 ++++ deploy/tesm-motd | 176 ++++++++++++++++++ docs/BETRIEB.md | 32 ++++ packages/tesm-core/pyproject.toml | 2 +- .../tesm-core/src/tesm_core/app_factory.py | 2 +- packages/tesm-licensing-server/pyproject.toml | 4 +- packages/tesm-licensing/pyproject.toml | 2 +- 8 files changed, 250 insertions(+), 6 deletions(-) create mode 100644 deploy/tesm-motd diff --git a/apps/tesm-license/pyproject.toml b/apps/tesm-license/pyproject.toml index 339fbde..b2ffa86 100644 --- a/apps/tesm-license/pyproject.toml +++ b/apps/tesm-license/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-license" -version = "2.0.4" +version = "2.0.5" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" requires-python = ">=3.11" dependencies = [ diff --git a/deploy/install.sh b/deploy/install.sh index 05dcc82..a2d718b 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -295,6 +295,42 @@ install -o root -g root -m 0440 "$TMP_SUDO" "/etc/sudoers.d/$APP" rm -f "$TMP_SUDO" 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 ------------------------------------------------------------------ step "Dateirechte" chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR" diff --git a/deploy/tesm-motd b/deploy/tesm-motd new file mode 100644 index 0000000..1092bbf --- /dev/null +++ b/deploy/tesm-motd @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Live-Banner fuer die SSH-Anmeldung (MOTD). + +Eingebunden ueber ``/etc/update-motd.d/50-``, 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 _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 = [f"{BOLD}{online}{RESET} 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(f"{BOLD}{restarting}{RESET} startet 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} 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 f"{BOLD}{kunden}{RESET} Kunden · {BOLD}{lizenzen}{RESET} 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) diff --git a/docs/BETRIEB.md b/docs/BETRIEB.md index c9ecf9b..05d143c 100644 --- a/docs/BETRIEB.md +++ b/docs/BETRIEB.md @@ -251,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-` 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 ```bash diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml index 3a13a1c..52e7a42 100644 --- a/packages/tesm-core/pyproject.toml +++ b/packages/tesm-core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-core" -version = "2.0.4" +version = "2.0.5" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = [ diff --git a/packages/tesm-core/src/tesm_core/app_factory.py b/packages/tesm-core/src/tesm_core/app_factory.py index 406ec22..878f960 100644 --- a/packages/tesm-core/src/tesm_core/app_factory.py +++ b/packages/tesm-core/src/tesm_core/app_factory.py @@ -134,7 +134,7 @@ def create_base_app( #: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die #: Funktionsliste von TESM waere dort schlicht falsch. auth_tagline: str = "", - app_version: str = "2.0.4", + app_version: str = "2.0.5", built_in_trusted_keys: tuple[str, ...] = (), enable_license: bool = True, template_folder: str | None = None, diff --git a/packages/tesm-licensing-server/pyproject.toml b/packages/tesm-licensing-server/pyproject.toml index 1efc73c..8c26f93 100644 --- a/packages/tesm-licensing-server/pyproject.toml +++ b/packages/tesm-licensing-server/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-licensing-server" -version = "2.0.4" +version = "2.0.5" description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten" requires-python = ">=3.11" -dependencies = ["tesm-licensing==2.0.4", "cryptography>=41"] +dependencies = ["tesm-licensing==2.0.5", "cryptography>=41"] [tool.setuptools.packages.find] where = ["src"] diff --git a/packages/tesm-licensing/pyproject.toml b/packages/tesm-licensing/pyproject.toml index dcf6252..1313b10 100644 --- a/packages/tesm-licensing/pyproject.toml +++ b/packages/tesm-licensing/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-licensing" -version = "2.0.4" +version = "2.0.5" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = ["cryptography>=42"]