"""End-to-End-Rauchtest gegen eine laufende Installation. Laeuft auf dem Zielhost, spricht ueber HTTP mit nginx und der echten Anwendung -- kein Testclient, keine Attrappen. Prueft genau das, was ein Mensch beim ersten Durchklicken pruefen wuerde, plus die Dinge, die man beim Klicken leicht uebersieht (Sicherheitsheader, CSRF, statische Dateien, Rechteschranken). Aufruf: python3 e2e_smoke.py http://127.0.0.1:8080 admin 'Passwort' """ from __future__ import annotations import http.cookiejar import json import re import secrets import ssl import subprocess 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="([^"]+)"') class Client: def __init__(self, base: str) -> None: self.base = base.rstrip("/") self.https = self.base.startswith("https://") self.jar = http.cookiejar.CookieJar() # Der Test laeuft auf dem Zielhost selbst gegen ein Zertifikat, das # dieselbe Maschine ausgestellt hat. Es zu pruefen hiesse, das eigene # Zertifikat gegen sich selbst zu pruefen -- geprueft wird hier, ob TLS # steht und die Anwendung darueber antwortet. context = ssl.create_default_context() context.check_hostname = False context.verify_mode = ssl.CERT_NONE self.tls = urllib.request.HTTPSHandler(context=context) self.opener = urllib.request.build_opener( urllib.request.HTTPCookieProcessor(self.jar), urllib.request.HTTPRedirectHandler(), self.tls, ) self.opener.addheaders = [("User-Agent", "tesm-e2e/2.0")] class _NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, *_args, **_kwargs): # type: ignore[override] return None # Zweiter Opener ohne Weiterleitung: nur so laesst sich pruefen, ob eine # Seite tatsaechlich mit 302 auf die Anmeldung verweist. self.plain = urllib.request.build_opener( urllib.request.HTTPCookieProcessor(self.jar), _NoRedirect(), self.tls ) self.plain.addheaders = [("User-Agent", "tesm-e2e/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=20) 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: """Statuscode ohne Weiterleitung zu folgen.""" request = urllib.request.Request(self.base + path) try: with self.plain.open(request, timeout=20) 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=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) PASSED: list[str] = [] FAILED: list[str] = [] #: Jeder Lauf legt eigene Datensaetze an. Sonst laesst sich der Test kein #: zweites Mal gegen dieselbe Installation ausfuehren -- beim ersten Lauf #: landet ein Geraet im Papierkorb, und die Anwendung weigert sich danach #: voellig zu Recht, dieselbe MAC noch einmal zu vergeben. RUN = secrets.token_hex(3) MAC = ":".join(("AA", "BB", RUN[0:2], RUN[2:4], RUN[4:6], "01")).upper() SUBNET = f"10.99.{int(RUN[0:2], 16)}" FLASH_RE = re.compile(r'id="flashed-messages">(.*?)', re.S) ROW_SPLIT = re.compile(r"]|]") def flash(body: str) -> str: """Die Meldungen der Seite -- damit ein Fehlschlag erklaert, was schiefging.""" match = FLASH_RE.search(body) if not match: return "keine Meldung" try: return " | ".join(text for _level, text in json.loads(match.group(1))) except (ValueError, TypeError): return match.group(1)[:120] def id_for(body: str, needle: str, pattern: str) -> str: """Sucht die Kennung in der Zeile, in der ``needle`` steht. Die Listenansicht nennt die Kennung nur in Formular- und Link-Zielen. Der Test darf sie nicht raten -- eine feste ``1`` funktioniert genau einmal und trifft beim naechsten Lauf einen fremden Datensatz. """ for chunk in ROW_SPLIT.split(body): if needle in chunk: match = re.search(pattern, chunk) if match: return match.group(1) return "" 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}") def local_host_fingerprints() -> set[str]: """Die Fingerprints, die ``ssh-keyscan`` fuer diesen Host meldet. Der Vergleich ist der Kern der Pruefung: die Anwendung darf nicht irgendeinen plausiblen Wert anzeigen, sondern genau den des Gegenuebers. """ try: scan = subprocess.run( ["ssh-keyscan", "-T", "5", "-p", "22", "127.0.0.1"], capture_output=True, text=True, timeout=20, ) fingerprints: set[str] = set() for line in scan.stdout.splitlines(): if not line.strip() or line.startswith("#"): continue result = subprocess.run( ["ssh-keygen", "-lf", "-"], input=line, capture_output=True, text=True, timeout=10 ) match = re.search(r"(SHA256:[A-Za-z0-9+/]{43})", result.stdout) if match: fingerprints.add(match.group(1)) return fingerprints except (OSError, subprocess.SubprocessError): return set() def main(base: str, username: str, password: str) -> int: client = Client(base) print(f"\n== Rauchtest gegen {base} (Lauf {RUN}) ==\n") # -- oeffentlich --------------------------------------------------------- print("Oeffentlich erreichbar:") status, body, headers = client.get("/gesundheit") check("Gesundheitsendpunkt", status == 200 and json.loads(body)["status"] == "ok", body[:80]) status, body, headers = client.get("/login") check("Anmeldeseite", status == 200 and "Anmelden" in body, f"HTTP {status}") if client.https: cookie = headers.get("Set-Cookie", "") check("Sitzungscookie mit Secure", "Secure" in cookie, cookie[:80] or "kein Cookie") check("Sitzungscookie HttpOnly", "HttpOnly" in cookie, cookie[:80] or "kein Cookie") check("Sitzungscookie SameSite", "SameSite" in cookie, cookie[:80] or "kein Cookie") csp = headers.get("Content-Security-Policy", "") check("CSP mit Nonce", "nonce-" in csp and "unsafe-inline" not in csp.split("style-src")[0], csp[:90]) check("X-Frame-Options", headers.get("X-Frame-Options") == "DENY", str(headers.get("X-Frame-Options"))) check("Vorgangsnummer im Header", bool(headers.get("X-Request-Id")), "fehlt") status, body, _ = client.get("/") check("Oeffentliche Statusuebersicht", status == 200 and "Statusuebersicht" in body, f"HTTP {status}") check("Ohne Anmeldung keine Details", "oeffentliche Ansicht" in body, "Hinweis fehlt") for asset in ("/core-assets/css/base.css", "/core-assets/js/app.js"): status, body, _ = client.get(asset) check(f"Statische Datei {asset}", status == 200 and len(body) > 1000, f"HTTP {status}") print("\nSchranken ohne Anmeldung:") for path in ("/clients/", "/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}") # -- Anmeldung ----------------------------------------------------------- print("\nAnmeldung:") token = client.token("/login") status, body = client.post( "/login", {"username": username, "password": password, "csrf_token": token} ) check("Anmeldung erfolgreich", "Willkommen" in body or status == 200, f"HTTP {status}") status, body, _ = client.get("/konto/") check("Kontoseite erreichbar", status == 200 and username in body, f"HTTP {status}") # -- Seiten -------------------------------------------------------------- print("\nSeiten (angemeldet):") pages = [ ("/", "Statusuebersicht"), ("/clients/", "Clients"), ("/switche/", "Switche"), ("/zugangsdaten/", "Zugangsdaten"), ("/protokolle/laufend", "Protokoll"), ("/protokolle/aenderungen", "Aenderungsprotokoll"), ("/protokolle/neustarts", "Neustarts"), ("/einstellungen/system", "Systemeinstellungen"), ("/einstellungen/netzwerk", "Netzwerk"), ("/einstellungen/webserver", "Webserver"), ("/einstellungen/verzeichnisdienst", "Verzeichnisdienst"), ("/lizenz/", "Lizenz"), ("/sicherung/", "Sicherung"), ("/papierkorb/", "Papierkorb"), ("/diagnose/", "Diagnose"), ("/verwaltung/benutzer", "Benutzer"), ("/verwaltung/gruppen", "Gruppen"), ("/verwaltung/sitzungen", "Sitzungen"), ("/konto/sicherheit", "Sicherheit"), ] for path, needle in pages: status, body, _ = client.get(path) ok = status == 200 and needle in body and "Interner Fehler" not in body check(f"{path}", ok, f"HTTP {status}") # Die Modulrouten sind an die Lizenz gebunden. Welcher Zustand gerade gilt, # darf der Test nicht vorschreiben -- er darf nur verlangen, dass alle drei # dasselbe sagen. Ein Modul, das ohne Lizenz durchkommt, waere ein Loch; # ein Modul, das mit Lizenz 404 liefert, waere ein Ausfall. print("\nLizenzpflichtige Module:") module_status = { path: client.get(path)[0] for path in ("/dhcp/", "/wartung/", "/dateifreigaben/") } licensed = set(module_status.values()) == {200} unlicensed = set(module_status.values()) == {404} check( "Alle Modulrouten im selben Zustand", licensed or unlicensed, ", ".join(f"{path} {code}" for path, code in module_status.items()), ) print(f" [info] Lizenz ist {'aktiv' if licensed else 'nicht aktiv'}.") # -- Daten anlegen ------------------------------------------------------- print("\nDaten anlegen:") credential_name = f"E2E-Switchlogin-{RUN}" switch_name = f"e2e-switch-{RUN}" device_name = f"E2E-Kamera-{RUN}" secret = f"Geheim-fuer-den-Test-{RUN}" token = client.token("/zugangsdaten/") status, body = client.post( "/zugangsdaten/neu", { "csrf_token": token, "name": credential_name, "username": "operator", "secret": secret, "category": "switch", }, ) check("Zugangsdaten angelegt", "angelegt" in body, flash(body)) credential_id = id_for(body, credential_name, r"/zugangsdaten/(\d+)/") check("Kennung der Zugangsdaten gefunden", bool(credential_id), "nicht gefunden") token = client.token("/switche/") status, body = client.post( "/switche/neu", { "csrf_token": token, "hostname": switch_name, "ip": f"{SUBNET}.2", "ssh_port": "22", "credential_id": credential_id, }, ) check("Switch angelegt", "angelegt" in body, flash(body)) switch_id = id_for(body, switch_name, r"/switche/(\d+)") check("Kennung des Switches gefunden", bool(switch_id), "nicht gefunden") token = client.token("/clients/") status, body = client.post( "/clients/neu", { "csrf_token": token, "name": device_name, "mac": MAC, "ip": f"{SUBNET}.50", "switch_id": switch_id, "port": "7", "ssh_port": "22", "is_active": "1", "auto_restart": "1", }, ) check("Geraet angelegt", "angelegt" in body, flash(body)) device_id = id_for(body, device_name, r"/clients/(\d+)") check("Kennung des Geraets gefunden", bool(device_id), "nicht gefunden") status, body, _ = client.get("/clients/") check( "Geraet in der Liste", device_name in body and MAC.lower() in body, "nicht gefunden", ) status, body, _ = client.get("/") check("Geraet auf der Uebersicht", device_name in body, "nicht gefunden") print("\nValidierung:") token = client.token("/clients/") status, body = client.post( "/clients/neu", {"csrf_token": token, "name": "Kaputt", "mac": "unsinn", "ip": f"{SUBNET}.51"}, ) check("Ungueltige MAC abgelehnt", "hexadezimale" in body, flash(body)) token = client.token("/clients/") status, body = client.post( "/clients/neu", {"csrf_token": token, "name": "Kaputt", "mac": MAC[:-2] + "FE", "ip": "999.1.1.1"}, ) check("Ungueltige IP abgelehnt", "gueltige IP-Adresse" in body, flash(body)) token = client.token("/clients/") status, body = client.post( "/clients/neu", {"csrf_token": token, "name": "Doppelt", "mac": MAC, "ip": f"{SUBNET}.52"}, ) check("Doppelte MAC abgelehnt", "angelegt" not in body, "zweimal dieselbe MAC") # -- Host-Schluessel eines Clients --------------------------------------- # Gegen einen echten SSH-Dienst: der Host selbst. Nur so ist geprueft, dass # der gelesene Fingerprint tatsaechlich der des Gegenuebers ist -- eine # Attrappe wuerde genau den Fehler durchlassen, auf den es hier ankommt. print("\nSSH-Host-Schluessel eines Clients:") token = client.token("/clients/") ssh_name = f"E2E-SSH-{RUN}" status, body = client.post( "/clients/neu", { "csrf_token": token, "name": ssh_name, "mac": MAC[:-2] + "0F", "ip": "127.0.0.1", "ssh_port": "22", "credential_id": credential_id, "is_active": "0", }, ) check("Client fuer den SSH-Test angelegt", "angelegt" in body, flash(body)) ssh_id = id_for(body, ssh_name, r"/clients/(\d+)") check("Kennung gefunden", bool(ssh_id), "nicht gefunden") if ssh_id: status, body, _ = client.get(f"/clients/{ssh_id}") check("Detailseite zeigt die Schluesselkarte", "SSH-Host-Schluessel" in body, "fehlt") check("Zustand offen", "Noch kein Schluessel hinterlegt" in body, "unerwartet") token = client.token(f"/clients/{ssh_id}") status, body = client.post( f"/clients/{ssh_id}/hostkey/pruefen", {"csrf_token": token} ) check("Schluessel ausgelesen", "Host-Schluessel gelesen" in body, flash(body)) match = re.search(r"(SHA256:[A-Za-z0-9+/]{43})", body) fingerprint = match.group(1) if match else "" check("Fingerprint erhalten", bool(fingerprint), "nicht gefunden") expected = local_host_fingerprints() check( "Fingerprint stimmt mit ssh-keyscan ueberein", bool(expected) and fingerprint in expected, f"gelesen {fingerprint}, erwartet eines von {sorted(expected)}", ) key_match = re.search(r'name="key_type" value="([^"]+)"', body) key_type = key_match.group(1) if key_match else "" token = client.token(f"/clients/{ssh_id}") status, body = client.post( f"/clients/{ssh_id}/hostkey/freigeben", {"csrf_token": token, "key_type": key_type, "fingerprint": fingerprint}, ) check("Schluessel freigegeben", "freigegeben" in body, flash(body)) check("Fingerprint steht auf der Seite", fingerprint in body, "nicht gefunden") token = client.token(f"/clients/{ssh_id}") status, body = client.post( f"/clients/{ssh_id}/hostkey/freigeben", {"csrf_token": token, "key_type": key_type, "fingerprint": "SHA256:zu-kurz"}, ) check("Unsinniger Fingerprint abgelehnt", "kein gueltiger Fingerprint" in body, flash(body)) status, body, _ = client.get("/protokolle/aenderungen") check("Freigabe protokolliert", "device.host_key_trusted" in body, "kein Eintrag") token = client.token(f"/clients/{ssh_id}") status, body = client.post( f"/clients/{ssh_id}/hostkey/verwerfen", {"csrf_token": token} ) check("Schluessel verworfen", "entfernt" in body, flash(body)) print("\nNeustart-Protokoll:") status, body, _ = client.get("/protokolle/neustarts") check("Seite heisst Neustarts", status == 200 and "Neustarts" in body, f"HTTP {status}") check("Alte PoE-Adresse ist weg", client.get_raw("/protokolle/poe") == 404, "noch da") check("Filter nach Methode vorhanden", "methode=ssh" in body, "kein Filter") print("\nGeheimnisse:") token = client.token("/zugangsdaten/") status, body = client.post( f"/zugangsdaten/{credential_id}/anzeigen", {"csrf_token": token} ) check("Klartextanzeige moeglich (frisch angemeldet)", secret in body, f"HTTP {status}") status, body, _ = client.get("/protokolle/aenderungen") check("Zugriff protokolliert", "credential.secret_revealed" in body, "kein Eintrag") print("\nPapierkorb:") token = client.token("/clients/") client.post(f"/clients/{device_id}/loeschen", {"csrf_token": token}) status, body, _ = client.get("/papierkorb/") check("Geloeschtes Geraet im Papierkorb", device_name in body, "nicht gefunden") token = client.token("/papierkorb/") status, body = client.post( f"/papierkorb/devices/{device_id}/wiederherstellen", {"csrf_token": token} ) if licensed: check("Wiederherstellen mit Lizenz moeglich", "wiederhergestellt" in body, flash(body)) else: check("Wiederherstellen ohne Lizenz gesperrt", "Lizenz" in body, flash(body)) print("\nDiagnose:") status, body, _ = client.get("/diagnose/") check("Audit-Kette unversehrt", "unversehrt" in body, "Kette gebrochen?") # Nicht "liegt die Datei da", sondern "antwortet er". Genau der # Unterschied hat einmal verdeckt, dass jede Systemaktion an # NoNewPrivileges scheiterte, waehrend die Seite "erreichbar" meldete. check("Systemhelfer antwortet", "Helfer antwortet" in body, "Helfer meldet sich nicht") check("Schluesselrechte in Ordnung", "in Ordnung" in body, "Problem gemeldet") print("\nAbmeldung:") token = client.token("/konto/") status, body = client.post("/logout", {"csrf_token": token}) status = client.get_raw("/clients/") check("Nach Abmeldung gesperrt", status in (302, 401), f"HTTP {status}") 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]))