Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f481a91773 | ||
|
|
970f45cdf7 | ||
|
|
c9ceb871b6 | ||
|
|
37c508297f |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm"
|
||||
version = "2.8.34"
|
||||
version = "2.8.38"
|
||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -249,6 +249,39 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# 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. #}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('terminal', size=17) }} Abfrage durch ein RMM</div>
|
||||
<div class="card__sub">
|
||||
Ein Agent auf dieser Maschine liest die Restlaufzeit ohne Anmeldung und ohne Netz.
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-copy="#rmm-json"
|
||||
data-copy-message="Befehl kopiert.">{{ icon('copy', size=14) }} Kopieren</button>
|
||||
</div>
|
||||
<div class="card__body stack stack--tight">
|
||||
<pre class="code-block code-block--wrap" id="rmm-json">{{ rmm_befehl }}</pre>
|
||||
<p class="fs-xs faint">
|
||||
Antwortet mit einer Zeile JSON auf <strong>stdout</strong>
|
||||
(<code>valid</code>, <code>days_left</code>, <code>expires_at</code>,
|
||||
<code>lifetime</code>, <code>modules</code>). Der <strong>Ausstiegscode</strong> trägt
|
||||
die Aussage: <strong>0</strong> in Ordnung, <strong>1</strong> läuft in
|
||||
{{ rmm_warnschwelle }} Tagen oder weniger ab, <strong>2</strong> ungültig oder nicht
|
||||
vorhanden. Meldungen des Starts gehen nach stderr und stören das Auswerten nicht.
|
||||
</p>
|
||||
<div class="divider"></div>
|
||||
<div class="fs-xs faint">Nur die Restlaufzeit, ohne JSON-Auswertung:</div>
|
||||
<pre class="code-block code-block--wrap" id="rmm-tage">{{ rmm_befehl_tage }}</pre>
|
||||
<div class="divider"></div>
|
||||
<div class="fs-xs faint">Beispiel für einen Agenten mit PowerShell:</div>
|
||||
<pre class="code-block code-block--wrap">{{ rmm_powershell }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
|
||||
@@ -26,6 +26,23 @@
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
|
||||
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
|
||||
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
|
||||
# nicht erzeugt, warnt **jede neu gestartete Shell**:
|
||||
#
|
||||
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
|
||||
#
|
||||
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
|
||||
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
|
||||
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
|
||||
# klingt, liest sich schlecht.
|
||||
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
|
||||
export LC_ALL=C.UTF-8 LANG=C.UTF-8
|
||||
else
|
||||
export LC_ALL=C LANG=C
|
||||
fi
|
||||
|
||||
APP=""
|
||||
INSTANCE=""
|
||||
#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die
|
||||
|
||||
@@ -28,6 +28,18 @@
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# **Der venv muss fuer den Dienstbenutzer lesbar sein.**
|
||||
#
|
||||
# Dieses Programm laeuft als ExecStartPre der Dienst-Unit -- und erbt damit
|
||||
# deren UMask=0027. Alles, was es anlegt, gehoerte root und waere 0750: der
|
||||
# Dienst selbst laeuft unprivilegiert und koennte sein eigenes site-packages
|
||||
# nicht einmal betreten. Gemessen: nach der Reparatur ging "import gunicorn"
|
||||
# als root, als Benutzer tesm nicht -- die Reparatur baute etwas, das genau
|
||||
# der Prozess nicht benutzen konnte, fuer den sie gedacht war.
|
||||
#
|
||||
# Im venv liegt nichts Geheimes; Schluessel und Datenbank stehen woanders.
|
||||
umask 022
|
||||
|
||||
ROOT="${1:-}"
|
||||
APP="${2:-}"
|
||||
MODUS="reparieren"
|
||||
@@ -130,6 +142,11 @@ if ! bestuecken; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Und noch einmal ausdruecklich, fuer den Fall, dass ein frueherer Lauf mit
|
||||
# strengerer Maske gebaut hat: was hier liegt, muss lesbar sein. Idempotent
|
||||
# und in einer Sekunde durch.
|
||||
chmod -R a+rX "$VENV" 2>/dev/null || true
|
||||
|
||||
[[ "$MODUS" == "immer" ]] || melden "INFO" "Python-Umgebung von $NAME ist wieder vollstaendig."
|
||||
rm -f "$MERKER" 2>/dev/null || true
|
||||
exit 0
|
||||
@@ -29,6 +29,23 @@
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
|
||||
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
|
||||
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
|
||||
# nicht erzeugt, warnt **jede neu gestartete Shell**:
|
||||
#
|
||||
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
|
||||
#
|
||||
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
|
||||
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
|
||||
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
|
||||
# klingt, liest sich schlecht.
|
||||
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
|
||||
export LC_ALL=C.UTF-8 LANG=C.UTF-8
|
||||
else
|
||||
export LC_ALL=C LANG=C
|
||||
fi
|
||||
|
||||
APP=""
|
||||
INSTANCE=""
|
||||
PURGE=0
|
||||
|
||||
@@ -18,6 +18,23 @@
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
|
||||
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
|
||||
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
|
||||
# nicht erzeugt, warnt **jede neu gestartete Shell**:
|
||||
#
|
||||
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
|
||||
#
|
||||
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
|
||||
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
|
||||
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
|
||||
# klingt, liest sich schlecht.
|
||||
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
|
||||
export LC_ALL=C.UTF-8 LANG=C.UTF-8
|
||||
else
|
||||
export LC_ALL=C LANG=C
|
||||
fi
|
||||
|
||||
APP="${1:-}"
|
||||
TAG="${2:-latest}"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.8.18"
|
||||
version = "2.8.20"
|
||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -162,6 +162,67 @@ 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,
|
||||
# Drei Angaben, weil zwei davon verschieden sein koennen und die
|
||||
# Verwechslung teuer ist: "modules" ist, was die **Lizenz** freigibt --
|
||||
# dieselbe Liste, die der Lizenzserver zeigt. Was davon vor Ort
|
||||
# abgeschaltet wurde, steht daneben.
|
||||
#
|
||||
# Vorher stand hier allein das Laufende. Beim Kunden fehlte darin
|
||||
# "dhcp", waehrend die Lizenzseite es auswies -- es war lediglich unter
|
||||
# Verwaltung -> System abgeschaltet. Aus der Antwort ging das nicht
|
||||
# hervor, und damit sah es nach einem Lizenzproblem aus.
|
||||
"modules": sorted(state.modules),
|
||||
"modules_active": sorted(state.modules_in_use),
|
||||
"modules_off": sorted(state.modules_off),
|
||||
"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."""
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-licensing"
|
||||
version = "2.7.7"
|
||||
version = "2.7.8"
|
||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["cryptography>=42"]
|
||||
|
||||
@@ -46,6 +46,7 @@ from .license import (
|
||||
key_id,
|
||||
license_status,
|
||||
parse_iso,
|
||||
tage_bis,
|
||||
split_bundle,
|
||||
utcnow,
|
||||
verify_license,
|
||||
@@ -103,6 +104,7 @@ __all__ = [
|
||||
"machine_identity",
|
||||
"new_nonce",
|
||||
"parse_iso",
|
||||
"tage_bis",
|
||||
"sign",
|
||||
"split_bundle",
|
||||
"system_fingerprint",
|
||||
|
||||
@@ -221,6 +221,27 @@ def verify_license(
|
||||
)
|
||||
|
||||
|
||||
def tage_bis(expires_at: datetime, *, now: datetime | None = None) -> int:
|
||||
"""Restlaufzeit in **angefangenen** Tagen.
|
||||
|
||||
Aufgerundet, und das ist eine Entscheidung: eine Lizenz, die in zwei
|
||||
Stunden ablaeuft, hat "1 Tag" und nicht "0". Null bedeutet hier
|
||||
ausschliesslich *abgelaufen* -- eine gueltige Lizenz, die mit "0 Tage"
|
||||
dasteht, liest sich wie eine tote.
|
||||
|
||||
**Es gibt genau diese eine Rechnung.** Der Lizenzserver hatte eine zweite,
|
||||
abgerundete: dieselbe Lizenz zeigte in seiner Oberflaeche "21 Tage" und im
|
||||
Abruf beim Kunden "22". Zwei Zahlen fuer dieselbe Frage sind schlimmer als
|
||||
eine ungenaue -- sie kosten jemanden einen Nachmittag mit der Suche nach
|
||||
einem Fehler, den es nicht gibt.
|
||||
"""
|
||||
moment = now or utcnow()
|
||||
sekunden = int((expires_at - moment).total_seconds())
|
||||
if sekunden <= 0:
|
||||
return 0
|
||||
return -(-sekunden // 86400)
|
||||
|
||||
|
||||
def license_status(
|
||||
document: Mapping[str, Any],
|
||||
*,
|
||||
@@ -278,7 +299,7 @@ def license_status(
|
||||
days_left = 0
|
||||
days_since_expiry = int((moment - expires_at).total_seconds() // 86400)
|
||||
else:
|
||||
days_left = -(-int(delta.total_seconds()) // 86400)
|
||||
days_left = tage_bis(expires_at, now=moment)
|
||||
days_since_expiry = None
|
||||
|
||||
grace_active = bool(
|
||||
|
||||
Reference in new issue
Block a user