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:
co-authored by
Claude Opus 5.5
parent
afa3a476b5
commit
53ea0cd9b6
@@ -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"])}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user