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