* Die Wortmarke stand nur bei TESM -- sie hing an der Funktionszeile, und die hatte der Lizenzserver nicht. Jetzt zeigen beide die Marke; die Zeile darunter ist optional und beim Lizenzserver "LIZENZIEREN". * Der Kartenkopf ist weg: eine Kachel mit zwei Buchstaben, die Ueberschrift "Anmelden" und der Anwendungsname darunter sagten dasselbe wie die Marke darueber und der Knopf darunter. Die Ueberschrift bleibt fuer die Titelzeile des Browsers. * Marke und Karte stehen jetzt uebereinander im Fluss statt uebereinander gestapelt: auf einem flachen Fenster schnitt die Karte mitten durch den Schriftzug. * Die Marke ist kuerzer und symmetrisch um die Mitte aufgebaut -- Schriftzug ueber text-anchor, Zacken spiegelbildlich, Linie links so lang wie der Punkt rechts. Volle Deckkraft, weil sie kein Wasserzeichen hinter der Karte mehr ist. * Funktionszeile gekuerzt auf "UEBERWACHEN . BOOTEN . ANBINDEN" -- mit echtem Umlaut. "(PXE)" stand fuer etwas, das es noch nicht gibt. * Der Farbverlauf im Hintergrund hat wieder Geometrie und Staerke der Fassung 1.x. Der zweite Kreis lag auf --info und 10 %: ein kaltes Blau, das man auf dunklem Grund nicht sah und das nicht zur Marke gehoert. * "Bitte melden Sie sich an." stand auf einer Seite aus Anmeldefeld, Passwortfeld und einem Knopf "Anmelden". Statt dessen erfaehrt jetzt der, dessen Sitzung gerade ablief, warum er wieder hier ist. Dabei drei Dinge gefunden, die lautlos nichts taten: * __version__ stand fest verdrahtet in drei __init__.py. Die Fusszeile zeigte vier Releases lang 2.0.2, waehrend die Anwendung 2.0.6 war. Die Nummer kommt jetzt aus den Metadaten des installierten Pakets; Quelle ist pyproject.toml. * "gap: var(--sp-7)" -- die Skala kennt kein --sp-7 (1 bis 6, dann 8, 10, 12, 16). Eine undefinierte Variable verwirft die ganze Deklaration ohne Meldung: der Abstand war "normal", und Marke und Karte stiessen auf den Pixel aneinander. * Ein Test dagegen fand sofort ein zweites: --surface-0 wird dreimal benutzt und nirgends definiert. Die Schrift auf der Kachel "kritisch" blieb dunkelgrau auf Rot, die Dateivorschau hatte keinen Grund, und das Feld "Recht nicht vorhanden" in der Rechtematrix war durchsichtig -- also so hell wie die Karte, obwohl es dunkler sein sollte. Version 2.0.7. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
186 lines
6.7 KiB
Python
186 lines
6.7 KiB
Python
#!/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 jeweiligen Anwendung
|
|
#: (``AUTH_TAGLINE`` in deren ``__init__.py``) -- ein Hygienetest haelt beides
|
|
#: zusammen.
|
|
TAGLINE = {
|
|
"tesm": "ÜBERWACHEN · BOOTEN · ANBINDEN",
|
|
"tesm-license": "LIZENZIEREN",
|
|
}
|
|
|
|
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)
|