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:
co-authored by
Claude Opus 5.5
parent
5325544d89
commit
904598031a
@@ -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.")}
|
||||
Reference in New Issue
Block a user