"""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 /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 ...""" 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.")}