"""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:]))