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
+251
View File
@@ -0,0 +1,251 @@
"""End-to-End-Rauchtest des Lizenzservers gegen eine laufende Installation.
Gegenstueck zu ``e2e_smoke.py`` (das prueft TESM). Beide Anwendungen teilen
sich den Kern; hier geht es um die Seiten und Schranken, die nur der
Lizenzserver hat -- Kunden, Tickets, Lizenzen, Kundenportal, Selbstbedienung
und die Maschinen-API.
Aufruf:
python3 e2e_license_server.py https://lizenz.example.org 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 Client:
def __init__(self, base: str) -> None:
self.base = base.rstrip("/")
self.https = self.base.startswith("https://")
jar = http.cookiejar.CookieJar()
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
tls = urllib.request.HTTPSHandler(context=context)
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar), tls
)
self.opener.addheaders = [("User-Agent", "tesm-e2e-lic/2.0")]
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *_args, **_kwargs): # type: ignore[override]
return None
self.plain = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar), _NoRedirect(), tls
)
self.plain.addheaders = [("User-Agent", "tesm-e2e-lic/2.0")]
def get(self, path: str) -> tuple[int, str, dict[str, str]]:
request = urllib.request.Request(self.base + path)
try:
with self.opener.open(request, timeout=30) as response:
return (
response.status,
response.read().decode("utf-8", "replace"),
dict(response.headers),
)
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace"), dict(exc.headers)
def get_raw(self, path: str) -> int:
try:
with self.plain.open(urllib.request.Request(self.base + path), timeout=30) as response:
return response.status
except urllib.error.HTTPError as exc:
return exc.code
def post(self, path: str, data: dict[str, object]) -> tuple[int, str]:
encoded = urllib.parse.urlencode(
[
(key, str(item))
for key, value in data.items()
for item in (value if isinstance(value, (list, tuple)) else [value])
]
).encode("utf-8")
request = urllib.request.Request(
self.base + path,
data=encoded,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Origin": self.base,
"Referer": self.base + path,
},
)
try:
with self.opener.open(request, timeout=45) as response:
return response.status, response.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace")
def post_json(self, path: str, payload: dict[str, object]) -> tuple[int, str]:
request = urllib.request.Request(
self.base + path,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
try:
with self.opener.open(request, timeout=30) as response:
return response.status, response.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace")
def token(self, path: str) -> str:
_, body, _ = self.get(path)
match = CSRF_RE.search(body) or BODY_CSRF_RE.search(body)
if not match:
raise AssertionError(f"Kein CSRF-Token auf {path}")
return match.group(1)
def main(base: str, username: str, password: str) -> int:
client = Client(base)
print(f"\n== Rauchtest Lizenzserver gegen {base} ==\n")
print("Oeffentlich erreichbar:")
status, body, headers = client.get("/gesundheit")
check(
"Gesundheitsendpunkt",
status == 200 and json.loads(body)["status"] == "ok",
body[:80],
)
check(
"Antwortet als Lizenzserver",
json.loads(body).get("version") == "tesm-license",
body[:80],
)
status, body, headers = client.get("/login")
check("Anmeldeseite", status == 200 and "Anmelden" in body, f"HTTP {status}")
csp = headers.get("Content-Security-Policy", "")
check("CSP mit Nonce", "nonce-" in csp, csp[:80])
check(
"X-Frame-Options",
headers.get("X-Frame-Options") == "DENY",
str(headers.get("X-Frame-Options")),
)
if client.https:
cookie = headers.get("Set-Cookie", "")
check("Sitzungscookie mit Secure", "Secure" in cookie, cookie[:80] or "kein Cookie")
print("\nKundenportal (ohne Anmeldung erreichbar):")
status, body, _ = client.get("/portal/")
check("Portalseite", status == 200 and "Kundenportal" in body, f"HTTP {status}")
check("Fragt nach der Kennung, nicht nach einem Passwort", "Passwort" not in body, "unerwartet")
print("\nSchranken ohne Anmeldung:")
for path in ("/kunden/", "/tickets/", "/lizenzen/", "/verwaltung/benutzer", "/diagnose/"):
status = client.get_raw(path)
check(f"{path} verlangt Anmeldung", status in (302, 401), f"HTTP {status}")
print("\nCSRF:")
status, body = client.post("/login", {"username": "x", "password": "y"})
check("POST ohne Token wird abgewiesen", status == 403, f"HTTP {status}")
print("\nMaschinen-API (eigene Authentisierung, kein Cookie):")
status, body = client.post_json("/api/v1/licenses/activate", {"unsinn": True})
check(
"Unsinniger Antrag wird abgewiesen",
status in (400, 401, 403, 404, 422),
f"HTTP {status}",
)
check(
"Antwort ist maschinenlesbar, nicht HTML",
body.lstrip().startswith("{") and "error" in body,
body[:100],
)
check("Keine Interna in der Antwort", "Traceback" not in body, "Stacktrace nach aussen")
check(
"Kein CSRF-Token noetig (Maschinenschnittstelle)",
"Sicherheitstoken" not in body,
"API verlangt ein Formulartoken",
)
print("\nAnmeldung:")
token = client.token("/login")
status, body = client.post(
"/login", {"username": username, "password": password, "csrf_token": token}
)
check("Anmeldung erfolgreich", status == 200 and "Anmelden" not in body, f"HTTP {status}")
print("\nSeiten (angemeldet):")
pages = [
("/", "Uebersicht"),
("/kunden/", "Kunden"),
("/tickets/", "Tickets"),
("/tickets/neu", "Ticket"),
("/lizenzen/", "Lizenzen"),
("/lizenzen/offline-code", "Offline"),
("/lizenzen/sub-lizenzserver", "Lizenzserver"),
("/eigene-lizenz/", "Lizenz"),
("/einstellungen/system", "System"),
("/einstellungen/anbieter", "Anbieter"),
("/einstellungen/email", "Mail"),
("/einstellungen/netzwerk", "Netzwerk"),
("/einstellungen/webserver", "Webserver"),
("/einstellungen/verzeichnisdienst", "Verzeichnisdienst"),
("/protokolle/aenderungen", "Aenderungsprotokoll"),
("/sicherung/", "Sicherung"),
("/papierkorb/", "Papierkorb"),
("/diagnose/", "Diagnose"),
("/verwaltung/benutzer", "Benutzer"),
("/verwaltung/gruppen", "Gruppen"),
("/konto/sicherheit", "Sicherheit"),
]
for path, needle in pages:
status, body, _ = client.get(path)
check(
f"{path}",
status == 200 and needle in body and "Interner Fehler" not in body,
f"HTTP {status}",
)
print("\nEigene Master-Lizenz:")
status, body, _ = client.get("/eigene-lizenz/")
check("Master-Rolle erkannt", "master" in body.lower(), "Rolle fehlt")
print("\nDiagnose:")
status, body, _ = client.get("/diagnose/")
check("Audit-Kette unversehrt", "unversehrt" in body, "Kette gebrochen?")
check("Signaturschluessel vorhanden", "Signatur" in body, "kein Hinweis")
print("\nAbmeldung:")
token = client.token("/konto/")
client.post("/logout", {"csrf_token": token})
check("Nach Abmeldung gesperrt", client.get_raw("/kunden/") in (302, 401), "weiter offen")
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) != 4:
print(__doc__)
raise SystemExit(2)
raise SystemExit(main(sys.argv[1], sys.argv[2], sys.argv[3]))