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>
This commit is contained in:
Hitonabi
2026-09-25 16:45:57 +02:00
co-authored by Claude Opus 5.5
parent afa3a476b5
commit 53ea0cd9b6
11 changed files with 610 additions and 6 deletions
+8
View File
@@ -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)."""
+139
View File
@@ -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
+2
View File
@@ -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]
+2 -1
View File
@@ -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):
+160
View File
@@ -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()
+132
View File
@@ -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": "<gemeinsames Geheimnis>", "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]:
"""„### <datei>“ 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"])}
@@ -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(<Zertifikate daten={{ gelesen: true, zertifikate: [npm, pve] }} />)
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(<Zertifikate daten={{ gelesen: true, zertifikate: [haengt, pve] }} />)
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(<Zertifikate daten={{ gelesen: false, zertifikate: [] }} />)
expect(container).toBeEmptyDOMElement()
})
})
@@ -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 (
<div role="row" className={cn("grid grid-cols-2 gap-x-4 gap-y-1 border-t border-white/[0.06] py-3 md:items-center", SPALTEN)}>
<span role="rowheader" className="col-span-2 flex min-w-0 items-start gap-2.5 md:col-span-1">
<span aria-hidden className={cn("mt-1.5 size-2 shrink-0 rounded-full", PUNKT[z.stufe])} />
<span className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-all">{z.name}{weitere}</span>
<span className="text-[13px] text-text-3">{[z.wo, z.aussteller].filter(Boolean).join(" · ")}</span>
</span>
</span>
<span role="cell" className="text-sm text-text-2">
<span className="text-xs text-text-3 md:hidden">Gültig bis </span>
<span className="ziffern">{bisText(z.bis)}</span>
<span className="block text-xs text-text-3">{nochText(z.rest_s)}</span>
</span>
<span role="cell" className={cn("text-sm", TEXT[z.stufe])}>{z.problem ?? "in Ordnung"}</span>
</div>
)
}
/** 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 (
<details open={probleme.length > 0} className="flaeche-innen group rounded-2xl px-4 open:pb-3">
<summary className="flex min-h-12 cursor-pointer list-none flex-wrap items-center justify-between gap-x-3 gap-y-1 py-2 [&::-webkit-details-marker]:hidden">
<span className="flex items-center gap-2 text-[15px] font-medium">
<ChevronRight className="size-4 text-text-3 transition-transform group-open:rotate-90" aria-hidden />
{liste.length} Zertifikate
</span>
<span className={cn("text-sm", probleme.length ? TEXT[probleme.some((z) => z.stufe === "rot") ? "rot" : "gelb"] : "text-text-2")}>
{probleme.length ? `${probleme.length} mit Problem` : "alle gültig"}
</span>
</summary>
<div role="table" aria-label="Zertifikate" className="flex flex-col">
<div role="row" className={cn("etikett hidden gap-x-4 pt-1 pb-2.5 text-[10.5px] tracking-[0.14em] text-text-3 md:grid", SPALTEN)}>
<span role="columnheader">Für</span>
<span role="columnheader">Gültig bis</span>
<span role="columnheader">Zustand</span>
</div>
{liste.map((z) => <Zeile key={`${z.quelle}-${z.datei}-${z.name}`} z={z} />)}
</div>
{kurzlebig && (
<p className="m-0 border-t border-white/[0.06] pt-3 text-sm text-text-3">
Die Zertifikate von NPMplus gelten nur knapp eine Woche und werden laufend erneuert — gemeldet wird erst,
wenn eine Erneuerung ausbleibt.
</p>
)}
</details>
)
}
+11 -1
View File
@@ -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<HomelabZertifikate>("/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.
+27
View File
@@ -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. */
+9 -4
View File
@@ -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() {
/>
</Panel>
)}
{zertifikate.data?.gelesen && zertifikate.data.zertifikate.length > 0 && (
<Panel titel="Zertifikate" id="zertifikate">
<Zertifikate daten={zertifikate.data} />
</Panel>
)}
</>
)
}