diff --git a/backend/kern/ziele.py b/backend/kern/ziele.py index b0812d7..0d702dc 100644 --- a/backend/kern/ziele.py +++ b/backend/kern/ziele.py @@ -36,6 +36,7 @@ class BausteinStand: kurz: str = "" # „10 Pakete", „b11160 → b11172", „743 Änderungen" grund: str | None = None # bei „unbekannt" und „festgehalten": warum aktion: Aktion | None = None # None = kein Knopf (aktuell, festgehalten, unbekannt) + sicherheit: int | None = None # davon Sicherheitsupdates (seit 25.09.2026; None = unbekannt) @dataclass diff --git a/backend/services/homelab/arcane.py b/backend/services/homelab/arcane.py index 11a27cf..3e5761d 100644 --- a/backend/services/homelab/arcane.py +++ b/backend/services/homelab/arcane.py @@ -219,6 +219,32 @@ def _container_liste(url: str) -> list[dict] | None: return ergebnis +def luecken(url: str, image_ids: tuple[str, ...]) -> dict[str, dict]: + """Arcanes Lücken-Scan (Trivy) je Image: {imageId: {"kritisch", "hoch", "mittel", "gesamt", "zeit"}} — nur für + gescannte Images. Leer ohne Schlüssel, ohne Scan oder wenn Arcane nicht antwortet (seit 25.09.2026).""" + if not schluessel() or not image_ids: + return {} + return _gemerkt(f"luecken:{url}:{','.join(image_ids)}", lambda: _luecken(url, list(image_ids))) or {} + + +def _luecken(url: str, image_ids: list[str]) -> dict[str, dict]: + ergebnis: dict[str, dict] = {} + try: + for umgebung in umgebungen(url): + r = httpx.post(f"{url}/api/environments/{umgebung['id']}/images/vulnerabilities/summaries", + headers={"X-API-Key": schluessel()}, json={"imageIds": image_ids}, timeout=ZEITLIMIT) + r.raise_for_status() + for image_id, s in (((r.json() or {}).get("data") or {}).get("summaries") or {}).items(): + zahlen = (s or {}).get("summary") or {} + if s and s.get("scanTime") and zahlen: + ergebnis[image_id] = {"kritisch": int(zahlen.get("critical") or 0), "hoch": int(zahlen.get("high") or 0), + "mittel": int(zahlen.get("medium") or 0), "gesamt": int(zahlen.get("total") or 0), + "zeit": s.get("scanTime")} + except (httpx.HTTPError, ValueError, TypeError, AttributeError): + return {} + return ergebnis + + def container_zahlen(url: str) -> dict | None: """Laufende und alle Container über alle Umgebungen, für die Kachel (seit 25.09.2026): {"laufend", "gesamt"}. None ohne Schlüssel oder wenn Arcane nicht antwortet.""" diff --git a/backend/services/homelab/docker.py b/backend/services/homelab/docker.py index 1227eb7..28241ae 100644 --- a/backend/services/homelab/docker.py +++ b/backend/services/homelab/docker.py @@ -7,6 +7,8 @@ Stack (Compose-Projekt) Zustand, Gesundheit, Image und Update-Stand: • „lokal gebaut“, wenn das Image aus keiner Registry kommt (Arcane: updateType „local“, oder die Registry kennt es nicht — NerdQuiz und Rippy baut der User selbst) — dort gibt es keinen Abgleich, • sonst „aktuell“. +Seit 25.09.2026 (Punkt 11) steht je Container auch, was Arcanes Lücken-Scan (Trivy, sonntags 02:00) im Image gefunden +hat — kritische und hohe zuerst; ungescannte Images haben keine Angabe. Der Wächter meldet gelb, wenn ein Container ungesund ist (Healthcheck) oder immer wieder neu startet; beendete Container (etwa einmalige Aufgaben) sind kein Befund. Neue Stacks erscheinen unter „Neu entdeckt“ (entdecken.py). """ @@ -37,7 +39,8 @@ def container_zeile(c: dict) -> dict: else: update = "aktuell" name = str((c.get("names") or ["?"])[0]).lstrip("/") - return {"id": str(c.get("id") or name)[:12], "name": name, "stack": labels.get("com.docker.compose.project"), + return {"id": str(c.get("id") or name)[:12], "image_id": c.get("imageId"), "luecken": None, + "name": name, "stack": labels.get("com.docker.compose.project"), "dienst": labels.get("com.docker.compose.service"), "image": c.get("image"), "zustand": str(c.get("state") or "unbekannt"), "status": c.get("status"), "gesund": {"healthy": True, "unhealthy": False}.get(gesund.group(1)) if gesund else None, @@ -55,8 +58,10 @@ def lage() -> dict: if roh is None: return {"verfuegbar": False, "grund": "Arcane antwortet gerade nicht.", "stacks": []} stacks: dict[str, list[dict]] = {} - for c in roh: - zeile = container_zeile(c) + zeilen = [container_zeile(c) for c in roh] + befunde = arcane.luecken(url, tuple(sorted({z["image_id"] for z in zeilen if z["image_id"]}))) + for zeile in zeilen: + zeile["luecken"] = befunde.get(zeile["image_id"] or "") stacks.setdefault(zeile["stack"] or zeile["name"], []).append(zeile) return {"verfuegbar": True, "grund": None, "stacks": [{"name": n, "container": sorted(cs, key=lambda z: z["name"])} for n, cs in sorted(stacks.items())]} diff --git a/backend/services/homelab/inventar.py b/backend/services/homelab/inventar.py index 9af717b..7d8e53b 100644 --- a/backend/services/homelab/inventar.py +++ b/backend/services/homelab/inventar.py @@ -23,7 +23,7 @@ from kern.github import neueste_version from kern.zeit import LOCAL_TZ from kern.ziele import Aktion, BausteinStand, ZielStand -from services.homelab import apps, arcane, kanal, karenz +from services.homelab import apps, arcane, kanal, karenz, sicherheit BERICHT_ALT_S = 30 * 60 LISTEN_ALT_S = 14 * 24 * 3600 @@ -168,9 +168,13 @@ def _os_baustein(gast: dict, zid: str, jetzt: float) -> BausteinStand: stand.zustand, stand.aktion = "unbekannt", suchen stand.grund = f"Die Paketlisten sind vom {_datum(listen)}; was fehlt, weiß erst eine neue Suche." elif anzahl > 0: + # Seit 25.09.2026: wie viele davon eine Sicherheitslücke schließen (Debian: Suite „-security“, Alpine: secdb). + stand.sicherheit = os_stand.get("sicherheit") if "sicherheit" in os_stand else sicherheit.alpine_sicherheit(os_stand) + davon = f" (davon {stand.sicherheit} Sicherheitsupdate{'s' if stand.sicherheit != 1 else ''})" \ + if stand.sicherheit else "" stand.zustand, stand.kurz = "neu", f"{anzahl} Pakete" stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/os-update", - f"{anzahl} Pakete im Gast jetzt einspielen? {_rueckweg_frage(gast)}") + f"{anzahl} Pakete{davon} im Gast jetzt einspielen? {_rueckweg_frage(gast)}") return stand @@ -269,6 +273,8 @@ def _host_ziel(host: dict) -> ZielStand: pakete.zustand, pakete.grund = "unbekannt", host.get("fehler") or "Die Update-Liste fehlt im Bericht." elif updates: pakete.zustand, pakete.kurz = "neu", f"{len(updates)} Pakete" + if isinstance(host.get("updates_sicherheit"), list): + pakete.sicherheit = len(host["updates_sicherheit"]) pakete.aktion = Aktion("POST", "/api/homelab/ziele/pve/update", f"{len(updates)} Pakete auf dem Proxmox-Host einspielen? Alle Gäste laufen weiter. " "Einen Neustart löst das nicht aus — der ist ein eigener Knopf.") diff --git a/backend/services/homelab/sicherheit.py b/backend/services/homelab/sicherheit.py new file mode 100644 index 0000000..d57a002 --- /dev/null +++ b/backend/services/homelab/sicherheit.py @@ -0,0 +1,120 @@ +"""Sicherheits-Markierung (Ausbauplan Welle 2, Punkt 11, seit 25.09.2026). + +Welche offenen Paket-Updates schließen eine Sicherheitslücke? + • Debian und Ubuntu (Gäste und Proxmox-Host) liefern sie aus eigenen Suiten („trixie-security“); das zählt der + Ausführer selbst (os_updates.sicherheit, host.updates_sicherheit). + • Alpine (NPMplus) kennt keine solche Suite. Dafür führt Alpine eine Sicherheitsdatenbank (secdb.alpinelinux.org, + je Zweig main und community): je Paket die Versionen, die Lücken schließen. Ein Update zählt, wenn eine davon + nach der installierten und höchstens bei der neuen Version liegt. Die Datenbank lädt dieser Teil im Hintergrund + und merkt sie zwölf Stunden (/alpine-secdb-v.json); bis sie da ist, heißt es „unbekannt“. +Docker-Images prüft Arcane selbst (Trivy, sonntags 02:00): docker.py holt dessen Befunde je Image. +""" + +import json +import logging +import re +import threading +import time +from pathlib import Path + +import httpx +from kern.einstellungen import einstellungen + +log = logging.getLogger(__name__) + +SECDB = "https://secdb.alpinelinux.org/v{zweig}/{repo}.json" +NEU_LADEN_S = 12 * 3600 +_SUFFIX = {"alpha": -4, "beta": -3, "pre": -2, "rc": -1, "cvs": 1, "svn": 1, "git": 1, "hg": 1, "p": 2} +_lock = threading.Lock() +_laedt: set[str] = set() +_gemerkt: dict[str, tuple[float, dict]] = {} + + +def apk_teile(version: str) -> tuple: + """Eine Alpine-Version („1.37.0-r18“, „3.3.2_rc1-r0“, „1.2a-r1“) als vergleichbares Tupel: Zahlen (Buchstaben + als kleine Zahlen), Zusätze (_alpha < _beta < _pre < _rc < ohne < _p), zuletzt die Paketrevision -rN.""" + haupt, _, revision = version.partition("-r") + kern, *zusaetze = haupt.split("_") + teile = tuple(int(x) if x.isdigit() else ord(x[0]) - 96 for x in re.findall(r"\d+|[a-z]+", kern.lower())) + suffixe = [] + for z in zusaetze: + m = re.fullmatch(r"([a-z]+)(\d*)", z) + suffixe.append((_SUFFIX.get(m.group(1), 0), int(m.group(2) or 0)) if m else (0, 0)) + return (teile, tuple(suffixe) or ((0, 0),), int(revision) if revision.isdigit() else 0) + + +def _pfad(zweig: str) -> Path: + return einstellungen().daten_dir / f"alpine-secdb-v{zweig}.json" + + +def _laden(zweig: str) -> None: + """Beide Repos des Zweigs holen und als {paket: [Versionen mit Fixes]} ablegen (eigener Faden).""" + try: + fixes: dict[str, list[str]] = {} + for repo in ("main", "community"): + r = httpx.get(SECDB.format(zweig=zweig, repo=repo), timeout=30, follow_redirects=True) + r.raise_for_status() + for eintrag in r.json().get("packages") or []: + pkg = (eintrag or {}).get("pkg") or {} + if pkg.get("name") and isinstance(pkg.get("secfixes"), dict): + fixes.setdefault(pkg["name"], []).extend(v for v in pkg["secfixes"] if v != "0") + pfad = _pfad(zweig) + pfad.parent.mkdir(parents=True, exist_ok=True) + tmp = pfad.with_suffix(".tmp") + tmp.write_text(json.dumps({"geholt": time.time(), "fixes": fixes}), encoding="utf-8") + tmp.replace(pfad) + log.info("sicherheit: Alpine-Sicherheitsdatenbank v%s geladen (%d Pakete)", zweig, len(fixes)) + except Exception: + log.warning("sicherheit: Alpine-Sicherheitsdatenbank v%s nicht geladen", zweig, exc_info=True) + finally: + with _lock: + _laedt.discard(zweig) + + +def secdb(zweig: str, jetzt: float | None = None) -> dict | None: + """{paket: [Versionen mit Fixes]} — aus der Datei, im Speicher gemerkt; ist sie alt oder fehlt, lädt ein Faden + sie nach (bis dahin die alte oder None). Blockiert nie.""" + jetzt = time.time() if jetzt is None else jetzt + pfad = _pfad(zweig) + daten = None + try: + mtime = pfad.stat().st_mtime + with _lock: + alt = _gemerkt.get(zweig) + if alt and alt[0] == mtime: + daten = alt[1] + else: + daten = json.loads(pfad.read_text(encoding="utf-8")) + with _lock: + _gemerkt[zweig] = (mtime, daten) + except (OSError, ValueError): + daten = None + if daten is None or jetzt - float(daten.get("geholt") or 0) > NEU_LADEN_S: + with _lock: + starten = zweig not in _laedt + _laedt.add(zweig) + if starten: + threading.Thread(target=_laden, args=(zweig,), name=f"secdb-{zweig}", daemon=True).start() + return daten.get("fixes") if daten else None + + +def alpine_sicherheit(os_stand: dict, fixes: dict | None = None) -> int | None: + """Wie viele der offenen Alpine-Updates eine Lücke schließen (None, solange die Datenbank fehlt).""" + pakete = os_stand.get("pakete") + if not isinstance(pakete, list): + return None + if not pakete: + return 0 + if fixes is None: + zweig = ".".join(str(os_stand.get("alpine") or "").split(".")[:2]) + if not re.fullmatch(r"\d+\.\d+", zweig): + return None + fixes = secdb(zweig) + if fixes is None: + return None + anzahl = 0 + for p in pakete: + alt, neu = apk_teile(str(p.get("alt") or "")), apk_teile(str(p.get("neu") or "")) + if any(alt < apk_teile(v) <= neu for v in fixes.get(str(p.get("name")), [])): + anzahl += 1 + return anzahl diff --git a/backend/tests/test_sicherheit.py b/backend/tests/test_sicherheit.py new file mode 100644 index 0000000..1a06c58 --- /dev/null +++ b/backend/tests/test_sicherheit.py @@ -0,0 +1,127 @@ +"""Sicherheits-Markierung (Ausbauplan Welle 2, Punkt 11, 25.09.2026): Debian-Suiten „-security“ (Ausführer), Alpine +über die Sicherheitsdatenbank (NPMplus, Alpine 3.24), Lücken-Scans aus Arcane je Container.""" + +import importlib.util +import json +import time +from pathlib import Path + +import pytest +from kern import einstellungen as einstellungen_mod +from services.homelab import arcane, docker, einstellungen, sicherheit + +AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py" + + +@pytest.fixture(scope="module") +def a(): + spec = importlib.util.spec_from_file_location("ausfuehrer_sicherheit", AUSFUEHRER) + modul = importlib.util.module_from_spec(spec) + spec.loader.exec_module(modul) + return modul + + +def test_alpine_versionen_vergleichen(): + t = sicherheit.apk_teile + assert t("1.37.0-r18") < t("1.37.0-r19") < t("1.37.1-r0") + assert t("3.3.2_rc1-r0") < t("3.3.2-r0") < t("3.3.2_p1-r0") + assert t("1.2-r0") < t("1.2a-r0") and t("2.042-r0") > t("2.41-r9") + + +def test_alpine_sicherheitsupdates_zaehlen(): + fixes = {"busybox": ["1.37.0-r19", "1.36.1-r2"], "py3-requests": ["2.32.0-r0"]} + stand = {"alpine": "3.24.2", "pakete": [ + {"name": "busybox", "alt": "1.37.0-r18", "neu": "1.37.0-r19"}, # schließt eine Lücke + {"name": "py3-requests", "alt": "2.32.3-r0", "neu": "2.32.4-r0"}, # Fix war schon älter + {"name": "curl", "alt": "8.14.1-r0", "neu": "8.14.1-r1"}]} # ohne Eintrag + assert sicherheit.alpine_sicherheit(stand, fixes) == 1 + assert sicherheit.alpine_sicherheit({"pakete": []}) == 0 + assert sicherheit.alpine_sicherheit({"anzahl": 3}) is None # Debian-Stand: nicht hier + + +@pytest.fixture +def daten(tmp_path, monkeypatch): + monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path)) + einstellungen_mod.einstellungen.cache_clear() + sicherheit._gemerkt.clear() + yield tmp_path + einstellungen_mod.einstellungen.cache_clear() + sicherheit._gemerkt.clear() + + +def test_datenbank_laedt_im_hintergrund_und_blockiert_nie(daten, monkeypatch): + geladen: list[str] = [] + monkeypatch.setattr(sicherheit, "_laden", lambda zweig: geladen.append(zweig) or sicherheit._laedt.discard(zweig)) + monkeypatch.setattr(sicherheit.threading, "Thread", lambda target, args, name, daemon: type( + "T", (), {"start": lambda self: target(*args)})()) + assert sicherheit.secdb("3.24") is None and geladen == ["3.24"] # fehlt: nachladen, bis dahin None + (daten / "alpine-secdb-v3.24.json").write_text(json.dumps({"geholt": time.time(), "fixes": {"busybox": ["1-r1"]}}), + encoding="utf-8") + assert sicherheit.secdb("3.24") == {"busybox": ["1-r1"]} and geladen == ["3.24"] + alt = time.time() - sicherheit.NEU_LADEN_S - 60 + (daten / "alpine-secdb-v3.24.json").write_text(json.dumps({"geholt": alt, "fixes": {"x": []}}), encoding="utf-8") + assert sicherheit.secdb("3.24") == {"x": []} and geladen == ["3.24", "3.24"] # alt: liefern und nachladen + + +# --- Ausführer --------------------------------------------------------------------------------------------------- + +def test_ausfuehrer_zaehlt_sicherheitsupdates(a, monkeypatch): + monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: (0, "12\n3\n1790329178\n")) + assert a._os_updates(104, "debian") == {"anzahl": 12, "sicherheit": 3, "listen_stand": 1790329178} + monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: ( + 0, "3.24.2\n1790328932\nbusybox-1.37.0-r18 < 1.37.0-r19\n")) + alpine = a._os_updates(101, "alpine") + assert (alpine["anzahl"], alpine["alpine"], alpine["pakete"]) == ( + 1, "3.24.2", [{"name": "busybox", "alt": "1.37.0-r18", "neu": "1.37.0-r19"}]) + liste = ("Listing...\ncurl/stable-security,stable 8.14.1-2+deb13u1 amd64 [upgradable from: 8.14.1-2]\n" + "pve-manager/stable 9.2.20 all [upgradable from: 9.2.19]\n") + assert a.sicherheit_auf_dem_host(liste) == ["curl"] + + +# --- Homelab-Teil ------------------------------------------------------------------------------------------------ + +def test_bausteine_tragen_die_markierung(monkeypatch): + from services.homelab import inventar + gast = {"vmid": 104, "art": "lxc", "os_updates": {"anzahl": 12, "sicherheit": 3, "listen_stand": time.time()}, + "snapshot_moeglich": True} + stand = inventar._os_baustein(gast, "ct-104", time.time()) + assert (stand.sicherheit, stand.kurz) == (3, "12 Pakete") and "(davon 3 Sicherheitsupdates)" in stand.aktion.frage + host = inventar._host_ziel({"version": "9.2.19", "updates": [{"paket": "curl"}, {"paket": "vim"}], + "updates_sicherheit": ["curl"]}) + assert host.bausteine[0].sicherheit == 1 + ohne = inventar._host_ziel({"version": "9.2.19", "updates": [{"paket": "vim"}]}) + assert ohne.bausteine[0].sicherheit is None # älterer Ausführer: unbekannt + + +def test_docker_mit_luecken_aus_arcane(monkeypatch): + monkeypatch.setattr(einstellungen, "arcane_url", lambda: "http://arcane") + monkeypatch.setattr(arcane, "schluessel", lambda: "schluessel") + monkeypatch.setattr(arcane, "container_liste", lambda url: [ + {"id": "a1", "names": ["/nerdquiz"], "imageId": "sha256:q", "state": "running", "labels": {}}, + {"id": "a2", "names": ["/arcane"], "imageId": "sha256:a", "state": "running", "labels": {}}]) + gefragt: list[tuple] = [] + monkeypatch.setattr(arcane, "luecken", lambda url, ids: gefragt.append(ids) or { + "sha256:q": {"kritisch": 2, "hoch": 5, "mittel": 9, "gesamt": 30, "zeit": "2026-09-27T02:04:00Z"}}) + zeilen = {c["name"]: c for s in docker.lage()["stacks"] for c in s["container"]} + assert gefragt == [("sha256:a", "sha256:q")] + assert zeilen["nerdquiz"]["luecken"]["kritisch"] == 2 and zeilen["arcane"]["luecken"] is None + + +def test_arcane_luecken_auslesen(monkeypatch): + monkeypatch.setattr(arcane, "schluessel", lambda: "schluessel") + monkeypatch.setattr(arcane, "umgebungen", lambda url, s=None: [{"id": 0}]) + arcane.cache_leeren() + + class Antwort: + def raise_for_status(self): + pass + + def json(self): + return {"data": {"summaries": { + "sha256:q": {"scanTime": "2026-09-27T02:04:00Z", "summary": {"critical": 2, "high": 5, "total": 30}}, + "sha256:n": {"status": "not_scanned", "summary": {}}}}} + + monkeypatch.setattr(arcane.httpx, "post", lambda url, headers, json, timeout: Antwort()) + assert arcane.luecken("http://arcane", ("sha256:n", "sha256:q")) == { + "sha256:q": {"kritisch": 2, "hoch": 5, "mittel": 0, "gesamt": 30, "zeit": "2026-09-27T02:04:00Z"}} + assert arcane.luecken("http://arcane", ()) == {} diff --git a/deploy/homelab/ausfuehrer.py b/deploy/homelab/ausfuehrer.py index 5d4c837..de1a6a7 100644 --- a/deploy/homelab/ausfuehrer.py +++ b/deploy/homelab/ausfuehrer.py @@ -320,21 +320,54 @@ def _app_version(vmid: int, kennung: str) -> str | None: return m.group(1) if m else zeile[:40] +# Sicherheitsupdates (seit 25.09.2026, Ausbauplan Welle 2, Punkt 11): Debian und Ubuntu liefern sie aus eigenen Suiten +# („trixie-security“, „jammy-security“) — das steht in `apt list --upgradable` hinter dem Paketnamen. Alpine hat das +# nicht; dort geht die Liste mit den Versionen mit, und der Homelab-Teil gleicht sie mit Alpines Sicherheitsdatenbank ab. +APT_ZAEHLEN = ("LC_ALL=C apt list --upgradable 2>/dev/null" + " | awk '/upgradable/{n++; if ($1 ~ /-security/) s++} END{print n+0; print s+0}'; " + "stat -c %Y /var/lib/apt/lists 2>/dev/null || echo 0") +APK_LISTE = ("cat /etc/alpine-release 2>/dev/null || echo ?; stat -c %Y /var/cache/apk 2>/dev/null || echo 0; " + "apk version -l '<' 2>/dev/null | tail -n +2") +_APK_ZEILE = re.compile(r"^(\S+?)-(\d\S*)\s+<\s+(\S+)$") + + +def apk_auswerten(text: str) -> dict: + """Ausgabe von APK_LISTE → {"anzahl", "listen_stand", "alpine", "pakete": [{"name", "alt", "neu"}]}.""" + zeilen = text.splitlines() + pakete = [{"name": m.group(1), "alt": m.group(2), "neu": m.group(3)} + for z in zeilen[2:] if (m := _APK_ZEILE.match(z.strip()))] + try: + stand = int(zeilen[1].strip()) or None + except (IndexError, ValueError): + stand = None + return {"anzahl": len(pakete), "listen_stand": stand, "alpine": zeilen[0].strip() if zeilen else None, + "pakete": pakete[:300]} + + def _os_updates(vmid: int, ostype: str) -> dict: - """Aktualisierbare Pakete nach den vorhandenen Paketlisten (liest nur) und wie alt die Listen sind.""" + """Aktualisierbare Pakete nach den vorhandenen Paketlisten (liest nur), wie alt die Listen sind und — seit + 25.09.2026 — wie viele davon Sicherheitsupdates sind (Alpine: die Liste für den Abgleich im Homelab-Teil).""" if ostype == "alpine": - code, text = _im_gast(vmid, "apk version -l '<' 2>/dev/null | tail -n +2 | wc -l; " - "stat -c %Y /var/cache/apk 2>/dev/null || echo 0", 30) - else: - code, text = _im_gast(vmid, "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable; " - "stat -c %Y /var/lib/apt/lists 2>/dev/null || echo 0", 30) + code, text = _im_gast(vmid, APK_LISTE, 30) + return apk_auswerten(text) if code in (0, 1) else {"anzahl": None, "listen_stand": None, "fehler": text[:160]} + code, text = _im_gast(vmid, APT_ZAEHLEN, 30) zeilen = text.split() if code in (0, 1) else [] try: - return {"anzahl": int(zeilen[0]), "listen_stand": int(zeilen[1]) or None} + return {"anzahl": int(zeilen[0]), "sicherheit": int(zeilen[1]), "listen_stand": int(zeilen[2]) or None} except (IndexError, ValueError): return {"anzahl": None, "listen_stand": None, "fehler": text[:160]} +def sicherheit_auf_dem_host(apt_liste: str) -> list[str]: + """Die Pakete aus `apt list --upgradable`, die aus einer Sicherheits-Suite kommen.""" + namen = [] + for zeile in apt_liste.splitlines(): + kopf = zeile.split(" ", 1)[0] + if "upgradable" in zeile and "/" in kopf and "-security" in kopf.split("/", 1)[1]: + namen.append(kopf.split("/", 1)[0]) + return namen + + def _ip_lxc(vmid: int) -> str | None: try: for schnitt in _pvesh(f"/nodes/{NODE}/lxc/{vmid}/interfaces"): @@ -834,6 +867,9 @@ def _host() -> dict: host["version"] = _pvesh("/version").get("version") host["updates"] = [{"paket": p.get("Package"), "alt": p.get("OldVersion"), "neu": p.get("Version"), "herkunft": p.get("Origin")} for p in _pvesh(f"/nodes/{NODE}/apt/update")] + code, liste = _laufen(["env", "LC_ALL=C", "apt", "list", "--upgradable"], 60) + if code == 0: + host["updates_sicherheit"] = sicherheit_auf_dem_host(liste) except Exception as exc: host["fehler"] = str(exc)[:200] code, kernel = _laufen(["uname", "-r"], 5) diff --git a/frontend/src/components/homelab/DockerDienste.tsx b/frontend/src/components/homelab/DockerDienste.tsx index fefffa6..484c564 100644 --- a/frontend/src/components/homelab/DockerDienste.tsx +++ b/frontend/src/components/homelab/DockerDienste.tsx @@ -31,14 +31,26 @@ function zustandText(c: DockerContainer): string { /** Aus „ghcr.io/getarcaneapp/manager:latest“ wird „manager“ — für das Logo. */ const imageName = (image: string | null) => (image ?? "").split("/").at(-1)?.split(":")[0] ?? "" +/** „2 kritische, 5 hohe Lücken“ aus Arcanes Scan (seit 25.09.2026) — nur, wenn es kritische oder hohe gibt. */ +function lueckenText(c: DockerContainer): string | null { + const l = c.luecken + if (!l || (!l.kritisch && !l.hoch)) return null + const teile = [l.kritisch ? `${l.kritisch} kritische` : null, l.hoch ? `${l.hoch} hohe` : null].filter(Boolean) + return `${teile.join(", ")} Lücken` +} + function Zeile({ c }: { c: DockerContainer }) { const u = UPDATE[c.update] ?? UPDATE.unbekannt + const luecken = lueckenText(c) return (
  • {c.name} {c.image} + {luecken && ( + {luecken} + )} {zustandText(c)} diff --git a/frontend/src/components/homelab/UpdateTabelle.tsx b/frontend/src/components/homelab/UpdateTabelle.tsx index 019cd78..0230717 100644 --- a/frontend/src/components/homelab/UpdateTabelle.tsx +++ b/frontend/src/components/homelab/UpdateTabelle.tsx @@ -42,7 +42,14 @@ function PaketZelle({ b, onSuchen, gesperrt }: { b: BausteinStand | null; onSuch if (!b) return – const docker = b.id === "docker" let text - if (b.zustand === "neu") text = {b.kurz || "Update bereit"} + if (b.zustand === "neu") { + text = ( + + {b.kurz || "Update bereit"} + {!!b.sicherheit && · {b.sicherheit} Sicherheit} + + ) + } else if (b.zustand === "aktuell") text = {docker ? "Images aktuell" : "aktuell"} else { const datum = listenAlter(b.grund) diff --git a/frontend/src/lib/homelab.test.ts b/frontend/src/lib/homelab.test.ts index d549341..833d173 100644 --- a/frontend/src/lib/homelab.test.ts +++ b/frontend/src/lib/homelab.test.ts @@ -1,4 +1,4 @@ -import { kennzahlText, lage, lampeFuer, listenAlter, offeneUpdates, rueckwegText } from "@/lib/homelab" +import { kennzahlText, lage, lampeFuer, listenAlter, offeneUpdates, rueckwegText, sicherheitText } from "@/lib/homelab" import type { BausteinStand, Hinweis, HomelabStand, ZielStand } from "@/lib/typen" const baustein = (b: Partial & Pick): BausteinStand => ({ @@ -110,3 +110,12 @@ describe("kennzahlText (seit 25.09.2026)", () => { expect(kennzahlText(undefined)).toBeNull() }) }) + +describe("Sicherheits-Markierung (seit 25.09.2026)", () => { + it("hängt die Zahl der Sicherheitsupdates an", () => { + const pakete = baustein({ id: "os", zustand: "neu", kurz: "12 Pakete", sicherheit: 3 }) + expect(sicherheitText(pakete)).toBe(" · 3 Sicherheit") + expect(lampeFuer(ziel({ id: "ct-104", name: "Gitea", bausteine: [pakete] }), false).wert).toBe("12 Pakete · 3 Sicherheit") + expect(sicherheitText(baustein({ id: "os", zustand: "neu", kurz: "2 Pakete", sicherheit: 0 }))).toBe("") + }) +}) diff --git a/frontend/src/lib/homelab.ts b/frontend/src/lib/homelab.ts index 4b0cb94..ff66b32 100644 --- a/frontend/src/lib/homelab.ts +++ b/frontend/src/lib/homelab.ts @@ -95,7 +95,12 @@ export const versionText = (v: string) => v.replace(/^Image vom /, "") /** Was die Versionsspalte „Neu“ zeigt: die neue Version einer App, sonst „58 Pakete“. */ export function neuText(b: BausteinStand): string { if (istApp(b) && b.verfuegbar) return b.verfuegbar - return b.kurz || b.verfuegbar || "neu" + return `${b.kurz || b.verfuegbar || "neu"}${sicherheitText(b)}` +} + +/** „ · 3 Sicherheit“ — wenn Updates eine Sicherheitslücke schließen (seit 25.09.2026), sonst leer. */ +export function sicherheitText(b: BausteinStand): string { + return b.sicherheit ? ` · ${b.sicherheit} Sicherheit` : "" } /** Anteil der belegten Platte (0…1) oder null, wo unbekannt. Ab 80 % gelb, ab 90 % rot — wie der Wächter (ext4 hält 5 % zurück). */ diff --git a/frontend/src/lib/typen.ts b/frontend/src/lib/typen.ts index 794dbf0..47e87b2 100644 --- a/frontend/src/lib/typen.ts +++ b/frontend/src/lib/typen.ts @@ -306,6 +306,8 @@ export interface BausteinStand { kurz: string grund: string | null aktion: ZielAktion | null + /** Davon Sicherheitsupdates (seit 25.09.2026; null/fehlt = unbekannt). */ + sicherheit?: number | null } /** Ein Gerät (KI-Box, Proxmox-Host, Container, VM) mit seinen Bausteinen. */ @@ -697,6 +699,8 @@ export interface DockerContainer { update: "neu" | "lokal" | "aktuell" | "unbekannt" aktuell: string | null neu: string | null + /** Arcanes Lücken-Scan des Images (seit 25.09.2026); null = noch nicht gescannt. */ + luecken?: { kritisch: number; hoch: number; mittel: number; gesamt: number; zeit: string } | null } export interface DockerLage { diff --git a/frontend/src/lib/uebersicht.ts b/frontend/src/lib/uebersicht.ts index 89093f6..533070a 100644 --- a/frontend/src/lib/uebersicht.ts +++ b/frontend/src/lib/uebersicht.ts @@ -1,4 +1,4 @@ -import { systemBaustein, appBaustein } from "@/lib/homelab" +import { appBaustein, sicherheitText, systemBaustein } from "@/lib/homelab" import type { BausteinStand, ZielStand } from "@/lib/typen" // Die Kurzform für die Übersicht: wer hat wo welche Updates — in einer Zeile je Gerät. @@ -32,7 +32,7 @@ export function kurzform(ziel: ZielStand): KurzZeile | null { teile.push(`App → ${app.verfuegbar ?? "neu"}${w ? ` (${w})` : ""}`) } const sys = systemBaustein(ziel) - if (sys?.zustand === "neu") teile.push(sys.kurz || (sys.id === "docker" ? "Images" : "Pakete")) + if (sys?.zustand === "neu") teile.push(`${sys.kurz || (sys.id === "docker" ? "Images" : "Pakete")}${sicherheitText(sys)}`) // KI-Box: Bausteine heißen selbst (Betriebssystem, Hermes, Motor …). for (const b of neu) { if (b === app || b === sys) continue