Später · 18 Plattengesundheit: Proxmox-Host, NAS über SNMP, NVMe der KI-Box

- kern/platten.py: SMART aus pvesh, smartctl und SNMP in einer Form; rot bei SMART FAILED, kritischer Warnung,
  aufgebrauchter Reserve, ≥ 90 % verbraucht; gelb bei ≥ 80 %, Hitze, gewachsenen Fehlerzählern (Ausgangslage je
  Platte, „Zur Kenntnis genommen“ setzt sie neu)
- Ausführer: stündlich disks/list + disks/smart im Bericht (nur lesend)
- services/homelab/nas_snmp.py: kleiner SNMPv2c-GetBulk-Leser ohne neue Abhängigkeit, QNAP hdTable/diskTable,
  Zugang 0600, Einstellungen „Verbindung testen und speichern“
- services/platten.py: NVMe der KI-Box über sudo smartctl (smartmontools am 26.09. nachinstalliert)
- Wächter beider Rollen, Plattenfeld in beiden Cockpits, Doku

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-26 09:54:38 +02:00
co-authored by Claude Opus 5.5
parent 5325544d89
commit 904598031a
46 changed files with 1212 additions and 41 deletions
+4 -1
View File
@@ -17,7 +17,7 @@ import logging
import re
import time
from services.homelab import adguard, apps, arcane, inventar, kanal, karenz
from services.homelab import adguard, apps, arcane, inventar, kanal, karenz, nas_snmp
log = logging.getLogger(__name__)
@@ -46,6 +46,9 @@ def stand() -> dict:
"echt_quelle": arcane.echt_quelle()},
"adguard": {"url": adguard.url() or "", "zugang": "hinterlegt" if (z := adguard.zugang()) else "fehlt",
"benutzer": z[0] if z else None},
# Seit 26.09.2026: SNMP-Zugang des NAS für die Plattengesundheit — nie der Zugangsname selbst.
"nas_snmp": {"zugang": "hinterlegt" if (n := nas_snmp.zugang()) else "fehlt",
"adresse": (n or {}).get("adresse") or nas_snmp.nas_adresse()},
"karenz_h": round(karenz.karenz_s() / 3600),
"ausfuehrer": {"verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
"zuletzt": zuletzt, "nur_lesen": bool(host.get("nur_lesen")),
+325
View File
@@ -0,0 +1,325 @@
"""Die Platten des NAS über SNMP (Ausbauplan „Später“, Punkt 18, seit 26.09.2026).
User-Entscheid 25.09.2026: Der User schaltet am QNAP SNMP v1/v2c ein (Systemsteuerung → Netzwerk- und Dateidienste →
SNMP) und trägt den Zugangsnamen („Community“) unter Einstellungen → Homelab ein. Vor dem Speichern fragt dieser Teil
das NAS einmal damit; antwortet es nicht, wird nichts gespeichert. Der Zugangsname liegt in <Datenordner>/nas-snmp.json
(0600) und erscheint nie wieder in einer Antwort.
Ein kleiner SNMPv2c-Leser (GetBulk über UDP 161) statt einer neuen Abhängigkeit. Gelesen wird die Plattentabelle der
QNAP-MIB: zuerst die ältere (NAS-MIB, hdTable — die meisten QTS-Versionen liefern sie noch), sonst die neuere
(QTS-MIB, diskTable), deren Spalten nach Inhalt erkannt werden. Ein falscher Zugangsname fällt bei SNMP v2c nur als
Schweigen auf: Das NAS antwortet dann gar nicht.
"""
import json
import os
import random
import re
import socket
import threading
import time
from pathlib import Path
from kern.einstellungen import einstellungen
from services.homelab import kanal
PORT = 161
ZEITLIMIT_S = 3.0
CACHE_S = 600
SYS_DESCR = "1.3.6.1.2.1.1.1"
HD_TABELLE = "1.3.6.1.4.1.24681.1.2.11.1" # NAS-MIB hdTable: 2 Name, 3 Temperatur, 4 Status, 5 Modell, 7 SMART
DISK_TABELLE = "1.3.6.1.4.1.24681.1.4.1.1.1.1.5.2.1" # QTS-MIB diskTable (neuere Firmware)
GESUND = {"good", "normal", "ok"}
KRANK = {"abnormal", "bad", "error"}
_lock = threading.Lock()
_cache: dict[str, tuple[float, dict]] = {}
class SnmpFehler(Exception):
pass
# --- BER: gerade genug für GetBulk und die Antwort -----------------------------------------------------------------
def _laenge(n: int) -> bytes:
if n < 0x80:
return bytes([n])
b = n.to_bytes((n.bit_length() + 7) // 8, "big")
return bytes([0x80 | len(b)]) + b
def _tlv(tag: int, inhalt: bytes) -> bytes:
return bytes([tag]) + _laenge(len(inhalt)) + inhalt
def _int(n: int) -> bytes:
return _tlv(0x02, n.to_bytes(max(1, (n.bit_length() + 8) // 8), "big", signed=True))
def _oid(oid: str) -> bytes:
teile = [int(t) for t in oid.strip(".").split(".")]
inhalt = bytearray([40 * teile[0] + teile[1]])
for t in teile[2:]:
stuecke = [t & 0x7F]
t >>= 7
while t:
stuecke.insert(0, 0x80 | (t & 0x7F))
t >>= 7
inhalt += bytes(stuecke)
return _tlv(0x06, bytes(inhalt))
def anfrage(community: str, oid: str, anfrage_id: int, wiederholungen: int = 25) -> bytes:
"""Eine SNMPv2c-GetBulk-Anfrage ab oid."""
bindung = _tlv(0x30, _oid(oid) + b"\x05\x00")
pdu = _tlv(0xA5, _int(anfrage_id) + _int(0) + _int(wiederholungen) + _tlv(0x30, bindung))
return _tlv(0x30, _int(1) + _tlv(0x04, community.encode("utf-8")) + pdu)
def _lies(daten: bytes, pos: int) -> tuple[int, bytes, int]:
tag = daten[pos]
laenge = daten[pos + 1]
pos += 2
if laenge & 0x80:
n = laenge & 0x7F
laenge = int.from_bytes(daten[pos:pos + n], "big")
pos += n
return tag, daten[pos:pos + laenge], pos + laenge
def _oid_text(b: bytes) -> str:
teile = [b[0] // 40, b[0] % 40]
wert = 0
for x in b[1:]:
wert = (wert << 7) | (x & 0x7F)
if not x & 0x80:
teile.append(wert)
wert = 0
return ".".join(str(t) for t in teile)
def _wert(tag: int, inhalt: bytes) -> object:
if tag == 0x02:
return int.from_bytes(inhalt, "big", signed=True)
if tag == 0x04:
return inhalt.decode("utf-8", "replace").strip("\x00").strip()
if tag in (0x41, 0x42, 0x43, 0x46): # Counter32, Gauge32, TimeTicks, Counter64
return int.from_bytes(inhalt, "big")
if tag == 0x06:
return _oid_text(inhalt)
if tag == 0x40:
return ".".join(str(b) for b in inhalt)
return None # NULL, noSuchObject, noSuchInstance, endOfMibView
def antwort(daten: bytes) -> tuple[int, int, list[tuple[str, object, int]]]:
"""(Anfrage-Nr., Fehlerstatus, [(OID, Wert, Typ)]) aus einer Response-PDU."""
try:
_, nachricht, _ = _lies(daten, 0)
_, _, pos = _lies(nachricht, 0) # Version
_, _, pos = _lies(nachricht, pos) # Community
tag, pdu, _ = _lies(nachricht, pos)
if tag != 0xA2:
raise SnmpFehler("keine Antwort-PDU")
_, nr, p = _lies(pdu, 0)
_, fehler, p = _lies(pdu, p)
_, _, p = _lies(pdu, p)
_, bindungen, _ = _lies(pdu, p)
werte, q = [], 0
while q < len(bindungen):
_, bindung, q = _lies(bindungen, q)
_, oid, r = _lies(bindung, 0)
wtag, winhalt, _ = _lies(bindung, r)
werte.append((_oid_text(oid), _wert(wtag, winhalt), wtag))
except (IndexError, ValueError) as exc:
raise SnmpFehler("unlesbare Antwort") from exc
return int.from_bytes(nr, "big", signed=True), int.from_bytes(fehler, "big"), werte
def walk(adresse: str, community: str, basis: str, zeitlimit: float | None = None, grenze: int = 2000,
port: int | None = None) -> list:
"""Alle (OID, Wert) unter basis. SnmpFehler bei Schweigen (falscher Zugangsname, SNMP aus) oder Fehlern."""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(zeitlimit or ZEITLIMIT_S)
ergebnis: list[tuple[str, object]] = []
oid, nr = basis, random.randint(1, 1_000_000)
try:
while len(ergebnis) < grenze:
nr += 1
sock.sendto(anfrage(community, oid, nr), (adresse, port or PORT))
try:
daten, _ = sock.recvfrom(65535)
except TimeoutError as exc:
raise SnmpFehler("keine Antwort") from exc
rnr, fehler, werte = antwort(daten)
if rnr != nr:
continue
if fehler:
raise SnmpFehler(f"Fehlerstatus {fehler}")
fertig = not werte
for o, w, tag in werte:
if tag == 0x82 or not o.startswith(basis + "."):
fertig = True
break
ergebnis.append((o, w))
if fertig:
break
oid = werte[-1][0]
except OSError as exc:
raise SnmpFehler(f"Netzfehler {exc.__class__.__name__}") from exc
finally:
sock.close()
return ergebnis
# --- Plattentabelle der QNAP-MIB ----------------------------------------------------------------------------------
def _spalten(werte: list, basis: str) -> dict[str, dict[int, object]]:
"""{Zeile: {Spalte: Wert}} aus OIDs der Form <basis>.<spalte>.<zeile>."""
zeilen: dict[str, dict[int, object]] = {}
for oid, wert in werte:
rest = oid[len(basis) + 1:].split(".")
if len(rest) >= 2 and rest[0].isdigit():
zeilen.setdefault(".".join(rest[1:]), {})[int(rest[0])] = wert
return zeilen
def platte(nr: str, name: object, modell: object, temperatur: object, smart: object, status: object = None) -> dict:
s = str(smart or "").strip().lower()
temp = re.search(r"-?\d+", str(temperatur)) if temperatur is not None else None
return {"id": f"nas:{nr}:{modell or name}", "name": str(modell or name or f"Platte {nr}").strip(),
"wo": f"NAS {name or nr}", "typ": "hdd", "groesse": None,
"gesund": True if s in GESUND else False if s in KRANK else None,
"smart_warnung": s == "warning", "smart": str(smart or "") or None,
"verbraucht": None, "reserve": None, "reserve_grenze": None, "warnung": 0,
"temperatur": int(temp.group(0)) if temp else None, "stunden": None,
"fehler": {}, "status": status}
def platten_aus_hd(werte: list) -> list[dict]:
"""NAS-MIB hdTable: 2 Name, 3 Temperatur („35 C/95 F“), 4 Status (0 = bereit), 5 Modell, 7 SMART („GOOD“)."""
ergebnis = []
for nr, z in sorted(_spalten(werte, HD_TABELLE).items()):
if z.get(4) == -5: # kein Laufwerk im Schacht
continue
ergebnis.append(platte(nr, z.get(2), z.get(5), z.get(3), z.get(7), z.get(4)))
return ergebnis
def platten_aus_disk(werte: list) -> list[dict]:
"""QTS-MIB diskTable, Spalten nach Inhalt: SMART-Wort, Temperatur (Zahl zwischen 5 und 90), längster Text = Modell."""
ergebnis = []
for nr, z in sorted(_spalten(werte, DISK_TABELLE).items()):
texte = {k: v for k, v in z.items() if isinstance(v, str) and v}
smart = next((v for v in texte.values() if v.strip().lower() in GESUND | KRANK | {"warning"}), None)
temperatur = next((v for k, v in z.items() if isinstance(v, int) and 5 <= v <= 90 and k > 3), None)
modell = max((v for v in texte.values() if v != smart), key=len, default=None)
ergebnis.append(platte(nr, f"Platte {nr}", modell, temperatur, smart))
return ergebnis
# --- Zugang, Abfrage, Oberfläche ---------------------------------------------------------------------------------
def _pfad() -> Path:
return einstellungen().daten_dir / "nas-snmp.json"
def zugang() -> dict | None:
"""{"community", "adresse"} oder None."""
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError, UnicodeDecodeError):
return None
if not isinstance(daten, dict) or not isinstance(daten.get("community"), str) or not daten["community"]:
return None
return {"community": daten["community"], "adresse": str(daten.get("adresse") or "") or None}
def zugang_speichern(community: str, adresse: str | None) -> None:
pfad = _pfad()
tmp = pfad.with_name(pfad.name + ".tmp")
with _lock:
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp.unlink(missing_ok=True)
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump({"community": community, "adresse": adresse}, f)
os.replace(tmp, pfad)
_cache.clear()
def zugang_loeschen() -> bool:
with _lock:
pfad = _pfad()
da = pfad.exists()
pfad.unlink(missing_ok=True)
_cache.clear()
return da
def nas_adresse() -> str | None:
"""Das NAS aus dem Bericht (die Einbindungen des Proxmox-Hosts), sonst None."""
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
for m in host.get("netzlaufwerke") or []:
if m.get("server"):
return str(m["server"])
return None
def lesen(adresse: str, community: str) -> dict:
"""{"ok", "system", "platten"} oder {"ok": False, "detail"} — ohne den Zugangsnamen."""
try:
system = walk(adresse, community, SYS_DESCR, grenze=1)
platten = platten_aus_hd(walk(adresse, community, HD_TABELLE))
if not platten:
platten = platten_aus_disk(walk(adresse, community, DISK_TABELLE))
except SnmpFehler as exc:
if str(exc) == "keine Antwort":
return {"ok": False, "detail": f"Das NAS ({adresse}) antwortet nicht über SNMP. Ist SNMP v1/v2c an, und "
"stimmt der Zugangsname (Community)?"}
return {"ok": False, "detail": f"SNMP-Abfrage an {adresse} gescheitert: {exc}."}
return {"ok": True, "system": str(system[0][1]) if system else None, "platten": platten}
def stand() -> dict:
"""Für Oberfläche und Wächter: höchstens alle CACHE_S neu gefragt. {"eingerichtet", "ok", "platten", "detail"}."""
z = zugang()
if not z:
return {"eingerichtet": False, "ok": False, "platten": [], "detail": None}
adresse = z["adresse"] or nas_adresse()
if not adresse:
return {"eingerichtet": True, "ok": False, "platten": [], "detail": "Die Adresse des NAS ist unbekannt."}
jetzt = time.time()
with _lock:
if (eintrag := _cache.get(adresse)) and jetzt - eintrag[0] < CACHE_S:
return eintrag[1]
ergebnis = {"eingerichtet": True, **lesen(adresse, z["community"])}
ergebnis.setdefault("platten", [])
with _lock:
_cache[adresse] = (jetzt, ergebnis)
return ergebnis
def setzen(community: str, adresse: str | None) -> dict:
"""POST /api/homelab/einstellungen/nas-snmp: erst das NAS fragen, dann speichern."""
community, adresse = (community or "").strip(), (adresse or "").strip() or None
if not community or len(community) > 200 or (adresse and not re.fullmatch(r"[\w.:-]{1,253}", adresse)):
return {"ok": False, "detail": "Zugangsname (Community) eintragen, die Adresse nur, wenn sie abweicht. Nichts "
"gespeichert."}
ziel = adresse or nas_adresse()
if not ziel:
return {"ok": False, "detail": "Die Adresse des NAS ist unbekannt — bitte mit eintragen. Nichts gespeichert."}
ergebnis = lesen(ziel, community)
if not ergebnis["ok"]:
return {"ok": False, "detail": ergebnis["detail"] + " Nichts gespeichert."}
try:
zugang_speichern(community, adresse)
except OSError as exc:
return {"ok": False, "detail": f"Das NAS antwortet, aber der Zugang ließ sich nicht speichern "
f"({exc.__class__.__name__})."}
platten = ergebnis["platten"]
gesund = sum(1 for p in platten if p["gesund"])
return {"ok": True, "text": f"Das NAS antwortet ({(ergebnis.get('system') or 'SNMP')[:80]}): "
+ (f"{len(platten)} Platten, davon {gesund} gesund." if platten
else "Die Plattentabelle ist leer — die Firmware nennt sie vielleicht anders.")}
+46
View File
@@ -0,0 +1,46 @@
"""Plattengesundheit im Homelab (Ausbauplan „Später“, Punkt 18, seit 26.09.2026): die Platten des Proxmox-Hosts
(Ausführer, stündlich im Bericht) und die des NAS (SNMP, services/homelab/nas_snmp.py), bewertet mit kern/platten.py.
Der Wächter meldet nur, was schlecht ist oder schlechter wird; „Zur Kenntnis genommen“ macht die heutigen
Fehlerzähler zur neuen Ausgangslage.
"""
from kern import platten as kern_platten
from services.homelab import kanal, nas_snmp
def host_platten() -> list[dict]:
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
return [kern_platten.aus_proxmox(e.get("disk") or {}, e.get("smart")) for e in host.get("platten") or []
if isinstance(e, dict) and isinstance(e.get("disk"), dict)]
def lage() -> dict:
"""Für GET /api/homelab/platten."""
nas = nas_snmp.stand()
platten = kern_platten.bewertet(host_platten() + list(nas.get("platten") or []))
return {"platten": platten, "nas": {"eingerichtet": nas["eingerichtet"], "ok": nas.get("ok", False),
"detail": nas.get("detail"), "system": nas.get("system")}}
def hinnehmen(pid: str) -> dict:
return kern_platten.hinnehmen(pid, host_platten() + list(nas_snmp.stand().get("platten") or []))
def pruefe_platten_gesundheit() -> list:
"""Für den Wächter (Rolle homelab)."""
from services.waechter import Befund, _aktion
befunde = []
for p in lage()["platten"]:
if p["stufe"] == "ok":
continue
fehler = any("→" in s for s in p["saetze"])
befunde.append(Befund(
id=f"platte:{p['id']}", stufe=p["stufe"], titel=kern_platten.befund_titel(p),
text="; ".join(p["saetze"]) + ". Die Daten darauf sichern und die Platte bald ersetzen, wenn es weiter "
"schlechter wird.",
quelle=p["id"].split(":")[0] if p["id"].startswith("nas:") else "pve",
aktionen=[_aktion("hinnehmen", "Zur Kenntnis genommen", platte=p["id"])] if fehler else [],
sofort=True))
return befunde
+89
View File
@@ -0,0 +1,89 @@
"""Plattengesundheit der KI-Box (Ausbauplan „Später“, Punkt 18, seit 26.09.2026): smartctl (Paket smartmontools,
seit 26.09.2026 auf der Box) je Platte aus lsblk, höchstens stündlich, bewertet mit kern/platten.py. smartctl braucht
root (sudo -n); fehlt es oder verweigert sudo, gibt es schlicht keine Werte — nie einen Absturz (Windows, Tests).
"""
import json
import logging
import shutil
import subprocess
import threading
import time
from kern import platten as kern_platten
log = logging.getLogger(__name__)
CACHE_S = 3600
_lock = threading.Lock()
_cache: dict = {"zeit": 0.0, "platten": None}
def _laufen(befehl: list[str], zeitlimit: int = 30) -> tuple[int, str]:
try:
r = subprocess.run(befehl, capture_output=True, text=True, timeout=zeitlimit)
except (OSError, subprocess.SubprocessError):
return 127, ""
return r.returncode, r.stdout
def geraete() -> list[str]:
code, aus = _laufen(["lsblk", "-J", "-d", "-o", "NAME,TYPE"], 10)
try:
return [f"/dev/{d['name']}" for d in json.loads(aus).get("blockdevices") or [] if d.get("type") == "disk"
and not str(d.get("name", "")).startswith(("zram", "loop"))] if code == 0 else []
except (ValueError, AttributeError):
return []
def lesen() -> list[dict]:
if not shutil.which("sudo"): # Windows, Tests ohne sudo: keine Werte
return []
ergebnis = []
for geraet in geraete():
_, aus = _laufen(["sudo", "-n", "smartctl", "-j", "-a", geraet])
try:
daten = json.loads(aus)
except ValueError:
continue
# smartctl setzt Bits im Exit-Code auch für „Fehler im Protokoll“ — gelesen ist gelesen.
if not isinstance(daten, dict) or not (daten.get("model_name") or daten.get("nvme_smart_health_information_log")):
continue
ergebnis.append(kern_platten.aus_smartctl(daten, geraet, "KI-Box"))
return ergebnis
def platten() -> list[dict]:
with _lock:
if _cache["platten"] is not None and time.time() - _cache["zeit"] < CACHE_S:
return _cache["platten"]
werte = lesen()
with _lock:
_cache.update(zeit=time.time(), platten=werte)
return werte
def lage() -> dict:
"""Für GET /api/platten."""
return {"platten": kern_platten.bewertet(platten())}
def hinnehmen(pid: str) -> dict:
return kern_platten.hinnehmen(pid, platten())
def pruefe_platten_gesundheit() -> list:
"""Für den Wächter (Rolle box)."""
from services.waechter import Befund, _aktion
befunde = []
for p in lage()["platten"]:
if p["stufe"] == "ok":
continue
fehler = any("→" in s for s in p["saetze"])
befunde.append(Befund(
id=f"platte:{p['id']}", stufe=p["stufe"], titel=kern_platten.befund_titel(p),
text="; ".join(p["saetze"]) + ". Wichtiges sichern; wird es schlechter, die Platte ersetzen.",
quelle="platte", aktionen=[_aktion("hinnehmen", "Zur Kenntnis genommen", platte=p["id"])] if fehler else [],
sofort=True))
return befunde
+9
View File
@@ -640,6 +640,9 @@ PRUEFUNGEN += (__import__("services.homelab.speicher").homelab.speicher.pruefe_s
PRUEFUNGEN += (__import__("services.homelab.zertifikate").homelab.zertifikate.pruefe_zertifikate,) if ROLLE == "homelab" else ()
# Docker-Dienste unter Arcane (seit 25.09.2026): gelb, wenn einer ungesund ist oder ständig neu startet.
PRUEFUNGEN += (__import__("services.homelab.docker").homelab.docker.pruefe_docker,) if ROLLE == "homelab" else ()
# Plattengesundheit (seit 26.09.2026): Host und NAS im Homelab, die NVMe der KI-Box auf der Box.
PRUEFUNGEN += ((__import__("services.homelab.platten").homelab.platten.pruefe_platten_gesundheit,) if ROLLE == "homelab"
else (__import__("services.platten").platten.pruefe_platten_gesundheit,))
# Neue Geräte im Heimnetz (seit 26.09.2026): gelb mit „Ist bekannt“, bis jemand ihn drückt.
PRUEFUNGEN += (__import__("services.homelab.netzgeraete").homelab.netzgeraete.pruefe_netzgeraete,) \
if ROLLE == "homelab" else ()
@@ -923,6 +926,12 @@ def fuehre_aktion_aus(hinweis_id: str, aktion_id: str) -> dict:
if not update_verlauf.freigeben(aktion["baustein"]):
return {"ok": False, "detail": "Der Baustein war schon freigegeben."}
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
if aktion_id == "hinnehmen": # Plattenfehler zur Kenntnis genommen (seit 26.09.2026)
if ROLLE == "homelab":
from services.homelab import platten as homelab_platten
return homelab_platten.hinnehmen(str(aktion.get("platte") or ""))
from services import platten
return platten.hinnehmen(str(aktion.get("platte") or ""))
if aktion_id == "bekannt": # neues Gerät im Heimnetz (seit 26.09.2026)
from services.homelab import netzgeraete
return netzgeraete.bestaetigen(str(aktion.get("mac") or ""))