Der erste echte Bericht trug statt der Zertifikate „'function' object is not subscriptable“: def _zertifikate() überschrieb das gleichnamige Dict. Jetzt _zertifikate_stand, mit Test über genau diesen Weg (auf dem Host nachgeprüft: 12 Zertifikate). Proxmox-Host und PBS heißen in der Liste nach ihrem Rechnernamen statt „localhost“. OFFENE-FAEDEN: Welle 1 erledigt, AdGuard-Zugang offen beim User. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
9.9 KiB
Python
175 lines
9.9 KiB
Python
"""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()
|