Files
mission-control-v2/backend/tests/test_zertifikate.py
T
HitonabiandClaude Opus 5.5 53ea0cd9b6 Welle 1 · Zertifikats-Wache: NPMplus, Proxmox-Host und PBS mit Grenzen nach Laufzeit
Der Ausführer liest alle 30 Minuten die https-Zertifikate (NPMplus samt dem, was es für den Namen ausliefert,
Proxmox-Host, PBS). Weil NPMplus das kurzlebige Profil von Let's Encrypt nutzt (6,7 Tage), gilt keine feste
30-Tage-Grenze: gelb „wird nicht erneuert“, sobald weniger als ein Drittel der Laufzeit übrig ist (langlebige: 30 Tage
vorher), rot unter einem Sechstel (1–7 Tage vor Ablauf), gelb auch, wenn NPMplus noch das alte ausliefert. Je Quelle
ein Hinweis; das Homelab-Cockpit zeigt die Liste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:45:57 +02:00

161 lines
9.1 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]
# --- 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": []}
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()