Files
tesm-license/deploy/tesm-motd
T
alientimandClaude Opus 5 d31173fa5e Banner: Einzahl, wo eines gemeint ist
"1 Clients online" liest man jeden Tag einmal zu oft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:48:35 +02:00

189 lines
6.9 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 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)