Welle 2 · Sicherheits-Markierung: Debian-Sicherheitsupdates, Alpine über secdb, Lücken-Scans aus Arcane

Der Ausführer zählt je Gast, wie viele offene Updates aus einer Sicherheits-Suite kommen („-security“ in apt list),
beim Proxmox-Host ebenso; für Alpine (NPMplus) schickt er die Paketliste, und der Homelab-Teil gleicht sie mit Alpines
Sicherheitsdatenbank ab (im Hintergrund geladen, zwölf Stunden gemerkt, Alpine-Versionsvergleich). Die Update-Tabelle,
die Kacheln und die Übersicht zeigen „· 3 Sicherheit“, die Rückfrage nennt es. Je Docker-Container steht, was Arcanes
Lücken-Scan (Trivy, sonntags 02:00) im Image fand, kritische und hohe. Der Ausführer braucht ausfuehrer-einrichten.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 18:02:33 +02:00
co-authored by Claude Opus 5.5
parent 995a0f34ee
commit 1c66daad0b
13 changed files with 375 additions and 17 deletions
+1
View File
@@ -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
+26
View File
@@ -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."""
+8 -3
View File
@@ -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())]}
+8 -2
View File
@@ -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.")
+120
View File
@@ -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 (<Datenordner>/alpine-secdb-v<zweig>.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
+127
View File
@@ -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", ()) == {}
+43 -7
View File
@@ -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)
@@ -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 (
<li className="flex min-w-0 items-center gap-3 border-t border-white/[0.06] py-2.5 first:border-t-0">
<Geraetesymbol bild={geraetebild({ art: "container", name: c.dienst ?? imageName(c.image) })} groesse="sm" punkt={punkt(c)} />
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[14.5px] font-medium">{c.name}</span>
<span className="truncate text-[12.5px] text-text-3">{c.image}</span>
{luecken && (
<span className={cn("truncate text-[12.5px]", c.luecken?.kritisch ? "text-rot-text" : "text-bernstein")}>{luecken}</span>
)}
</span>
<span className="flex shrink-0 flex-col items-end text-right">
<span className={cn("text-[13px]", punkt(c) === "rot" ? "text-rot-text" : "text-text-2")}>{zustandText(c)}</span>
@@ -42,7 +42,14 @@ function PaketZelle({ b, onSuchen, gesperrt }: { b: BausteinStand | null; onSuch
if (!b) return <span className="text-sm text-text-3">–</span>
const docker = b.id === "docker"
let text
if (b.zustand === "neu") text = <span className="text-cyan-text">{b.kurz || "Update bereit"}</span>
if (b.zustand === "neu") {
text = (
<span className="text-cyan-text">
{b.kurz || "Update bereit"}
{!!b.sicherheit && <span className="text-rot-text"> · {b.sicherheit} Sicherheit</span>}
</span>
)
}
else if (b.zustand === "aktuell") text = <span className="text-gruen-text">{docker ? "Images aktuell" : "aktuell"}</span>
else {
const datum = listenAlter(b.grund)
+10 -1
View File
@@ -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<BausteinStand> & Pick<BausteinStand, "id" | "zustand">): 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("")
})
})
+6 -1
View File
@@ -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). */
+4
View File
@@ -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 {
+2 -2
View File
@@ -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