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
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user