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>
252 lines
9.1 KiB
Python
252 lines
9.1 KiB
Python
"""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]))
|