Files
tesm-license/srv/tesm-license/licensing.py
T
alientimandClaude Sonnet 5 c130c85b17 Sub-Lizenzserver melden ihren Kunden-/Ticket-/Lizenzstand an den Master
Ein Sub-Lizenzserver (role='license_server') meldet jetzt seinen
kompletten eigenen Kunden-/Ticket-/Lizenzstand an den Master, der ihn
als Ueberblick auf einer neuen Kunden-Detailseite anzeigt: unter dem
Kunden, der die Lizenzserver-Lizenz haelt, erscheinen dessen eigene
Kunden mit ihren Tickets/Lizenzen -- genau wie gewuenscht.

Transport: huckepack auf dem bereits bestehenden 6-Stunden-Heartbeat
(licensing.py: neues optionales 'report'-Feld in
build_client_request(), signiert wie jedes andere Feld -- ein
Angreifer ohne den privaten Lizenzschluessel kann keinen gefaelschten
Bericht unterschieben). Zusaetzlich sofortiger Best-Effort-Report per
Hintergrund-Thread nach jeder lokalen Ticket-/Kunden-/Lizenz-Aenderung
(_report_to_master_async()), damit der Master nicht bis zu 6h auf
Aktualitaet warten muss. Funktioniert automatisch auch ueber den
Offline-Code-Weg (/licenses/manual-code), da beide Transportwege
denselben _process_heartbeat()-Code nutzen.

- Drei neue, rein informative Tabellen (reported_sub_customers/
  _tickets/_licenses), verankert an source_license_id (die Lizenz,
  die den Sub-Lizenzserver identifiziert). Ein Bericht ist immer ein
  VOLLER Schnappschuss -- bei jedem Empfang werden alle bisherigen
  Zeilen dieser source_license_id ersetzt (kein Diffing noetig, ein
  beim Sub-Lizenzserver geloeschtes Ticket verschwindet dadurch
  automatisch korrekt auch hier).
- _build_own_issuance_report(): baut den Schnappschuss auf der
  Sub-Lizenzserver-Seite.
- _ingest_sub_report(): uebernimmt ihn auf der Master-Seite, NUR wenn
  row['role']=='license_server' fuer die absendende Lizenz (Verteid-
  igung in der Tiefe -- row['role'] steht in der Master-DB fest, nicht
  im Client-Request, ein gewoehnlicher Kunde kann sich also nicht als
  Lizenzserver ausgeben).
- Neue Kunden-Detailseite (/customers/<id>, customer_detail.html):
  Stammdaten, eigene Tickets bei uns, und -- falls dieser Kunde eine
  aktive role='license_server'-Lizenz haelt -- die gemeldeten eigenen
  Kunden mit Tickets/Lizenzen (Ampelfarbe/Resttage ueber dieselbe
  _license_dict_for_display()-Funktion wie ueberall sonst). Kunden-
  liste verlinkt jetzt auf die Detailseite, zeigt 'Lizenzserver'-Badge.

Getestet: licensing.py isoliert (report signiert+verifiziert,
Manipulation bricht die Signatur, unveraendertes Verhalten ohne
report-Argument), _ingest_sub_report() isoliert (Erstbericht korrekt
uebernommen, Zweitbericht ersetzt korrekt inkl. verschwindender
Tickets, Isolation zwischen mehreren source_license_id sauber).
ZUSAETZLICH ein vollstaendiger Zwei-Prozess-Integrationstest ueber
echtes HTTP (zwei unabhaengige, native laufende Instanzen: 'Master' +
'Sub-Lizenzserver' auf unterschiedlichen Ports): Ticket mit
role=license_server angelegt, Lizenz ausgestellt, auf der zweiten
Instanz hochgeladen und ONLINE aktiviert, dort ein eigener Endkunde
samt Ticket/Lizenz angelegt, automatischer Report ausgeloest --
Master-Kundendetailseite zeigt den gemeldeten Endkunden mit korrektem
Ticket-Typ/Laufzeit/Status und aktuellem Heartbeat-Zeitstempel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:45:17 +02:00

364 lines
18 KiB
Python

"""
TESM Lizenzsystem — gemeinsame Kryptographie- und Protokoll-Bausteine für
Kundeninstanz (Client) UND Lizenzserver (Master). Beide Seiten importieren
exakt dieselbe Datei (auf dem Master 1:1 mitkopiert) — Signieren und
Verifizieren müssen bitidentisch funktionieren, jede Abweichung würde
Lizenzen der jeweils anderen Seite unlesbar machen.
Vollständiges Design: siehe C:\\Users\\tim\\.claude\\plans\\toasty-twirling-hickey.md
(Phase 0). Kurzfassung der Schlüsselhierarchie:
- Master-Signaturschlüssel (Ed25519): signiert Lizenzdateien selbst UND
jede Aktivierungs-/Deaktivierungs-/Heartbeat-Antwort. Privater Teil bleibt
ausschließlich auf dem Master, öffentlicher Teil steckt in jeder
ausgestellten Lizenzdatei (ermöglicht rein OFFLINE verifizierbare
Master-Antworten beim Kunden).
- Pro-Lizenz-Schlüsselpaar (Ed25519, einmalig je Lizenz erzeugt): der
private Teil reist in der Lizenzdatei zum Kunden und signiert dessen
Aktivierungs-/Deaktivierungs-/Heartbeat-*Anfragen*; der öffentliche Teil
bleibt beim Master in dessen Datenbank (ermöglicht dem Master, jede
Anfrage einer bestimmten Lizenz zweifelsfrei zuzuordnen, ohne die
ursprüngliche Aktivierung "live" gesehen haben zu müssen).
Ed25519 statt RSA: kleine Schlüssel/Signaturen (wichtig, da Aktivierungs-
Anfragen/-Antworten für den Offline-Fall als von Hand kopierbarer Code
dargestellt werden), fest vorgegebene, sichere Parameter (keine
Padding-/Hash-Wahl wie bei RSA-PSS nötig).
"""
import base64
import datetime
import hashlib
import json
import socket
import time
import uuid
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
LICENSE_TYPES = ("trial", "standard", "custom", "enterprise")
ALL_MODULES = ("dhcp", "fileshare", "maintenance")
# Rolle der lizenzierten Instanz, eingebettet in JEDE ausgestellte
# Lizenzdatei (signiert, siehe issue_license) -- NICHT nur DB-seitige
# Buchfuehrung wie das gleichnamige, bislang ungenutzte licenses.role-Feld
# in der Master-DB vorher. Ermoeglicht einer lizenzierten Instanz, anhand
# ihrer EIGENEN, geladenen Lizenz strukturell zu erkennen, was sie darf:
# "master" -- die Wurzel-Instanz. Wird AUSSCHLIESSLICH lokal per
# create_master_license.py erzeugt, NIE ueber die
# normale Ticket-/Ausstellungs-UI -- das waere sonst
# eine Moeglichkeit, sich selbst zur Wurzel zu
# erklaeren. Einzige Rolle, die weitere
# "license_server"-Lizenzen ausstellen darf.
# "license_server" -- ein vom Master (oder einer anderen "master"-Instanz)
# ausgestellter Lizenzserver ("Sub-Lizenzserver").
# Darf alles, was ein Lizenzserver koennen soll
# (Kunden/Tickets/Lizenzen fuer eigene TESM-Kunden
# ausstellen, Benutzer/Gruppen/Einstellungen
# verwalten) -- AUSSER selbst weitere
# "license_server"-Lizenzen ausstellen. Diese Sperre
# ist strukturell (haengt an der eigenen Rolle, nicht
# an einem vergebbaren Recht) und daher auch von einem
# lokalen Admin dieser Instanz nicht aufhebbar.
# "customer" -- eine gewoehnliche TESM-Kundeninstanz (Default,
# unveraendertes Verhalten).
LICENSE_ROLES = ("master", "license_server", "customer")
GRACE_PERIOD_DAYS = 30 # nach Ablauf, bevor lizenzpflichtige Funktionen tatsächlich abgeschaltet werden
EXPIRY_WARNING_DAYS = 30 # Vorlauf für die orange "läuft bald ab"-Anzeige
HEARTBEAT_WARNING_DAYS = 14 # Nichterreichbarkeits-Hinweis (rein informativ, schaltet nie etwas ab)
# ============================================================== Schlüssel ==
def generate_keypair():
"""Erzeugt ein neues Ed25519-Schlüsselpaar. Rückgabe: (private_b64, public_b64)."""
priv = Ed25519PrivateKey.generate()
return _encode_private_key(priv), _encode_public_key(priv.public_key())
def _encode_private_key(priv: Ed25519PrivateKey) -> str:
return base64.urlsafe_b64encode(priv.private_bytes_raw()).decode("ascii")
def _encode_public_key(pub: Ed25519PublicKey) -> str:
return base64.urlsafe_b64encode(pub.public_bytes_raw()).decode("ascii")
def _decode_private_key(s: str) -> Ed25519PrivateKey:
return Ed25519PrivateKey.from_private_bytes(base64.urlsafe_b64decode(s.encode("ascii")))
def _decode_public_key(s: str) -> Ed25519PublicKey:
return Ed25519PublicKey.from_public_bytes(base64.urlsafe_b64decode(s.encode("ascii")))
# ===================================================== Signieren/Prüfen ==
def _canonical_bytes(payload: dict) -> bytes:
"""Deterministische JSON-Kodierung (sortierte Keys, kompakte Trenner) --
Voraussetzung dafür, dass Signieren und Verifizieren exakt dieselben
Bytes sehen, unabhängig von der Dict-Einfügereihenfolge."""
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
def sign_payload(payload: dict, private_key_b64: str) -> str:
priv = _decode_private_key(private_key_b64)
return base64.urlsafe_b64encode(priv.sign(_canonical_bytes(payload))).decode("ascii")
def verify_payload(payload: dict, signature_b64: str, public_key_b64: str) -> bool:
"""Gibt bewusst nur True/False zurück (nie eine Exception nach außen) --
ein Aufrufer soll "ungültig" nie mit einem Absturz verwechseln können."""
try:
pub = _decode_public_key(public_key_b64)
pub.verify(base64.urlsafe_b64decode(signature_b64.encode("ascii")), _canonical_bytes(payload))
return True
except (InvalidSignature, ValueError, TypeError, KeyError):
return False
# ============================================================ Zeitformat ==
def _iso(ts: float) -> str:
return datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _parse_iso(s: str) -> float:
return datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp()
# Sentinel-Ablaufdatum fuer "Lifetime"-Lizenzen. Bewusst ein fixes, weit in
# der Zukunft liegendes Datum statt eines Sonderwerts wie None/0 fuer
# expires_at -- dadurch bleibt JEDE bestehende Datumsvergleichs-/
# Differenzrechnung (hier, im Master-Dashboard, UND im TESM-Client) ohne
# jede Sonderbehandlung korrekt: "in > 90 Tagen faellig" o.ae. ist fuer ein
# Datum im Jahr 2099 schlicht immer wahr. Ob eine Lizenz als "Lifetime"
# geflaggt ist, wird zusaetzlich explizit in der Master-DB gespeichert
# (licenses.lifetime) -- dieses Sentinel-Datum ist nur die tatsaechlich in
# die signierte Lizenzdatei eingebettete, fuer den Client sichtbare Reprae-
# sentation davon.
LIFETIME_EXPIRES_AT = "2099-12-31T00:00:00Z"
# ================================================================ Lizenz ==
def issue_license(*, customer, license_type, modules, valid_days,
master_private_key_b64, master_public_key_b64,
master_endpoint, vendor, license_id=None, now=None, lifetime=False,
role="customer"):
"""Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt
frischem Pro-Lizenz-Schlüsselpaar.
lifetime=True ignoriert valid_days komplett und setzt stattdessen das
feste Sentinel-Datum LIFETIME_EXPIRES_AT -- siehe dortigen Kommentar.
role (siehe LICENSE_ROLES): "customer" (Default, unverändertes
Verhalten) für gewöhnliche TESM-Kundeninstanzen, "license_server" für
einen vom Aufrufer ausgestellten Sub-Lizenzserver. role="master" darf
NUR von create_master_license.py verwendet werden (lokal, ohne diese
Funktion über die normale Ticket-UI aufzurufen) -- diese Funktion prüft
das nicht selbst, das Verbot ist stattdessen dadurch durchgesetzt, dass
die App-seitige Ausstellungsfunktion role="master" gar nicht erst als
Option anbietet (siehe app.py can_issue_license_server_licenses()).
Rückgabe: (license_file, license_pubkey) -- license_file ist die
komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN
Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank
bestimmt (Verifikation künftiger Aktivierungs-/Heartbeat-Anfragen
dieser Lizenz) und wird nicht an den Kunden weitergegeben."""
if license_type not in LICENSE_TYPES:
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})")
if role not in LICENSE_ROLES:
raise ValueError(f"Unbekannte Rolle: {role!r} (erlaubt: {LICENSE_ROLES})")
unknown = set(modules) - set(ALL_MODULES)
if unknown:
raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})")
license_priv, license_pub = generate_keypair()
now = now if now is not None else time.time()
payload = {
"license_id": license_id or str(uuid.uuid4()),
"customer": customer,
"type": license_type,
"modules": sorted(modules),
"issued_at": _iso(now),
"expires_at": LIFETIME_EXPIRES_AT if lifetime else _iso(now + valid_days * 86400),
"license_pubkey": license_pub,
"master_pubkey": master_public_key_b64,
"master_endpoint": master_endpoint,
"vendor": vendor,
"role": role,
}
signature = sign_payload(payload, master_private_key_b64)
license_file = {**payload, "license_privkey": license_priv, "signature": signature}
return license_file, license_pub
def verify_license_file(license_file: dict) -> bool:
"""Prüft die Master-Signatur über die Lizenz-Nutzdaten. license_privkey
und signature selbst sind nicht Teil der signierten Nutzlast (die
Signatur wurde ja gerade über den Rest gebildet, siehe issue_license)."""
payload = {k: v for k, v in license_file.items() if k not in ("license_privkey", "signature")}
return verify_payload(payload, license_file.get("signature", ""), license_file.get("master_pubkey", ""))
def license_status(license_file: dict, now=None) -> dict:
"""Berechnet den aktuellen Anzeige-/Gate-Status einer (bereits als
signaturgültig geprüften!) Lizenz -- ruft NICHT selbst verify_license_file
auf, das bleibt bewusst Sache des Aufrufers, damit hier niemand versehentlich
den Status einer manipulierten Datei berechnet, ohne die Prüfung
überhaupt gemacht zu haben."""
now = now if now is not None else time.time()
expires_at = _parse_iso(license_file["expires_at"])
days_left = (expires_at - now) / 86400
expired = days_left < 0
days_since_expiry = -days_left if expired else 0.0
grace_active = expired and days_since_expiry <= GRACE_PERIOD_DAYS
modules_active = (not expired) or grace_active
return {
"expired": expired,
"days_left": days_left,
"days_since_expiry": days_since_expiry,
"expiring_soon": (not expired) and days_left <= EXPIRY_WARNING_DAYS,
"grace_active": grace_active,
"modules_active": modules_active,
"modules": set(license_file.get("modules", [])) if modules_active else set(),
"type": license_file.get("type"),
"customer": license_file.get("customer"),
"expires_at": license_file.get("expires_at"),
# .get(..., "customer"): aeltere, vor Einfuehrung von role signierte
# Lizenzdateien haben dieses Feld noch nicht -- fehlend bedeutet
# unveraendert "gewoehnliche Kundeninstanz", nie "master"/
# "license_server" (sonst koennte eine alte Datei rueckwirkend
# unbeabsichtigt privilegiert erscheinen).
"role": license_file.get("role", "customer"),
}
def heartbeat_stale_warning(last_heartbeat_ts, now=None) -> bool:
"""True, wenn der letzte erfolgreiche Heartbeat HEARTBEAT_WARNING_DAYS
oder länger zurückliegt. Rein informativ (siehe Moduldocstring) -- ein
fehlender/alter Heartbeat schaltet nie ein Modul ab, nur das
eingebettete Ablaufdatum selbst zählt dafür (siehe license_status)."""
if last_heartbeat_ts is None:
return False # noch nie verbunden gewesen ist der normale Ausgangszustand, kein Ausfall
now = now if now is not None else time.time()
return (now - last_heartbeat_ts) / 86400 >= HEARTBEAT_WARNING_DAYS
# ========================================== Fingerprint (System-Bindung) ==
def system_fingerprint():
"""Stabiler Identifikator dieses Hosts: /etc/machine-id (von systemd bei
der Erstinstallation einmalig erzeugt, übersteht Reboots und normale
Updates) kombiniert mit dem Hostnamen, gehasht damit die rohe
machine-id nie im Klartext übertragen/gespeichert wird. Unter
Windows/ohne /etc/machine-id fällt dies auf den Hostnamen allein
zurück (nur für lokale Tests relevant, Produktivsysteme sind Linux)."""
machine_id = ""
try:
with open("/etc/machine-id", "r", encoding="utf-8") as f:
machine_id = f.read().strip()
except OSError:
pass
raw = f"{machine_id}:{socket.gethostname()}".encode("utf-8")
return hashlib.sha256(raw).hexdigest()
# ============================ Aktivierung / Deaktivierung / Heartbeat =====
# Ein Protokoll, zwei Transportwege: online automatisch per HTTPS-API,
# offline exakt dasselbe Anfrage/Antwort-Paar als von Hand kopierbarer Code
# (encode_code/decode_code) -- der Master ist dadurch in BEIDEN Fällen die
# einzige Quelle der Wahrheit dafür, an welches System eine Lizenz gerade
# gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam
# verhindert".
def build_client_request(action, license_file, nonce=None, report=None) -> dict:
"""Vom CLIENT aufgerufen: erzeugt eine signierte Anfrage (action:
"activate"/"deactivate"/"heartbeat"). Das Ergebnis ist unverändert für
einen Online-API-Aufruf nutzbar; für den Offline-Fall wird es
zusätzlich mit encode_code() in einen kopierbaren Code umgewandelt.
report: NUR für Heartbeats einer role="license_server"-Lizenz gedacht
(siehe Lizenzserver-app.py _build_own_issuance_report()) -- ein
optionaler Schnappschuss der eigenen Kunden/Tickets/Lizenzen dieses
Sub-Lizenzservers, den der Master beim Empfang in seine
reported_sub_*-Tabellen übernimmt, um einen Gesamtüberblick zu haben.
Bewusst Teil DIESER generischen, gemeinsam genutzten Funktion (nicht
ein eigenes Protokoll-Feature) -- ein normaler TESM-Client/eine
"customer"-Lizenz übergibt hier einfach nie einen Wert, das Feld fehlt
dann in der Payload wie bisher (keine Verhaltensänderung für alle
bestehenden Aufrufer). Wie jedes andere Feld wird auch report
mitsigniert -- ein Angreifer ohne den privaten Lizenzschlüssel kann
keine gefälschten Berichte unterschieben."""
if action not in ("activate", "deactivate", "heartbeat"):
raise ValueError(f"Unbekannte Aktion: {action!r}")
payload = {
"license_id": license_file["license_id"],
"fingerprint": system_fingerprint(),
# Rein informativ fuer die Dashboard-Kacheln des Masters (siehe
# dortiges index()) -- fliesst NICHT in irgendeine Sicherheits-
# entscheidung ein (die bleibt allein Sache des Fingerprints), wird
# aber wie alle anderen Felder mitsigniert, damit ein Angreifer
# ohne den privaten Lizenzschluessel keinen falschen Hostnamen
# unterschieben kann.
"hostname": socket.gethostname(),
"action": action,
"nonce": nonce or uuid.uuid4().hex,
"timestamp": _iso(time.time()),
}
if report is not None:
payload["report"] = report
signature = sign_payload(payload, license_file["license_privkey"])
return {**payload, "signature": signature}
def verify_client_request(request: dict, license_public_key_b64: str) -> bool:
"""Vom MASTER aufgerufen: prüft eine Client-Anfrage gegen den bei
Ausstellung dieser Lizenz in der Master-DB gespeicherten Public-Key."""
payload = {k: v for k, v in request.items() if k != "signature"}
return verify_payload(payload, request.get("signature", ""), license_public_key_b64)
def build_master_response(action, license_id, fingerprint, master_private_key_b64,
status="ok", license_update=None, now=None) -> dict:
"""Vom MASTER aufgerufen: erzeugt eine signierte Antwort/Bestätigung
(z.B. "activated", "deactivated", oder das Ergebnis eines Heartbeats).
license_update: optional eine komplette, neu signierte Lizenzdatei
(z.B. nach nachträglicher Modul-Änderung durch den Admin) -- der Client
übernimmt sie nur nach eigener, erfolgreicher Signaturprüfung."""
payload = {
"license_id": license_id,
"fingerprint": fingerprint,
"action": action,
"status": status,
"timestamp": _iso(now if now is not None else time.time()),
}
if license_update is not None:
payload["license_update"] = license_update
signature = sign_payload(payload, master_private_key_b64)
return {**payload, "signature": signature}
def verify_master_response(response: dict, master_public_key_b64: str) -> bool:
"""Vom CLIENT aufgerufen: prüft eine Master-Antwort komplett OFFLINE
gegen den in der eigenen Lizenzdatei eingebetteten master_pubkey."""
payload = {k: v for k, v in response.items() if k != "signature"}
return verify_payload(payload, response.get("signature", ""), master_public_key_b64)
# ==================================================== Codes für Offline ==
def encode_code(data: dict) -> str:
"""Kodiert ein Anfrage-/Antwort-dict als kompakten, per Copy-Paste
übertragbaren Code (Base64 einer kanonischen JSON-Darstellung, keine
Zeilenumbrüche/Sonderzeichen, damit Kopieren aus/in ein Textfeld nichts
kaputt macht)."""
return base64.urlsafe_b64encode(_canonical_bytes(data)).decode("ascii")
def decode_code(code: str) -> dict:
return json.loads(base64.urlsafe_b64decode(code.strip().encode("ascii")).decode("utf-8"))