Files
mission-control-v2/backend/services/homelab/sicherheit.py
T
HitonabiandClaude Opus 5.5 9da666d04e Welle 3 · Wartungsfenster und Update-Regel je Gerät (So 03:00–04:15, Start: Sicherheitsupdates automatisch)
Der Steward des Homelab-Teils prüft jede Minute, ob das Fenster offen ist, und arbeitet dann einmal je Tag den Plan ab:
je Gerät nach seiner Regel — nur melden, Sicherheitsupdates automatisch (Standard, User-Entscheid) oder alles im
Fenster —, mit den bekannten Update-Läufen (Rückweg vorher, Prüfung danach, bei Rot zurück). Nach dem Fenster beginnt
kein Schritt mehr, Erfolge kommen als eine Meldung am Ende, Fehler sofort; der Proxmox-Host startet nie von selbst neu.
Der Ausführer meldet dafür die Namen der Sicherheitspakete und spielt auf Wunsch nur diese ein (Gäste und Host);
neuer Baustein „sicherheit“. Dazu „Version überspringen“ je App, die Einstellungen unter Einstellungen → Homelab und
„Wartung“ statt „Störung“ im Cockpit, solange das Fenster läuft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:33:38 +02:00

127 lines
5.5 KiB
Python

"""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)."""
namen = alpine_pakete(os_stand, fixes)
return None if namen is None else len(namen)
def alpine_pakete(os_stand: dict, fixes: dict | None = None) -> list[str] | None:
"""Die offenen Alpine-Updates, die eine Lücke schließen (Namen; None, solange die Datenbank fehlt)."""
pakete = os_stand.get("pakete")
if not isinstance(pakete, list):
return None
if not pakete:
return []
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
namen = []
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")), [])):
namen.append(str(p.get("name")))
return namen