From c9ceb871b6dbedb67ab8ed00ae1b6b5ab7789d0c Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Thu, 17 Sep 2026 08:49:24 +0200 Subject: [PATCH] Version 2.8.36: Lizenzabfrage fuer ein RMM auf der Maschine --- apps/tesm/pyproject.toml | 2 +- apps/tesm/src/tesm/blueprints/license.py | 27 +++++++++- apps/tesm/src/tesm/cli.py | 38 ++++++++++++++ .../tesm/src/tesm/templates/tesm/license.html | 33 ++++++++++++ packages/tesm-core/pyproject.toml | 2 +- .../src/tesm_core/licensing_client.py | 50 +++++++++++++++++++ 6 files changed, 149 insertions(+), 3 deletions(-) diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index 5114fe6..dd47af4 100644 --- a/apps/tesm/pyproject.toml +++ b/apps/tesm/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm" -version = "2.8.35" +version = "2.8.36" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/blueprints/license.py b/apps/tesm/src/tesm/blueprints/license.py index 2e845d2..2306e0d 100644 --- a/apps/tesm/src/tesm/blueprints/license.py +++ b/apps/tesm/src/tesm/blueprints/license.py @@ -19,7 +19,11 @@ from tesm_core.auth.models import ( from tesm_core.auth.service import client_ip from tesm_core.db import Database from tesm_core.extension import core, db, transaction -from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError +from tesm_core.licensing_client import ( + ABFRAGE_WARNSCHWELLE_TAGE, + LICENSED_FEATURES, + LicenseError, +) import tesm_licensing as lic @@ -50,8 +54,29 @@ def overview(): manager = extension.license state = manager.reload(conn) if manager else None pending = manager._pending(conn) if manager else {} # noqa: SLF001 - eigenes Paket + # Der Befehl, den ein Agent auf dieser Maschine ausfuehrt. Zusammengesetzt + # aus dem, was hier wirklich installiert ist -- der Shim traegt den Namen + # der Instanz, und der Dienstbenutzer auch. Abgeschrieben aus der Doku + # waere er auf einer benannten Instanz schlicht falsch. + werkzeug = f"/usr/local/bin/{extension.config.site_key}-admin" + # Der Dienstbenutzer traegt den Namen der **Anwendung**, nicht den der + # Instanz: install.sh setzt SERVICE_USER="$APP". Auf "tesm-test" heisst er + # also "tesm" -- und auf dem Lizenzserver "tesm-license". Aus dem + # Instanznamen ableiten waere genau dort falsch. + dienstbenutzer = extension.config.app_key + rmm_befehl = f"sudo -u {dienstbenutzer} {werkzeug} license" return render_template( "tesm/license.html", + rmm_befehl=rmm_befehl, + rmm_befehl_tage=f"{rmm_befehl} --days", + rmm_warnschwelle=ABFRAGE_WARNSCHWELLE_TAGE, + rmm_powershell=( + f'$j = (& {rmm_befehl} 2>$null) | ConvertFrom-Json' + chr(10) + + 'if (-not $j.valid) { exit 2 }' + chr(10) + + 'if ($j.lifetime) { exit 0 }' + chr(10) + + f'if ($j.days_left -le {ABFRAGE_WARNSCHWELLE_TAGE}) ' + '{ exit 1 }' + chr(10) + + 'exit 0' + ), state=state, pending=pending, server_certificate=extension.settings.get(conn, "license_server_ca") or "", diff --git a/apps/tesm/src/tesm/cli.py b/apps/tesm/src/tesm/cli.py index 489562d..ea1ff62 100644 --- a/apps/tesm/src/tesm/cli.py +++ b/apps/tesm/src/tesm/cli.py @@ -110,6 +110,36 @@ def cmd_create_admin(args: argparse.Namespace) -> int: return 0 +def cmd_license(args: argparse.Namespace) -> int: + """Die Lizenz als Auskunft fuer ein Ueberwachungswerkzeug. + + Gedacht fuer einen RMM-Agenten, der auf dieser Maschine laeuft: kein Netz, + kein Token, keine Anmeldung. Die Antwort steht auf **stdout**, alles andere + (Protokollzeilen des Starts) auf stderr -- ein ``| ConvertFrom-Json`` soll + nicht daran scheitern, dass eine Migration etwas erzaehlt hat. + + Der Ausstiegscode traegt die Aussage: 0 heil, 1 laeuft bald ab, 2 + ungueltig. Das ist die Sprache, in der ein RMM Alarm schlaegt -- Text + auszuwerten waere der Umweg. + """ + from tesm_core.licensing_client import abfrage_ausstiegscode, zustand_als_auskunft + + app, extension = _app_and_core() + if extension.license is None: + print("{}") + return 2 + with app.app_context(), extension.database.session() as conn: + auskunft = zustand_als_auskunft(extension.license.reload(conn)) + if getattr(args, "days", False): + # Eine Zahl, sonst nichts -- fuer Werkzeuge, die keinen JSON-Parser + # mitbringen. Unbefristet hat keine Zahl und sagt das auch. + tage = auskunft.get("days_left") + print("unbefristet" if auskunft.get("lifetime") else ("" if tage is None else int(tage))) + else: + print(json.dumps(auskunft, ensure_ascii=False, sort_keys=True)) + return abfrage_ausstiegscode(auskunft) + + def cmd_status(args: argparse.Namespace) -> int: app, extension = _app_and_core() with app.app_context(), extension.database.session() as conn: @@ -552,6 +582,14 @@ def build_parser() -> argparse.ArgumentParser: status.add_argument("--json", action="store_true") status.set_defaults(func=cmd_status) + lizenz = sub.add_parser( + "license", help="Lizenzzustand als JSON ausgeben (für Überwachungswerkzeuge)" + ) + lizenz.add_argument( + "--days", action="store_true", help="Nur die Restlaufzeit in Tagen ausgeben" + ) + lizenz.set_defaults(func=cmd_license) + house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufräumen") house.set_defaults(func=cmd_housekeeping) diff --git a/apps/tesm/src/tesm/templates/tesm/license.html b/apps/tesm/src/tesm/templates/tesm/license.html index 28bbcec..b8b2657 100644 --- a/apps/tesm/src/tesm/templates/tesm/license.html +++ b/apps/tesm/src/tesm/templates/tesm/license.html @@ -249,6 +249,39 @@ + {# Fuer ein Ueberwachungswerkzeug, das auf dieser Maschine laeuft. Bewusst + kein HTTP-Endpunkt mit Token: der Agent sitzt ohnehin hier, und was ohne + Netz auskommt, kann auch niemand aus dem Netz abfragen. #} +
+
+
+
{{ icon('terminal', size=17) }} Abfrage durch ein RMM
+
+ Ein Agent auf dieser Maschine liest die Restlaufzeit ohne Anmeldung und ohne Netz. +
+
+ +
+
+
{{ rmm_befehl }}
+

+ Antwortet mit einer Zeile JSON auf stdout + (valid, days_left, expires_at, + lifetime, modules). Der Ausstiegscode trägt + die Aussage: 0 in Ordnung, 1 läuft in + {{ rmm_warnschwelle }} Tagen oder weniger ab, 2 ungültig oder nicht + vorhanden. Meldungen des Starts gehen nach stderr und stören das Auswerten nicht. +

+
+
Nur die Restlaufzeit, ohne JSON-Auswertung:
+
{{ rmm_befehl_tage }}
+
+
Beispiel für einen Agenten mit PowerShell:
+
{{ rmm_powershell }}
+
+
+
diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml index a197cd0..c0bc4e3 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.8.18" +version = "2.8.19" 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/licensing_client.py b/packages/tesm-core/src/tesm_core/licensing_client.py index 9ef0505..c827b7f 100644 --- a/packages/tesm-core/src/tesm_core/licensing_client.py +++ b/packages/tesm-core/src/tesm_core/licensing_client.py @@ -162,6 +162,56 @@ class LicenseState: return dict(self.status.vendor) if self.status else {} +#: Ausstiegscodes der Abfrage -- danach schlaegt ein RMM Alarm, nicht nach Text. +ABFRAGE_OK = 0 +ABFRAGE_LAEUFT_AB = 1 +ABFRAGE_UNGUELTIG = 2 + +#: Ab hier gilt eine Lizenz als "laeuft bald ab". Dieselbe Zahl wie in der +#: Oberflaeche -- zwei Schwellen fuer dieselbe Frage waeren eine zu viel. +ABFRAGE_WARNSCHWELLE_TAGE = 30 + + +def zustand_als_auskunft(state: "LicenseState") -> dict[str, Any]: + """Der Lizenzzustand als schlichtes Woerterbuch -- fuer JSON und Skripte. + + Gedacht fuer ein RMM, das auf der Maschine selbst laeuft: kein Netz, kein + Token, keine Anmeldung. Was hier steht, beantwortet die eine Frage, die ein + Kunde wirklich hat -- wie lange laeuft die Lizenz noch --, und nichts + darueber hinaus: keine Schluessel, keine Kennungen anderer Installationen. + """ + status = state.status + return { + "valid": bool(state.valid), + "activated": bool(state.activated), + "type": str(status.type) if status else "", + "role": state.role, + "lifetime": bool(status.lifetime) if status else False, + "expires_at": lic.iso(status.expires_at) if status and status.expires_at else "", + # Bei einer unbefristeten Lizenz ist die Zahl bewusst ``null`` und + # keine grosse Zahl: "noch 99999 Tage" waere eine Behauptung. + "days_left": (status.days_left if status and not status.lifetime else None), + "expiring_soon": bool(status.expiring_soon) if status else False, + "grace_active": bool(status.grace_active) if status else False, + "modules": sorted(state.modules_in_use), + "customer": state.customer_name, + "last_heartbeat_at": state.last_heartbeat_at, + "error": state.error, + } + + +def abfrage_ausstiegscode(auskunft: dict[str, Any]) -> int: + """0 heil, 1 laeuft bald ab, 2 ungueltig -- die Sprache eines RMM.""" + if not auskunft.get("valid"): + return ABFRAGE_UNGUELTIG + if auskunft.get("lifetime"): + return ABFRAGE_OK + tage = auskunft.get("days_left") + if tage is None or int(tage) <= ABFRAGE_WARNSCHWELLE_TAGE: + return ABFRAGE_LAEUFT_AB + return ABFRAGE_OK + + class LicenseManager: """Haelt den Lizenzzustand und fuehrt die Handshakes durch."""