"""Zertifikats-Wache (Ausbauplan Welle 1, 25.09.2026): der Ausführer liest die Zertifikate (mit echten Ausgaben von openssl auf dem Proxmox-PC), der Homelab-Teil bewertet sie nach ihrer Laufzeit — NPMplus nutzt das kurzlebige Profil von Let's Encrypt (6,7 Tage), der Proxmox-Host hat zwei Jahre, der PBS tausend.""" import importlib.util from datetime import datetime, timezone from pathlib import Path import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from kern import einstellungen as einstellungen_mod from services.homelab import kanal, zertifikate AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py" TAG = 86400 def _utc(*teile: int) -> int: return int(datetime(*teile, tzinfo=timezone.utc).timestamp()) @pytest.fixture(scope="module") def a(): spec = importlib.util.spec_from_file_location("ausfuehrer_zertifikate", AUSFUEHRER) modul = importlib.util.module_from_spec(spec) spec.loader.exec_module(modul) return modul NPM_AUSGABE = """notBefore=Sep 23 04:45:02 2026 GMT notAfter=Sep 29 20:45:01 2026 GMT issuer=C=US, O=Let's Encrypt, CN=YE2 subject=CN=git.tobisniceshomelab.ddnsfree.com X509v3 Subject Alternative Name: critical DNS:git.tobisniceshomelab.ddnsfree.com """ PBS_AUSGABE = """notBefore=Jul 9 08:51:30 2026 GMT notAfter=Nov 9 08:51:30 3025 GMT issuer=O=Proxmox Backup Server, OU=6C03AEB2-FC61-470D-9C84-E039725B97DE, CN=proxmox-backup-server.fritz.box subject=O=Proxmox Backup Server, OU=6C03AEB2-FC61-470D-9C84-E039725B97DE, CN=proxmox-backup-server.fritz.box No extensions in certificate """ def test_ausgabe_von_openssl(a): assert a.openssl_zeit("Sep 29 20:45:01 2026 GMT") == _utc(2026, 9, 29, 20, 45, 1) assert a.openssl_zeit("Oct 1 17:46:16 2026 GMT") == _utc(2026, 10, 1, 17, 46, 16) assert a.openssl_zeit("kaputt") is None assert a.x509_auswerten(NPM_AUSGABE) == { "von": _utc(2026, 9, 23, 4, 45, 2), "bis": _utc(2026, 9, 29, 20, 45, 1), "aussteller": "Let's Encrypt", "inhaber": "git.tobisniceshomelab.ddnsfree.com", "namen": ["git.tobisniceshomelab.ddnsfree.com"]} pbs = a.x509_auswerten(PBS_AUSGABE) assert (pbs["namen"], pbs["inhaber"], pbs["aussteller"]) == ([], "proxmox-backup-server.fritz.box", "Proxmox Backup Server") platzhalter = a.x509_auswerten("notAfter=Oct 1 17:46:16 2026 GMT\nX509v3 Subject Alternative Name:\n" " DNS:*.tobisniceshomelab.ddnsfree.com, DNS:tobisniceshomelab.ddnsfree.com\n") assert platzhalter["namen"] == ["*.tobisniceshomelab.ddnsfree.com", "tobisniceshomelab.ddnsfree.com"] assert a.x509_auswerten("unable to load certificate") is None def test_pem_bloecke(a): text = "### /opt/npmplus/tls/certbot/live/npm-11/fullchain.pem\n-----BEGIN CERTIFICATE-----\nAAA\n" \ "### /opt/npmplus/tls/certbot/live/npm-4/fullchain.pem\n-----BEGIN CERTIFICATE-----\nBBB\n" assert a.pem_bloecke(text) == {"/opt/npmplus/tls/certbot/live/npm-11/fullchain.pem": "-----BEGIN CERTIFICATE-----\nAAA\n", "/opt/npmplus/tls/certbot/live/npm-4/fullchain.pem": "-----BEGIN CERTIFICATE-----\nBBB\n"} def test_zertifikate_lesen(a, monkeypatch): gaeste = [{"vmid": 101, "art": "lxc", "status": "running", "name": "npmplus", "ip": "192.168.178.110", "app": {"kennung": "npmplus"}}, {"vmid": 105, "art": "lxc", "status": "running", "name": "proxmox-backup-server", "ip": "192.168.178.156"}, {"vmid": 102, "art": "lxc", "status": "stopped", "name": "npmplus"}] befehle: list[str] = [] def im_gast(vmid, befehl, zeitlimit=30): befehle.append(f"{vmid}: {befehl}") if vmid == 101: return 0, "### /opt/npmplus/tls/certbot/live/npm-11/fullchain.pem\nNPM\n" return 0, "PBS" monkeypatch.setattr(a, "_im_gast", im_gast) monkeypatch.setattr(a, "_lesen", lambda pfad: "PVE") monkeypatch.setattr(a.os.path, "exists", lambda pfad: pfad.endswith("pve-ssl.pem")) monkeypatch.setattr(a, "_zertifikat", lambda pem: a.x509_auswerten(NPM_AUSGABE if "NPM" in pem else PBS_AUSGABE)) monkeypatch.setattr(a, "_ausgeliefert", lambda ip, namen: 12345 if ip == "192.168.178.110" else None) liste = a._zertifikate_lesen(gaeste) assert [(z["quelle"], z["gast"], z["name"]) for z in liste] == [ ("pve", None, "pve-ssl.pem"), ("npm", 101, "npm-11"), ("pbs", 105, "proxy.pem")] assert liste[1]["ausgeliefert_bis"] == 12345 assert len(befehle) == 2 and "/opt/npmplus/tls/certbot/live/*/fullchain.pem" in befehle[0] def test_zertifikate_im_bericht_hoechstens_alle_30_minuten(a, monkeypatch): """Der Weg, den der Bericht nimmt: 25.09.2026 abends hieß der Zwischenspeicher wie die Funktion, und der erste echte Bericht trug statt der Zertifikate „'function' object is not subscriptable“.""" gelesen: list[int] = [] monkeypatch.setattr(a, "_zertifikate_lesen", lambda gaeste: gelesen.append(len(gaeste)) or [{"quelle": "pve"}]) monkeypatch.setattr(a, "_zertifikate_stand", {"zeit": 0.0, "liste": None}) assert a._zertifikate([]) == [{"quelle": "pve"}] assert a._zertifikate([]) == [{"quelle": "pve"}] and gelesen == [0] # gemerkt a._zertifikate_stand["zeit"] -= a.ZERTIFIKATE_ALLE_S a._zertifikate([{"vmid": 1}]) assert gelesen == [0, 1] # --- Bewertung ----------------------------------------------------------------------------------------------------- NPM = {"quelle": "npm", "gast": 101, "name": "npm-11", "von": _utc(2026, 9, 23, 4, 45, 2), "bis": _utc(2026, 9, 29, 20, 45, 1), "aussteller": "Let's Encrypt", "namen": ["git.tobisniceshomelab.ddnsfree.com"]} PVE = {"quelle": "pve", "gast": None, "name": "pve-ssl.pem", "von": _utc(2025, 8, 5, 16, 5, 14), "bis": _utc(2027, 8, 5, 16, 5, 14), "aussteller": "PVE Cluster Manager CA", "inhaber": "pve.fritz.box", "namen": ["localhost", "pve", "pve.fritz.box"]} def test_bewertung_nach_laufzeit(): frisch = zertifikate.bewerten(NPM, _utc(2026, 9, 25, 14, 40)) # 4,25 Tage übrig von 6,7 assert (frisch["stufe"], frisch["name"], frisch["laufzeit_s"]) == ("ok", "git.tobisniceshomelab.ddnsfree.com", 576_000 - 1) haengt = zertifikate.bewerten(NPM, NPM["bis"] - 2 * TAG) # unter einem Drittel assert (haengt["stufe"], haengt["art"]) == ("gelb", "haengt") assert haengt["problem"].startswith("wird nicht erneuert, noch 2 Tage gültig (bis ") knapp = zertifikate.bewerten(NPM, NPM["bis"] - 20 * 3600) # unter einem Sechstel (1,1 Tage) assert (knapp["stufe"], knapp["art"]) == ("rot", "ablauf") and knapp["problem"].startswith("läuft in 20 Stunden ab") weg = zertifikate.bewerten(NPM, NPM["bis"] + 60) assert (weg["stufe"], weg["art"]) == ("rot", "abgelaufen") # Der Proxmox-Host: zwei Jahre Laufzeit — ein Drittel wären acht Monate; gewarnt wird 30 Tage vorher. assert zertifikate.bewerten(PVE, PVE["bis"] - 200 * TAG)["stufe"] == "ok" bald = zertifikate.bewerten(PVE, PVE["bis"] - 20 * TAG) assert (bald["stufe"], bald["name"], bald["problem"][:22]) == ("gelb", "pve.fritz.box", "läuft in 20 Tagen ab (") assert zertifikate.bewerten(PVE, PVE["bis"] - 3 * TAG)["stufe"] == "rot" # NPMplus liefert noch das alte aus, obwohl das neue da ist. alt = zertifikate.bewerten({**NPM, "ausgeliefert_bis": NPM["bis"] - 3 * TAG}, _utc(2026, 9, 25, 14, 40)) assert (alt["stufe"], alt["art"]) == ("gelb", "alt") assert zertifikate.bewerten({**NPM, "ausgeliefert_bis": NPM["bis"]}, _utc(2026, 9, 25, 14, 40))["stufe"] == "ok" def test_texte(): assert [zertifikate.bis_ablauf(s) for s in (1800, 20 * 3600, TAG, 5 * TAG, 90 * TAG, 1000 * 365 * TAG)] == [ "in einer Stunde", "in 20 Stunden", "in einem Tag", "in 5 Tagen", "in 3 Monaten", "in 1000 Jahren"] assert [zertifikate.dauer(s) for s in (3600, TAG, 2 * TAG)] == ["eine Stunde", "einen Tag", "2 Tage"] def test_hinweise_je_quelle(monkeypatch): jetzt = NPM["bis"] - 2 * TAG zweites = {**NPM, "name": "npm-12", "namen": ["mc.tobisniceshomelab.ddnsfree.com"]} monkeypatch.setattr(kanal, "bericht", lambda: {"bericht": {"host": {"zertifikate": [NPM, zweites, PVE]}}}) befunde = {b.id: b for b in zertifikate.pruefe_zertifikate(jetzt)} assert set(befunde) == {"zertifikat:npm"} # der Proxmox-Host ist in Ordnung npm = befunde["zertifikat:npm"] assert (npm.stufe, npm.titel, npm.quelle) == ("gelb", "NPMplus erneuert 2 Zertifikate nicht", "ct-101") assert "SSL Certificates" in npm.text and "Container 101" in npm.text monkeypatch.setattr(kanal, "bericht", lambda: {"bericht": {"host": {"zertifikate": [ {**NPM, "ausgeliefert_bis": NPM["bis"] - 3 * TAG}, {**PVE, "bis": jetzt + 20 * TAG}]}}}) befunde = {b.id: b for b in zertifikate.pruefe_zertifikate(NPM["von"] + TAG)} assert befunde["zertifikat:npm"].titel == ("NPMplus liefert für git.tobisniceshomelab.ddnsfree.com noch das alte " "Zertifikat aus") assert befunde["zertifikat:npm"].text.endswith("NPMplus einmal neu starten, dann liefert es das neue aus.") assert befunde["zertifikat:pve"].titel.startswith("Zertifikat des Proxmox-Hosts läuft in ") def test_schnittstelle(tmp_path, monkeypatch): from routers import homelab monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path)) einstellungen_mod.einstellungen.cache_clear() try: app = FastAPI() app.include_router(homelab.router) client = TestClient(app) assert client.get("/api/homelab/zertifikate").json() == {"gelesen": False, "zertifikate": []} kanal.bericht_speichern({"host": {"zertifikate": [PVE, NPM, {"kaputt": True}]}, "gaeste": []}) daten = client.get("/api/homelab/zertifikate").json() assert daten["gelesen"] is True and [z["quelle"] for z in daten["zertifikate"]] == ["npm", "pve"] finally: einstellungen_mod.einstellungen.cache_clear()