TESM-Lizenzserver 2.0.0 -- Neubau

Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert
im Zweig SONNET5 erhalten.

Aufbau: apps/tesm-license (Anwendung), packages/tesm-core (gemeinsamer Kern),
packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd,
privilegierter Helfer), docs, tests. Die verwaltete Anwendung liegt in ihrem
eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit,
damit sich jedes allein installieren laesst.

Die wichtigsten Unterschiede zum Vorgaenger:

* Keine doppelte licensing.py -- ein Paket, das beide Anwendungen
  installieren, statt zweier Dateien, die byte-identisch bleiben sollen.
* Der Webprozess laeuft unprivilegiert; alles, was Root braucht, geht ueber
  einen einzigen Helfer mit Positivlisten fuer jedes Argument.
* CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen.
* Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet.
* Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird
  abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann.
* Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als
  "Aktivierung offen" markiert, bis sie zurueckkommt.
* Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung,
  seq gegen das Zurueckrollen auf eine aeltere Lizenz.
* Kein Hostname im Maschinen-Fingerabdruck.
* Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 18:01:23 +02:00
co-authored by Claude Opus 5
parent c130c85b17
commit f7805a2180
211 changed files with 31099 additions and 14759 deletions
+493
View File
@@ -0,0 +1,493 @@
"""End-to-End: der komplette Lizenz-Lebenszyklus ueber beide Anwendungen hinweg.
Laeuft auf dem Zielhost gegen zwei echte, laufende Installationen:
Lizenzserver: Kunde -> Ticket -> Lizenz ausstellen -> Bundle herunterladen
TESM : Bundle einspielen -> aktivieren (online gegen den Lizenzserver)
-> Module freigeschaltet -> Heartbeat -> Revision -> Widerruf
Das ist der Pfad, der im Vorgaengerprojekt nur von Hand pruefbar war -- und
genau dort sassen die teuren Fehler. Geprueft wird deshalb nicht nur "es geht",
sondern auch: Doppelausstellung wird verhindert, Module sind vor der
Aktivierung wirklich gesperrt, eine neue Revision nimmt Module wieder weg, und
ein Widerruf kommt beim Client an.
Aufruf:
python3 e2e_license.py http://127.0.0.1:8081 http://127.0.0.1:8080 admin 'Passwort'
"""
from __future__ import annotations
import http.cookiejar
import json
import re
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
CSRF_RE = re.compile(r'name="csrf_token" value="([^"]+)"')
BODY_CSRF_RE = re.compile(r'data-csrf="([^"]+)"')
PASSED: list[str] = []
FAILED: list[str] = []
def check(name: str, condition: bool, detail: str = "") -> None:
if condition:
PASSED.append(name)
print(f" [ok] {name}")
else:
FAILED.append(f"{name}: {detail}")
print(f" [FEHL] {name} -- {detail}")
class Response:
def __init__(self, status: int, body: bytes, url: str, headers: dict[str, str]) -> None:
self.status = status
self.raw = body
self.url = url
self.headers = headers
@property
def text(self) -> str:
return self.raw.decode("utf-8", "replace")
class Client:
def __init__(self, base: str, label: str) -> None:
self.base = base.rstrip("/")
self.label = label
jar = http.cookiejar.CookieJar()
# Der Test laeuft auf dem Zielhost gegen dessen eigenes, selbst
# ausgestelltes Zertifikat. Geprueft wird, dass TLS steht und die
# richtige Anwendung antwortet -- nicht die Vertrauenskette.
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar),
urllib.request.HTTPSHandler(context=context),
)
self.opener.addheaders = [("User-Agent", "tesm-e2e-license/2.0")]
def _open(self, request: urllib.request.Request) -> Response:
try:
with self.opener.open(request, timeout=60) as response:
return Response(
response.status, response.read(), response.geturl(), dict(response.headers)
)
except urllib.error.HTTPError as exc:
return Response(exc.code, exc.read(), exc.url, dict(exc.headers))
def get(self, path: str) -> Response:
return self._open(urllib.request.Request(self.base + path))
def text(self, path: str) -> str:
return self.get(path).text
def post(self, path: str, data: dict[str, object]) -> Response:
pairs = [
(key, str(item))
for key, value in data.items()
for item in (value if isinstance(value, (list, tuple)) else [value])
]
body = urllib.parse.urlencode(pairs).encode("utf-8")
request = urllib.request.Request(
self.base + path,
data=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Origin": self.base,
"Referer": self.base + path,
},
)
return self._open(request)
def post_file(
self, path: str, field: str, filename: str, content: bytes, extra: dict[str, str]
) -> Response:
boundary = "----tesm-e2e-boundary-3f88"
parts: list[bytes] = []
for key, value in extra.items():
parts.append(
f'--{boundary}\r\nContent-Disposition: form-data; name="{key}"\r\n\r\n'
f"{value}\r\n".encode()
)
parts.append(
f'--{boundary}\r\nContent-Disposition: form-data; name="{field}"; '
f'filename="{filename}"\r\nContent-Type: application/json\r\n\r\n'.encode()
+ content
+ b"\r\n"
)
parts.append(f"--{boundary}--\r\n".encode())
request = urllib.request.Request(
self.base + path,
data=b"".join(parts),
headers={
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Origin": self.base,
"Referer": self.base + path,
},
)
return self._open(request)
def token(self, path: str) -> str:
body = self.text(path)
match = CSRF_RE.search(body) or BODY_CSRF_RE.search(body)
if not match:
raise AssertionError(f"{self.label}: kein CSRF-Token auf {path}")
return match.group(1)
def login(self, username: str, password: str) -> bool:
token = self.token("/login")
response = self.post(
"/login", {"username": username, "password": password, "csrf_token": token}
)
return response.status == 200 and "/login" not in response.url
def main(
server_base: str,
client_base: str,
username: str,
password: str,
endpoint: str = "",
) -> int:
# Der Endpunkt, den TESM spaeter anruft, muss nicht die Adresse sein, unter
# der ein Mensch den Lizenzserver bedient. Auf einem Host, der beide
# Anwendungen traegt, ist der direkte Weg ueber die Loopback-Adresse der
# ehrlichere -- er umgeht Namensaufloesung und Zertifikatspruefung, die
# zwischen zwei Diensten derselben Maschine nichts beitragen.
endpoint = (endpoint or server_base).rstrip("/")
server = Client(server_base, "Lizenzserver")
tesm = Client(client_base, "TESM")
print(f"\n== Lizenz-Lebenszyklus: {server_base} -> {client_base} ==\n")
print("Anmeldung:")
check("Lizenzserver", server.login(username, password), "fehlgeschlagen")
check("TESM", tesm.login(username, password), "fehlgeschlagen")
if FAILED:
print("\nOhne Anmeldung ist der Rest sinnlos -- Abbruch.")
return 1
# -- Anbieterdaten: der Endpunkt landet spaeter in jeder Lizenz -----------
print("\nAnbieterdaten und Endpunkt:")
token = server.token("/einstellungen/anbieter")
response = server.post(
"/einstellungen/anbieter",
{
"csrf_token": token,
"vendor_name": "WiS GmbH",
"vendor_email": "lizenzen@wis.invalid",
"vendor_phone": "+49 000 000000",
"vendor_address": "Musterweg 1, 12345 Musterstadt",
"master_endpoint": endpoint,
},
)
check(
"Anbieterdaten gespeichert",
"gespeichert" in response.text or "Keine Aenderungen" in response.text,
response.text[:150],
)
token = server.token("/einstellungen/anbieter")
response = server.post(
"/einstellungen/anbieter", {"csrf_token": token, "master_endpoint": "ftp://falsch"}
)
check("Ungueltiger Endpunkt abgelehnt", "muss mit http" in response.text, "keine Meldung")
# -- Kunde ---------------------------------------------------------------
print("\nKunde:")
token = server.token("/kunden/")
response = server.post(
"/kunden/neu",
{
"csrf_token": token,
"name": "Beispiel AG",
"contact_email": "it@beispiel.invalid",
"contact_person": "Frau Muster",
"street": "Hauptstrasse",
"house_number": "7",
"postal_code": "12345",
"city": "Musterstadt",
},
)
check("Kunde angelegt", "angelegt" in response.text, response.text[:150])
match = re.search(r"/kunden/(\d+)$", response.url)
customer_id = match.group(1) if match else ""
check("Kunden-Nummer erhalten", bool(customer_id), response.url)
# -- Ticket ---------------------------------------------------------------
print("\nTicket:")
token = server.token("/tickets/neu")
response = server.post(
"/tickets/neu",
{
"csrf_token": token,
"customer_id": customer_id,
"type": "enterprise",
"modules": ["dhcp", "fileshare", "maintenance"],
"valid_days": "365",
"role": "customer",
"notes": "E2E-Test",
},
)
check("Ticket angelegt", "angelegt" in response.text, response.text[:150])
match = re.search(r"/tickets/([^/]+)$", response.url)
ticket_id = match.group(1) if match else ""
check("Ticket-Kennung erhalten", bool(ticket_id), response.url)
token = server.token("/tickets/neu")
response = server.post(
"/tickets/neu",
{
"csrf_token": token,
"customer_id": customer_id,
"type": "trial",
"valid_days": "30",
"lifetime": "1",
"role": "customer",
},
)
check(
"Testlizenz kann nicht unbefristet sein",
"nicht unbefristet" in response.text,
"Regel greift nicht",
)
# -- Lizenz ausstellen ----------------------------------------------------
print("\nLizenz ausstellen:")
token = server.token(f"/tickets/{ticket_id}")
response = server.post(f"/lizenzen/aus-ticket/{ticket_id}", {"csrf_token": token})
check("Lizenz ausgestellt", "ausgestellt" in response.text, response.text[:200])
match = re.search(r"/lizenzen/([0-9a-f-]{36})$", response.url)
license_id = match.group(1) if match else ""
check("Lizenz-Kennung erhalten", bool(license_id), response.url)
token = server.token(f"/tickets/{ticket_id}")
response = server.post(f"/lizenzen/aus-ticket/{ticket_id}", {"csrf_token": token})
check(
"Doppelklick stellt keine zweite Lizenz aus",
"bereits eine Lizenz offen" in response.text,
"zweite Lizenz entstanden",
)
# -- Bundle herunterladen -------------------------------------------------
print("\nAuslieferung:")
response = server.get(f"/lizenzen/{license_id}/datei")
check("Bundle heruntergeladen", response.status == 200, f"HTTP {response.status}")
check(
"Als Anhang ausgeliefert",
"attachment" in response.headers.get("Content-Disposition", ""),
response.headers.get("Content-Disposition", ""),
)
check(
"Nicht zwischenspeicherbar",
"no-store" in response.headers.get("Cache-Control", ""),
response.headers.get("Cache-Control", ""),
)
bundle_bytes = response.raw
try:
bundle = json.loads(bundle_bytes.decode("utf-8"))
except ValueError:
check("Bundle ist gueltiges JSON", False, bundle_bytes[:120].decode("latin1"))
return 1
check("Bundle ist gueltiges JSON", True)
check(
"Dokument und privater Clientschluessel enthalten",
"license" in bundle and "client_key" in bundle,
str(sorted(bundle))[:100],
)
document = bundle.get("license", {})
check(
"Endpunkt eingebettet",
document.get("master_endpoint") == endpoint,
str(document.get("master_endpoint")),
)
check(
"Alle drei Module enthalten",
set(document.get("modules", [])) == {"dhcp", "fileshare", "maintenance"},
str(document.get("modules")),
)
check("Rolle ist customer", document.get("role") == "customer", str(document.get("role")))
body = server.text(f"/lizenzen/{license_id}")
check("Zustellprotokoll vermerkt den Download", "ownload" in body, "kein Eintrag")
# -- Einspielen in TESM ---------------------------------------------------
print("\nEinspielen in TESM:")
body = tesm.text("/lizenz/")
if "keine Lizenz installiert" in body:
check("Vorher keine Lizenz installiert", True)
else:
# Bei einem wiederholten Lauf liegt noch die Lizenz des Vorlaufs. Das ist
# kein Fehler -- das Einspielen ersetzt sie, und genau das wird gleich
# geprueft.
print(" [info] Es liegt bereits eine Lizenz -- wiederholter Lauf, wird ersetzt.")
token = tesm.token("/lizenz/")
response = tesm.post_file(
"/lizenz/einspielen", "license_file", "lizenz.json", bundle_bytes, {"csrf_token": token}
)
check("Lizenz eingespielt", "eingespielt" in response.text, response.text[:250])
check(
"Aussteller beim ersten Mal gepinnt",
"Aussteller gepinnt" in response.text,
"kein Pinning erkennbar",
)
check("Noch nicht aktiviert", "nicht aktiviert" in response.text, "Zustand unerwartet")
print("\nModule vor der Aktivierung:")
for path in ("/dhcp/", "/wartung/", "/dateifreigaben/"):
check(f"{path} gesperrt", tesm.get(path).status == 404, "erreichbar")
# -- Manipuliertes Bundle -------------------------------------------------
print("\nManipuliertes Bundle:")
tampered = json.loads(bundle_bytes.decode("utf-8"))
tampered["license"]["modules"] = ["dhcp", "fileshare", "maintenance", "erfunden"]
token = tesm.token("/lizenz/")
response = tesm.post_file(
"/lizenz/einspielen",
"license_file",
"boese.json",
json.dumps(tampered).encode("utf-8"),
{"csrf_token": token},
)
check(
"Veraenderte Lizenz wird abgewiesen",
"eingespielt" not in response.text,
"Manipulation akzeptiert!",
)
# -- Aktivierung ----------------------------------------------------------
print("\nAktivierung (online, echter HTTP-Aufruf an den Lizenzserver):")
token = tesm.token("/lizenz/")
response = tesm.post("/lizenz/aktivieren", {"csrf_token": token})
check("Aktivierung erfolgreich", "jetzt aktiv" in response.text, response.text[:300])
check("Zustand aktiviert", "nicht aktiviert" not in response.text, "Zustand unerwartet")
print("\nModule nach der Aktivierung:")
for path, needle in (
("/dhcp/", "DHCP"),
("/wartung/", "Wartung"),
("/dateifreigaben/", "Dateifreigaben"),
):
response = tesm.get(path)
check(
f"{path} freigeschaltet",
response.status == 200 and needle in response.text,
f"HTTP {response.status}",
)
print("\nSicht des Lizenzservers:")
body = server.text(f"/lizenzen/{license_id}")
check("Maschine gebunden", "noch nicht gebunden" not in body, "kein Fingerprint")
check("Aktivierungszeitpunkt vermerkt", "Aktiviert" in body, "fehlt")
# -- Heartbeat ------------------------------------------------------------
print("\nHeartbeat:")
token = tesm.token("/lizenz/")
response = tesm.post("/lizenz/heartbeat", {"csrf_token": token})
check("Heartbeat bestaetigt", "bestaetigt" in response.text, response.text[:250])
# -- Neue Revision: Modul entziehen ---------------------------------------
print("\nNeue Revision (Modul entziehen):")
token = server.token(f"/lizenzen/{license_id}")
response = server.post(
f"/lizenzen/{license_id}/neu-signieren",
{"csrf_token": token, "modules": ["dhcp"], "extend_days": ""},
)
check("Revision signiert", "Revision" in response.text, response.text[:250])
token = tesm.token("/lizenz/")
response = tesm.post("/lizenz/heartbeat", {"csrf_token": token})
check("Heartbeat holt die Revision", "bestaetigt" in response.text, response.text[:200])
check("Entzogenes Modul gesperrt", tesm.get("/wartung/").status == 404, "weiter offen")
check("Verbliebenes Modul offen", tesm.get("/dhcp/").status == 200, "gesperrt")
# -- Deaktivierung und erneute Aktivierung --------------------------------
print("\nDeaktivierung (Systemwechsel):")
token = tesm.token("/lizenz/")
response = tesm.post("/lizenz/deaktivieren", {"csrf_token": token})
check("Deaktivierung erfolgreich", "wurde deaktiviert" in response.text, response.text[:250])
check("Module wieder gesperrt", tesm.get("/dhcp/").status == 404, "weiter offen")
body = server.text(f"/lizenzen/{license_id}")
check("Server kennt die Deaktivierung", "deaktiviert" in body, "unerwartet")
print("\nErneute Aktivierung derselben Maschine:")
token = tesm.token("/lizenz/")
response = tesm.post("/lizenz/aktivieren", {"csrf_token": token})
check("Wieder aktiv", "jetzt aktiv" in response.text, response.text[:250])
check("Modul wieder offen", tesm.get("/dhcp/").status == 200, "gesperrt")
# -- Widerruf -------------------------------------------------------------
print("\nWiderruf durch den Anbieter:")
token = server.token(f"/lizenzen/{license_id}")
response = server.post(
f"/lizenzen/{license_id}/widerrufen", {"csrf_token": token, "reason": "E2E-Test"}
)
check("Widerruf gespeichert", "widerrufen" in response.text, response.text[:250])
token = tesm.token("/lizenz/")
response = tesm.post("/lizenz/heartbeat", {"csrf_token": token})
check("Client erfaehrt den Widerruf", "widerrufen" in response.text, response.text[:250])
check("Module nach Widerruf gesperrt", tesm.get("/dhcp/").status == 404, "weiter offen")
check("Lizenzseite bleibt erreichbar", tesm.get("/lizenz/").status == 200, "gesperrt")
# -- Aufraeumen -------------------------------------------------------
# Der Test endet mit einem Widerruf -- danach sind DHCP, Wartung und
# Dateifreigaben gesperrt. Eine Pruefung, die die Installation unbrauchbar
# zuruecklaesst, ist keine gute Pruefung: wer danach die Wartung sucht,
# findet einen 404 und haelt ihn fuer einen Fehler. Also wird zum Schluss
# eine frische Lizenz ausgestellt und aktiviert.
print("\nAufraeumen -- Installation wieder betriebsbereit machen:")
token = server.token(f"/tickets/{ticket_id}")
response = server.post(f"/lizenzen/aus-ticket/{ticket_id}", {"csrf_token": token})
check("Ersatzlizenz ausgestellt", "ausgestellt" in response.text, response.text[:200])
match = re.search(r"/lizenzen/([0-9a-f-]{36})$", response.url)
replacement_id = match.group(1) if match else ""
check("Kennung der Ersatzlizenz", bool(replacement_id), response.url)
response = server.get(f"/lizenzen/{replacement_id}/datei")
check("Ersatzbundle heruntergeladen", response.status == 200, f"HTTP {response.status}")
token = tesm.token("/lizenz/")
response = tesm.post_file(
"/lizenz/einspielen", "license_file", "lizenz.json", response.raw, {"csrf_token": token}
)
check("Ersatzlizenz eingespielt", "eingespielt" in response.text, response.text[:200])
token = tesm.token("/lizenz/")
response = tesm.post("/lizenz/aktivieren", {"csrf_token": token})
check("Ersatzlizenz aktiv", "jetzt aktiv" in response.text, response.text[:250])
for path in ("/dhcp/", "/wartung/", "/dateifreigaben/"):
check(f"{path} wieder erreichbar", tesm.get(path).status == 200, "weiter gesperrt")
# -- Protokolle -----------------------------------------------------------
print("\nProtokolle und Diagnose:")
body = server.text("/protokolle/aenderungen")
for action in ("license.issued", "license.downloaded", "license.resigned", "license.revoked"):
check(f"Eintrag {action}", action in body, "fehlt")
check(
"Diagnose Lizenzserver: Kette unversehrt",
"unversehrt" in server.text("/diagnose/"),
"gebrochen",
)
body = tesm.text("/protokolle/aenderungen")
for action in ("license.activate", "license.deactivate", "license.heartbeat"):
check(f"TESM-Eintrag {action}", action in body, "fehlt")
check("Diagnose TESM: Kette unversehrt", "unversehrt" in tesm.text("/diagnose/"), "gebrochen")
print(f"\n== Ergebnis: {len(PASSED)} bestanden, {len(FAILED)} fehlgeschlagen ==")
for entry in FAILED:
print(f" FEHLER: {entry}")
return 1 if FAILED else 0
if __name__ == "__main__":
if len(sys.argv) not in (5, 6):
print(__doc__)
raise SystemExit(2)
raise SystemExit(main(*sys.argv[1:]))