From 53ea0cd9b6c54e24a4d9027d2154b60be6d0c2a0 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 25 Sep 2026 16:45:57 +0200 Subject: [PATCH] =?UTF-8?q?Welle=201=20=C2=B7=20Zertifikats-Wache:=20NPMpl?= =?UTF-8?q?us,=20Proxmox-Host=20und=20PBS=20mit=20Grenzen=20nach=20Laufzei?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/routers/homelab.py | 8 + backend/services/homelab/zertifikate.py | 139 +++++++++++++++ backend/services/waechter.py | 2 + backend/tests/test_partner.py | 3 +- backend/tests/test_zertifikate.py | 160 ++++++++++++++++++ deploy/homelab/ausfuehrer.py | 132 +++++++++++++++ .../components/homelab/Zertifikate.test.tsx | 40 +++++ .../src/components/homelab/Zertifikate.tsx | 80 +++++++++ frontend/src/lib/abfragen.ts | 12 +- frontend/src/lib/typen.ts | 27 +++ frontend/src/views/Homelab.tsx | 13 +- 11 files changed, 610 insertions(+), 6 deletions(-) create mode 100644 backend/services/homelab/zertifikate.py create mode 100644 backend/tests/test_zertifikate.py create mode 100644 frontend/src/components/homelab/Zertifikate.test.tsx create mode 100644 frontend/src/components/homelab/Zertifikate.tsx diff --git a/backend/routers/homelab.py b/backend/routers/homelab.py index 97b1231..9a5f215 100644 --- a/backend/routers/homelab.py +++ b/backend/routers/homelab.py @@ -11,6 +11,7 @@ GET /api/homelab/snapshots Snapshots und Sicherungen je Gerät (Aktionen: siehe unten) POST /api/homelab/snapshots/rueckwege-loeschen Alle Rückwege von Updates löschen (nur per Klick) POST /api/homelab/sicherungen/probe Probe-Wiederherstellung jetzt (nur lesend, sonst einmal die Woche) + GET /api/homelab/zertifikate https-Zertifikate (NPMplus, Proxmox-Host, PBS) mit Stufe GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers GET /api/homelab/ausfuehrer/auftrag ┐ @@ -156,6 +157,13 @@ def speicher_lage() -> dict: return speicher.lage() +@router.get("/zertifikate") +def zertifikate_lage() -> dict: + """Die https-Zertifikate mit Ablauf und Stufe (Zertifikats-Wache, seit 25.09.2026).""" + from services.homelab import zertifikate + return zertifikate.lage() + + @router.post("/sicherungen/probe") def sicherungen_probe() -> dict: """Knopf „Jetzt prüfen“: die jüngste Sicherung jedes Geräts probeweise öffnen (seit 25.09.2026).""" diff --git a/backend/services/homelab/zertifikate.py b/backend/services/homelab/zertifikate.py new file mode 100644 index 0000000..8e15712 --- /dev/null +++ b/backend/services/homelab/zertifikate.py @@ -0,0 +1,139 @@ +"""Zertifikats-Wache (Ausbauplan Welle 1, seit 25.09.2026). + +Der Ausführer liest höchstens alle 30 Minuten die https-Zertifikate (host.zertifikate im Bericht): die von NPMplus — +Let's Encrypt im kurzlebigen Profil, am 25.09.2026 gut 6,7 Tage Laufzeit — samt dem, was NPMplus für den Namen gerade +ausliefert, das des Proxmox-Hosts (vom Cluster ausgestellt, zwei Jahre) und das des PBS (selbst signiert). Eine feste +30-Tage-Grenze passt zu so verschiedenen Laufzeiten nicht; die Grenzen richten sich nach der Laufzeit: + • gelb „wird nicht erneuert“: Bei kurzlebigen Zertifikaten (bis 100 Tage) ist weniger als ein Drittel der Laufzeit + übrig — NPMplus erneuert lange vorher. Bei langlebigen ab 30 Tagen vor Ablauf. + • rot: weniger als ein Sechstel übrig, dabei höchstens 7 und mindestens 1 Tag vor Ablauf; abgelaufen sowieso. + • gelb: NPMplus liefert ein älteres Zertifikat aus, als es hat (nach der Erneuerung nicht neu geladen). +Hinweise gehen über den Wächter (pruefe_zertifikate), je Quelle einer; die Liste zeigt das Homelab-Cockpit. +""" + +import time +from datetime import datetime + +from kern.zeit import LOCAL_TZ + +from services.homelab import kanal + +TAG_S = 86400 +KURZLEBIG_S = 100 * TAG_S +LANG_GELB_S = 30 * TAG_S +ROT_MAX_S, ROT_MIN_S = 7 * TAG_S, TAG_S +AUSGELIEFERT_TOLERANZ_S = 60 +RANG = {"ok": 0, "gelb": 1, "rot": 2} +QUELLE = {"npm": "NPMplus", "pve": "Proxmox-Host", "pbs": "Proxmox Backup Server"} +WESSEN = {"pve": "des Proxmox-Hosts", "pbs": "des PBS"} + + +def _datum(ts: float) -> str: + return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m. %H:%M") + + +def _zeitraum(sekunden: float) -> tuple[int, str, str]: + """(Anzahl, Einheit im Akkusativ, Einheit im Dativ) — 20 Stunden, 5 Tage, 3 Monate, 2 Jahre.""" + if sekunden < TAG_S: + return max(1, round(sekunden / 3600)), "Stunden", "Stunden" + tage = round(sekunden / TAG_S) + if tage < 60: + return tage, "Tage", "Tagen" + return (round(tage / 30), "Monate", "Monaten") if tage < 730 else (round(tage / 365), "Jahre", "Jahren") + + +def bis_ablauf(sekunden: float) -> str: + """„in 20 Stunden“, „in einem Tag“, „in 5 Tagen“, „in 3 Monaten“.""" + n, _, dativ = _zeitraum(sekunden) + if n == 1: + return {"Stunden": "in einer Stunde", "Tagen": "in einem Tag", "Monaten": "in einem Monat", + "Jahren": "in einem Jahr"}[dativ] + return f"in {n} {dativ}" + + +def dauer(sekunden: float) -> str: + """„20 Stunden“, „einen Tag“, „5 Tage“ — für „noch … gültig“.""" + n, akkusativ, _ = _zeitraum(sekunden) + if n == 1: + return {"Stunden": "eine Stunde", "Tage": "einen Tag", "Monate": "einen Monat", "Jahre": "ein Jahr"}[akkusativ] + return f"{n} {akkusativ}" + + +def bewerten(z: dict, jetzt: float) -> dict: + """Ein Zertifikat aus dem Bericht mit Stufe, Art des Problems (abgelaufen, ablauf, haengt, alt) und einem Satz.""" + von, bis = z.get("von"), z["bis"] + laufzeit = bis - von if von else None + rest = bis - jetzt + kurzlebig = laufzeit is not None and laufzeit <= KURZLEBIG_S + rot_ab = max(ROT_MIN_S, min(ROT_MAX_S, laufzeit / 6)) if laufzeit else ROT_MAX_S + ausgeliefert = z.get("ausgeliefert_bis") + alt = bool(ausgeliefert and ausgeliefert < bis - AUSGELIEFERT_TOLERANZ_S) + stufe, art, satz = "ok", None, None + if rest <= 0: + stufe, art, satz = "rot", "abgelaufen", f"abgelaufen am {_datum(bis)}" + elif rest < rot_ab or (not kurzlebig and rest < LANG_GELB_S): + stufe, art, satz = "rot" if rest < rot_ab else "gelb", "ablauf", f"läuft {bis_ablauf(rest)} ab ({_datum(bis)})" + elif kurzlebig and rest < laufzeit / 3: + stufe, art, satz = "gelb", "haengt", f"wird nicht erneuert, noch {dauer(rest)} gültig (bis {_datum(bis)})" + elif alt: + stufe, art, satz = "gelb", "alt", f"ausgeliefert wird noch das alte (bis {_datum(ausgeliefert)})" + namen = [n for n in z.get("namen") or [] if n] + return {"quelle": z.get("quelle"), "wo": QUELLE.get(z.get("quelle"), z.get("quelle")), "gast": z.get("gast"), + "name": namen[0] if namen else z.get("inhaber") or z.get("name"), "namen": namen, "datei": z.get("name"), + "aussteller": z.get("aussteller"), "von": von, "bis": bis, "rest_s": int(rest), + "laufzeit_s": int(laufzeit) if laufzeit else None, "stufe": stufe, "art": art, "problem": satz} + + +def lage(jetzt: float | None = None) -> dict: + """Für GET /api/homelab/zertifikate: die Zertifikate, Probleme zuerst, dann das am frühesten ablaufende. + „gelesen“: ob der Ausführer schon Zertifikate meldet (einer von vor dem 25.09.2026 tut es nicht).""" + jetzt = time.time() if jetzt is None else jetzt + host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {} + roh = host.get("zertifikate") + liste = [bewerten(z, jetzt) for z in roh or [] if isinstance(z, dict) and isinstance(z.get("bis"), int)] + liste.sort(key=lambda z: (-RANG[z["stufe"]], z["bis"])) + return {"gelesen": isinstance(roh, list), "zertifikate": liste} + + +RAT = { + "npm": ("In NPMplus: SSL Certificates → beim Zertifikat „…“ → Renew. Klappt das nicht, steht der Grund im Protokoll " + "von NPMplus (Container {gast}) — oft ist die Domain von außen nicht erreichbar."), + "pve": "In Proxmox: pve → System → Zertifikate. Das vom Cluster ausgestellte erneuert `pvecm updatecerts --force`.", + "pbs": "Im PBS: Konfiguration → Zertifikate — dort ein neues ausstellen oder hochladen.", +} + + +def _titel(quelle: str, faelle: list[dict], stufe: str) -> str: + if len(faelle) > 1: + return (f"{len(faelle)} Zertifikate von NPMplus laufen bald ab" if stufe == "rot" + else f"NPMplus erneuert {len(faelle)} Zertifikate nicht") + z = faelle[0] + wessen = f"für {z['name']}" if quelle == "npm" else WESSEN.get(quelle, z["name"]) + if z["art"] == "alt": + return f"NPMplus liefert für {z['name']} noch das alte Zertifikat aus" + if z["art"] == "abgelaufen": + return f"Zertifikat {wessen} ist abgelaufen" + if z["art"] == "haengt": + return f"Zertifikat {wessen} wird nicht erneuert" + return f"Zertifikat {wessen} läuft {bis_ablauf(z['rest_s'])} ab" + + +def pruefe_zertifikate(jetzt: float | None = None) -> list: + """Befunde für den Wächter: je Quelle (NPMplus, Proxmox-Host, PBS) einer mit der ernstesten Stufe.""" + from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier + befunde = [] + liste = lage(jetzt)["zertifikate"] + for quelle in QUELLE: + faelle = [z for z in liste if z["quelle"] == quelle and z["stufe"] != "ok"] + if not faelle: + continue + stufe = max((z["stufe"] for z in faelle), key=RANG.get) + nur_alt = all(z["art"] == "alt" for z in faelle) + rat = ("NPMplus einmal neu starten, dann liefert es das neue aus." if nur_alt + else "Läuft es ab, warnen Browser, und Apps verbinden sich nicht mehr. " + + RAT[quelle].format(gast=faelle[0]["gast"])) + einzeln = "; ".join(f"{z['name']} {z['problem']}" for z in faelle[:6]) + befunde.append(Befund(id=f"zertifikat:{quelle}", stufe=stufe, titel=_titel(quelle, faelle, stufe), + text=f"{einzeln}. {rat}", + quelle=f"ct-{faelle[0]['gast']}" if faelle[0]["gast"] else "pve")) + return befunde diff --git a/backend/services/waechter.py b/backend/services/waechter.py index 1c99c65..3b8a17a 100644 --- a/backend/services/waechter.py +++ b/backend/services/waechter.py @@ -622,6 +622,8 @@ PRUEFUNGEN += (__import__("services.homelab.pflege").homelab.pflege.pruefe_paket PRUEFUNGEN += (__import__("services.homelab.messwerte").homelab.messwerte.pruefe_host,) if ROLLE == "homelab" else () # Speicherpool, NAS und Sicherungen (seit 25.09.2026): gelb ab 80 %, rot bei 10 % frei; Sicherung älter als erlaubt. PRUEFUNGEN += (__import__("services.homelab.speicher").homelab.speicher.pruefe_speicher,) if ROLLE == "homelab" else () +# Zertifikats-Wache (seit 25.09.2026): gelb, wenn die Erneuerung hängt; rot kurz vor Ablauf. +PRUEFUNGEN += (__import__("services.homelab.zertifikate").homelab.zertifikate.pruefe_zertifikate,) if ROLLE == "homelab" else () BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"), "homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE] diff --git a/backend/tests/test_partner.py b/backend/tests/test_partner.py index b6236f5..8854bc9 100644 --- a/backend/tests/test_partner.py +++ b/backend/tests/test_partner.py @@ -220,7 +220,8 @@ from services import waechter print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "daten": str(waechter.DATEN_DIR)})) """) assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste", - "pruefe_gast_platten", "pruefe_paketlisten", "pruefe_host", "pruefe_speicher"] + "pruefe_gast_platten", "pruefe_paketlisten", "pruefe_host", "pruefe_speicher", + "pruefe_zertifikate"] def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path): diff --git a/backend/tests/test_zertifikate.py b/backend/tests/test_zertifikate.py new file mode 100644 index 0000000..800f121 --- /dev/null +++ b/backend/tests/test_zertifikate.py @@ -0,0 +1,160 @@ +"""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() diff --git a/deploy/homelab/ausfuehrer.py b/deploy/homelab/ausfuehrer.py index 5823b1a..e841d50 100644 --- a/deploy/homelab/ausfuehrer.py +++ b/deploy/homelab/ausfuehrer.py @@ -31,6 +31,9 @@ Container des Homelab-Teils braucht keinen Proxmox-Schlüssel. Viertelstunde nach dem Start) wird die jüngste Sicherung jedes Gasts probeweise geöffnet — die Konfiguration daraus gelesen, bei Containern auf dem PBS dazu der Dateibaum. Nur lesend; es entsteht kein Gast und keine Datei. + • Zertifikate im Bericht (seit 25.09.2026, höchstens alle 30 Minuten neu gelesen): die https-Zertifikate von + NPMplus samt dem, was es für den Namen gerade ausliefert, das des Proxmox-Hosts und das des PBS — + Laufzeit, Aussteller, Namen. Nur lesend. Nur die Standardbibliothek (Debian-Python des Hosts). Konfiguration: /etc/mc2-ausfuehrer.json {"server": "http://192.168.178.x:9001", "token": "", "node": "pve", "nur_lesen": false} @@ -908,6 +911,131 @@ def neuer_kernel(laufend: str | None, vorhanden: list[str], pin: str | None = No return None if naechster == laufend else naechster +# --- Zertifikate (seit 25.09.2026) ------------------------------------------------------------------------------- + +# Wo die https-Zertifikate liegen. NPMplus (Community-Script) legt die von Let's Encrypt unter certbot/live ab, eigene +# unter custom; der klassische Nginx Proxy Manager unter /etc/letsencrypt/live und /data/custom_ssl. +NPM_ORDNER = ("/opt/npmplus/tls/certbot/live", "/opt/npmplus/tls/custom", "/etc/letsencrypt/live", "/data/custom_ssl") +NPM_KENNUNGEN = {"npmplus", "nginxproxymanager"} +PBS_KENNUNG = "proxmox-backup-server" +# pveproxy nimmt das eigene (hochgeladen oder ACME), wenn es eins gibt, sonst das vom Cluster ausgestellte. +PVE_ZERTIFIKATE = ("/etc/pve/local/pveproxy-ssl.pem", "/etc/pve/local/pve-ssl.pem") +PBS_ZERTIFIKAT = "/etc/proxmox-backup/proxy.pem" +ZERTIFIKATE_ALLE_S = 1800 +_MONATE = {m: i for i, m in enumerate(("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"), + start=1)} +_zertifikate: dict = {"zeit": 0.0, "liste": None} + + +def openssl_zeit(text: str) -> int | None: + """„Sep 29 20:45:01 2026 GMT“ → Unix-Zeit. openssl schreibt englisch und in GMT, egal welche Locale gilt.""" + teile = text.split() + try: + stunde, minute, sekunde = (int(x) for x in teile[2].split(":")) + return int(datetime(int(teile[3]), _MONATE[teile[0]], int(teile[1]), stunde, minute, sekunde, + tzinfo=timezone.utc).timestamp()) + except (IndexError, KeyError, ValueError): + return None + + +def x509_auswerten(text: str) -> dict | None: + """Die Ausgabe von `openssl x509 -noout -startdate -enddate -issuer -subject -ext subjectAltName` → + {"von", "bis", "aussteller", "inhaber", "namen"}. None ohne Ablaufdatum.""" + werte: dict[str, str] = {} + namen: list[str] = [] + im_san = False + for zeile in text.splitlines(): + schluessel, gleich, wert = zeile.partition("=") + if gleich and schluessel in ("notBefore", "notAfter", "issuer", "subject"): + werte[schluessel], im_san = wert.strip(), False + elif "Subject Alternative Name" in zeile: + im_san = True + elif im_san and zeile.strip(): + namen += [n.strip()[4:] for n in zeile.split(",") if n.strip().startswith("DNS:")] + bis = openssl_zeit(werte.get("notAfter", "")) + if bis is None: + return None + + def feld(name: str, text: str) -> str | None: + treffer = re.search(rf"\b{name}\s*=\s*([^,/]+)", text) + return treffer.group(1).strip() if treffer else None + + return {"von": openssl_zeit(werte.get("notBefore", "")), "bis": bis, + "aussteller": feld("O", werte.get("issuer", "")) or feld("CN", werte.get("issuer", "")), + "inhaber": feld("CN", werte.get("subject", "")), "namen": namen} + + +def _zertifikat(pem: str) -> dict | None: + """Ein Zertifikat (das erste im Text) mit openssl auswerten.""" + try: + ergebnis = subprocess.run(["openssl", "x509", "-noout", "-startdate", "-enddate", "-issuer", "-subject", + "-ext", "subjectAltName"], input=pem, capture_output=True, text=True, timeout=15) + except (OSError, subprocess.TimeoutExpired): + return None + return x509_auswerten(ergebnis.stdout) if ergebnis.returncode == 0 else None + + +def pem_bloecke(text: str) -> dict[str, str]: + """„### “ und danach ihr Inhalt, Datei für Datei → {datei: inhalt}.""" + bloecke: dict[str, str] = {} + datei = None + for zeile in text.splitlines(keepends=True): + if zeile.startswith("### "): + datei = zeile[4:].strip() + bloecke[datei] = "" + elif datei: + bloecke[datei] += zeile + return bloecke + + +def _ausgeliefert(ip: str | None, namen: list[str]) -> int | None: + """Das Ablaufdatum dessen, was NPMplus für den ersten festen Namen gerade ausliefert — nur, wenn das ausgelieferte + Zertifikat den Namen auch trägt (sonst kam sein Standard-Zertifikat, und der Vergleich sagt nichts).""" + name = next((n for n in namen if not n.startswith("*.")), None) + if not ip or not name: + return None + try: + antwort = subprocess.run(["openssl", "s_client", "-connect", f"{ip}:443", "-servername", name], input="", + capture_output=True, text=True, timeout=15) + except (OSError, subprocess.TimeoutExpired): + return None + z = _zertifikat(antwort.stdout) + return z["bis"] if z and name in z["namen"] else None + + +def _zertifikate_lesen(gaeste: list[dict]) -> list[dict]: + liste = [] + for pfad in PVE_ZERTIFIKATE: + if os.path.exists(pfad): + if z := _zertifikat(_lesen(pfad)): + liste.append({**z, "quelle": "pve", "gast": None, "name": os.path.basename(pfad)}) + break + for g in gaeste: + # Die App-Kennung kennt der Bericht nur bei freigegebenen Gästen; sonst verrät es meist der Name (Community-Script). + kennung = (g.get("app") or {}).get("kennung") or str(g.get("name") or "").lower() + if g.get("status") != "running" or g.get("art") != "lxc": + continue + if kennung in NPM_KENNUNGEN: + muster = " ".join(f"{o}/*/fullchain.pem" for o in NPM_ORDNER) + code, text = _im_gast(g["vmid"], f'for f in {muster}; do [ -f "$f" ] && echo "### $f" && cat "$f"; done; true') + for datei, pem in (pem_bloecke(text) if code == 0 else {}).items(): + if z := _zertifikat(pem): + liste.append({**z, "quelle": "npm", "gast": g["vmid"], "name": datei.rsplit("/", 2)[-2], + "ausgeliefert_bis": _ausgeliefert(g.get("ip"), z["namen"])}) + elif kennung == PBS_KENNUNG: + code, pem = _im_gast(g["vmid"], f"cat {PBS_ZERTIFIKAT}", 15) + if code == 0 and (z := _zertifikat(pem)): + liste.append({**z, "quelle": "pbs", "gast": g["vmid"], "name": os.path.basename(PBS_ZERTIFIKAT)}) + return liste + + +def _zertifikate(gaeste: list[dict]) -> list[dict] | None: + """Die Zertifikate, höchstens alle ZERTIFIKATE_ALLE_S neu gelesen (sie ändern sich höchstens täglich).""" + if _zertifikate["liste"] is None or time.time() - _zertifikate["zeit"] >= ZERTIFIKATE_ALLE_S: + _zertifikate.update(zeit=time.time(), liste=_zertifikate_lesen(gaeste)) + return _zertifikate["liste"] + + def bericht() -> dict: speicher = _speicher_liste() arten = {s["storage"]: s.get("type") for s in speicher} @@ -926,6 +1054,10 @@ def bericht() -> dict: host = _host() host["sicherung"] = sicherung host.update(_speicherlage(speicher, gaeste)) + try: + host["zertifikate"] = _zertifikate(gaeste) + except Exception as exc: + host["zertifikate_fehler"] = str(exc)[:200] return {"zeit": datetime.now(timezone.utc).isoformat(timespec="seconds"), "host": host, "gaeste": sorted(gaeste, key=lambda g: g["vmid"])} diff --git a/frontend/src/components/homelab/Zertifikate.test.tsx b/frontend/src/components/homelab/Zertifikate.test.tsx new file mode 100644 index 0000000..149e811 --- /dev/null +++ b/frontend/src/components/homelab/Zertifikate.test.tsx @@ -0,0 +1,40 @@ +import { render, screen, within } from "@testing-library/react" +import type { Zertifikat } from "@/lib/typen" +import { Zertifikate } from "./Zertifikate" + +const jetzt = Math.floor(Date.now() / 1000) +const npm: Zertifikat = { + quelle: "npm", wo: "NPMplus", gast: 101, name: "git.tobisniceshomelab.ddnsfree.com", + namen: ["git.tobisniceshomelab.ddnsfree.com"], datei: "npm-11", aussteller: "Let's Encrypt", von: jetzt - 2 * 86_400, + bis: jetzt + 4 * 86_400 + 3600, rest_s: 4 * 86_400 + 3600, laufzeit_s: 575_999, stufe: "ok", art: null, problem: null, +} +const pve: Zertifikat = { + ...npm, quelle: "pve", wo: "Proxmox-Host", gast: null, name: "pve.fritz.box", namen: [], datei: "pve-ssl.pem", + aussteller: "PVE Cluster Manager CA", bis: jetzt + 680 * 86_400, rest_s: 680 * 86_400, laufzeit_s: 730 * 86_400, +} + +describe("Zertifikate", () => { + it("bleibt zu, solange alles gilt, und erklärt die kurze Laufzeit von NPMplus", () => { + const { container } = render() + expect(container.querySelector("details")!.open).toBe(false) + expect(screen.getByText("2 Zertifikate")).toBeInTheDocument() + expect(screen.getByText("alle gültig")).toBeInTheDocument() + expect(within(screen.getByRole("row", { name: /git\.tobis/ })).getByText("noch 4 Tage")).toBeInTheDocument() + expect(within(screen.getByRole("row", { name: /pve\.fritz\.box/ })).getByText("noch 23 Monate")).toBeInTheDocument() + expect(screen.getByText(/gelten nur knapp eine Woche/)).toBeInTheDocument() + }) + + it("klappt auf, wenn eine Erneuerung hängt", () => { + const haengt = { ...npm, stufe: "gelb" as const, art: "haengt" as const, rest_s: 2 * 86_400, + problem: "wird nicht erneuert, noch 2 Tage gültig (bis 29.09. 22:45)" } + const { container } = render() + expect(container.querySelector("details")!.open).toBe(true) + expect(screen.getByText("1 mit Problem")).toHaveClass("text-bernstein") + expect(screen.getByText("wird nicht erneuert, noch 2 Tage gültig (bis 29.09. 22:45)")).toBeInTheDocument() + }) + + it("zeigt nichts ohne Zertifikate vom Ausführer", () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/frontend/src/components/homelab/Zertifikate.tsx b/frontend/src/components/homelab/Zertifikate.tsx new file mode 100644 index 0000000..92822da --- /dev/null +++ b/frontend/src/components/homelab/Zertifikate.tsx @@ -0,0 +1,80 @@ +import { ChevronRight } from "lucide-react" +import { cn } from "cn" +import type { HomelabZertifikate, Zertifikat } from "@/lib/typen" +import { flugplanZeit } from "@/lib/zeit" + +// Zertifikats-Wache (Ausbauplan Welle 1, seit 25.09.2026): die https-Zertifikate von NPMplus, Proxmox-Host und PBS mit +// Ablauf. Die Grenzen richten sich nach der Laufzeit (NPMplus: 6,7 Tage, Let's Encrypt kurzlebig) — die Regeln stehen +// in backend/services/homelab/zertifikate.py. + +const SPALTEN = "md:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1.4fr)]" +const PUNKT = { ok: "bg-gruen", gelb: "bg-bernstein", rot: "bg-rot shadow-[0_0_8px_var(--rot)]" } as const +const TEXT = { ok: "text-text-3", gelb: "text-bernstein", rot: "text-rot-text" } as const +const bisText = (s: number) => flugplanZeit(new Date(s * 1000).toISOString()).toLowerCase() + +/** „noch 4 Tage“, „noch 20 Stunden“, „noch 3 Monate“, „noch 999 Jahre“. */ +function nochText(sekunden: number): string { + if (sekunden <= 0) return "abgelaufen" + if (sekunden < 86_400) return `noch ${Math.max(1, Math.round(sekunden / 3600))} Std.` + const tage = Math.round(sekunden / 86_400) + if (tage < 60) return `noch ${tage} ${tage === 1 ? "Tag" : "Tage"}` + if (tage < 730) return `noch ${Math.round(tage / 30)} Monate` + return `noch ${Math.round(tage / 365)} Jahre` +} + +function Zeile({ z }: { z: Zertifikat }) { + const weitere = z.namen.length > 1 ? ` und ${z.namen.length - 1} weitere` : "" + return ( +
+ + + + {z.name}{weitere} + {[z.wo, z.aussteller].filter(Boolean).join(" · ")} + + + + Gültig bis + {bisText(z.bis)} + {nochText(z.rest_s)} + + {z.problem ?? "in Ordnung"} +
+ ) +} + +/** Die Zertifikate im Homelab-Cockpit: zugeklappt, solange alles stimmt; die Zeile darüber sagt, was als Nächstes + * abläuft. */ +export function Zertifikate({ daten }: { daten: HomelabZertifikate }) { + const liste = daten.zertifikate + if (!daten.gelesen || liste.length === 0) return null + const probleme = liste.filter((z) => z.stufe !== "ok") + const kurzlebig = liste.some((z) => z.quelle === "npm" && z.laufzeit_s !== null && z.laufzeit_s < 30 * 86_400) + return ( +
0} className="flaeche-innen group rounded-2xl px-4 open:pb-3"> + + + + {liste.length} Zertifikate + + z.stufe === "rot") ? "rot" : "gelb"] : "text-text-2")}> + {probleme.length ? `${probleme.length} mit Problem` : "alle gültig"} + + +
+ + {liste.map((z) => )} +
+ {kurzlebig && ( +

+ Die Zertifikate von NPMplus gelten nur knapp eine Woche und werden laufend erneuert — gemeldet wird erst, + wenn eine Erneuerung ausbleibt. +

+ )} +
+ ) +} diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts index 4503fb8..c61874f 100644 --- a/frontend/src/lib/abfragen.ts +++ b/frontend/src/lib/abfragen.ts @@ -10,7 +10,7 @@ import { melden } from "./meldungen" import { stromVerbunden } from "./strom" import type { Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen, - HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus, + HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus, ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion, ZielStand, Zeitraum, } from "./typen" @@ -492,6 +492,16 @@ export const useHomelabSpeicher = (aktiv: boolean) => retry: false, }) +/** Die https-Zertifikate (Zertifikats-Wache, seit 25.09.2026) — der Ausführer liest sie alle 30 Minuten. */ +export const useHomelabZertifikate = (aktiv: boolean) => + useQuery({ + queryKey: ["homelab-zertifikate"], + queryFn: () => api("/api/homelab/zertifikate"), + enabled: aktiv, + refetchInterval: takt(300_000), + retry: false, + }) + /** * Snapshots und Sicherungen je Gerät (Seite Homelab → Snapshots, seit 25.09.2026). Der Schlüssel beginnt mit * „homelab“: Wer „homelab“ neu lädt (Live-Strom nach einem neuen Bericht, die Update-Knöpfe), lädt diese Liste mit. diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index 98f4b18..c7fc8c9 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -623,6 +623,33 @@ export interface HomelabSpeicher { empfehlungen: SpeicherEmpfehlung[] } +// --- Zertifikate des Homelabs (GET /api/homelab/zertifikate, seit 25.09.2026) ----------------------------------- + +export interface Zertifikat { + /** npm = NPMplus, pve = Proxmox-Host, pbs = Proxmox Backup Server */ + quelle: string + wo: string + gast: number | null + /** Der erste Name im Zertifikat (sonst der Inhaber). */ + name: string + namen: string[] + datei: string | null + aussteller: string | null + von: number | null + bis: number + rest_s: number + laufzeit_s: number | null + stufe: "ok" | "gelb" | "rot" + art: "abgelaufen" | "ablauf" | "haengt" | "alt" | null + problem: string | null +} + +export interface HomelabZertifikate { + /** Meldet der Ausführer schon Zertifikate? (Ein älterer tut es nicht.) */ + gelesen: boolean + zertifikate: Zertifikat[] +} + // --- Snapshots des Homelabs (GET /api/homelab/snapshots, seit 25.09.2026) --------------------------------------- /** Woher ein Snapshot stammt: Rückweg vor einem Update, per Knopf auf der Seite Snapshots, von Hand in Proxmox. */ diff --git a/frontend/src/views/Homelab.tsx b/frontend/src/views/Homelab.tsx index 1b7cf62..ebe59a1 100644 --- a/frontend/src/views/Homelab.tsx +++ b/frontend/src/views/Homelab.tsx @@ -7,11 +7,10 @@ import { Lagebild } from "@/components/homelab/Lagebild" import { Speicher } from "@/components/homelab/Speicher" import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellauf" import { UpdateTabelle } from "@/components/homelab/UpdateTabelle" +import { Zertifikate } from "@/components/homelab/Zertifikate" import { - useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useInstanz, usePartner, - useSammellauf, useSammellaufQuittieren, - useSicherungsProbe, - useZielAktion, + useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useHomelabZertifikate, + useInstanz, usePartner, useSammellauf, useSammellaufQuittieren, useSicherungsProbe, useZielAktion, } from "@/lib/abfragen" import { lage, offeneUpdates } from "@/lib/homelab" import { istSnapshotLauf } from "@/lib/snapshots" @@ -198,6 +197,7 @@ export function HomelabSeite() { const { ersatz, stand, laufend, laeufe, hinweise, aktion } = useHomelabBereich() const mess = useHomelabMesswerte("1h", !!stand) const speicher = useHomelabSpeicher(!!stand) + const zertifikate = useHomelabZertifikate(!!stand) const probe = useSicherungsProbe() if (ersatz || !stand) return ersatz @@ -256,6 +256,11 @@ export function HomelabSeite() { /> )} + {zertifikate.data?.gelesen && zertifikate.data.zertifikate.length > 0 && ( + + + + )} ) }