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
+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"])}