From 5325544d89fc7d7a8c33a87c25cd7a5631e0562b Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sat, 26 Sep 2026 09:43:45 +0200 Subject: [PATCH] =?UTF-8?q?Sp=C3=A4ter=20=C2=B7=2021=20Neue=20Ger=C3=A4te?= =?UTF-8?q?=20im=20Heimnetz=20(Nachbartabelle=20des=20Proxmox-Hosts=20stat?= =?UTF-8?q?t=20AdGuard)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdGuard sieht in diesem Netz fast nur die Fritzbox, die die Anfragen der meisten Geräte weiterreicht (nachgesehen 26.09.). Deshalb: - Ausführer: alle 30 min Ping an jede Adresse des /24 der Brücke vmbr0, dann ip neigh (MAC, IP, Rückwärtsname); nur lesend, im Bericht als host.netz (größer als /24 wird nicht abgesucht) - services/homelab/netzgeraete.py: Register netz-bekannt.json; erster Abgleich = alles bekannt, Gäste des Hosts und neue MACs mit bekanntem Namen (Handys mit zufälliger MAC) gelten als bekannt; Neues einmal gemeldet - Wächter: gelber Hinweis „Neues Gerät im Netz“ mit „Ist bekannt“; GET /api/homelab/netz Co-Authored-By: Claude Opus 5.5 --- backend/routers/homelab.py | 10 +- backend/services/homelab/netzgeraete.py | 166 ++++++++++++++++++++++++ backend/services/waechter.py | 6 + backend/tests/test_netzgeraete.py | 114 ++++++++++++++++ deploy/homelab/ausfuehrer.py | 80 ++++++++++++ frontend/src/lib/abfragen.ts | 2 +- 6 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 backend/services/homelab/netzgeraete.py create mode 100644 backend/tests/test_netzgeraete.py diff --git a/backend/routers/homelab.py b/backend/routers/homelab.py index e2cda55..1587a1f 100644 --- a/backend/routers/homelab.py +++ b/backend/routers/homelab.py @@ -134,6 +134,13 @@ def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict: return _antwort(waechter.fuehre_aktion_aus(hinweis_id, aktion_id)) +@router.get("/netz") +def netz() -> dict: + """Die Geräte im Heimnetz aus der Nachbartabelle des Proxmox-Hosts, neue zuerst (seit 26.09.2026).""" + from services.homelab import netzgeraete + return netzgeraete.lage() + + @router.get("/laeufe") def laeufe(anzahl: int = 20) -> dict: return {"laeufe": updates.laeufe(max(1, min(anzahl, 100)))} @@ -375,12 +382,13 @@ def auftrag_abholen() -> dict: @router.post("/ausfuehrer/bericht", dependencies=[Depends(_nur_ausfuehrer)]) def bericht(daten: dict) -> dict: - from services.homelab import entdecken, katalog, speicher + from services.homelab import entdecken, katalog, netzgeraete, speicher kanal.bericht_speichern(daten) speicher.verlauf_merken(daten) # Verlauf für „voll in etwa N Tagen“ (seit 25.09.2026) try: # „Neu entdeckt“ und der Katalog (seit 25.09.2026) halten den Bericht nie auf entdecken.bericht_gesehen(daten) katalog.ergaenzen(daten) + netzgeraete.abgleichen(daten) # neue Geräte im Heimnetz (seit 26.09.2026) threading.Thread(target=entdecken.docker_abgleichen, name="docker-abgleich", daemon=True).start() except Exception: log.warning("homelab: Abgleich mit dem Register gescheitert", exc_info=True) diff --git a/backend/services/homelab/netzgeraete.py b/backend/services/homelab/netzgeraete.py new file mode 100644 index 0000000..0153ef9 --- /dev/null +++ b/backend/services/homelab/netzgeraete.py @@ -0,0 +1,166 @@ +"""Neue Geräte im Heimnetz (Ausbauplan „Später“, Punkt 21, seit 26.09.2026). + +Geplant war AdGuard als Quelle. Nachgesehen am 26.09.: AdGuard sieht in diesem Netz fast nur die Fritzbox, die die +Anfragen der meisten Geräte weiterreicht. Der Ausführer sucht deshalb alle 30 Minuten das Netz der Proxmox-Brücke ab +(Ping an jede Adresse, dann die Nachbartabelle; nur lesend) und schickt die Geräte mit MAC, IP und Namen im Bericht mit +(host.netz). Hier wird jede MAC mit dem Register /netz-bekannt.json verglichen: + • Beim allerersten Abgleich gilt alles als bekannt — sonst stünden alle Geräte auf einmal da. + • Eine neue MAC wird „neu“: Lucy meldet sie einmal (nicht dringend, nachts mit der Morgenmeldung), und der Wächter + zeigt einen gelben Hinweis „Neues Gerät im Netz“ mit dem Knopf „Ist bekannt“, bis jemand ihn drückt. + • Handys wechseln ihre MAC (zufällige, „lokal verwaltete“ Adressen). Trägt eine neue MAC denselben Namen wie ein + bekanntes Gerät, gilt sie als dasselbe Gerät. + • Die Gäste des Proxmox-Hosts kennt der Orchestrator ohnehin; ihre IPs gelten als bekannt. +Verschwundene Geräte bleiben im Register (mit „zuletzt gesehen“): Ein Gerät, das nur ab und zu da ist, soll nicht jedes +Mal neu sein. Geschrieben wird nur im Prozess der Oberfläche (Bericht, Knopf); der Wächter im Steward liest. +""" + +import json +import logging +import threading +import time +from datetime import datetime +from pathlib import Path + +from kern.einstellungen import einstellungen +from kern.zeit import LOCAL_TZ + +from services import announce + +log = logging.getLogger(__name__) + +BETREFF = "[Homelab]" +_lock = threading.Lock() + + +def _pfad() -> Path: + return einstellungen().daten_dir / "netz-bekannt.json" + + +def _lesen() -> dict | None: + try: + daten = json.loads(_pfad().read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return daten if isinstance(daten, dict) and isinstance(daten.get("geraete"), dict) else None + + +def _schreiben(daten: dict) -> None: + pfad = _pfad() + pfad.parent.mkdir(parents=True, exist_ok=True) + tmp = pfad.with_suffix(".tmp") + tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8") + tmp.replace(pfad) + + +def lokal_verwaltet(mac: str) -> bool: + """Zufällige MAC (zweites Bit des ersten Bytes gesetzt) — so schützen Handys ihre Privatsphäre.""" + try: + return bool(int(mac.split(":")[0], 16) & 0x02) + except (ValueError, IndexError): + return False + + +def _kurzname(name: str | None) -> str | None: + return name.removesuffix(".fritz.box") if name else None + + +def abgleichen(bericht: dict, jetzt: float | None = None) -> list[dict]: + """Nach jedem Bericht: Register nachführen. Rückgabe: die Geräte, die eben neu dazukamen (für die Meldung).""" + jetzt = time.time() if jetzt is None else jetzt + b = bericht.get("bericht") if isinstance(bericht.get("bericht"), dict) else bericht + netz = ((b or {}).get("host") or {}).get("netz") + if not isinstance(netz, dict) or not isinstance(netz.get("geraete"), list) or not netz.get("netz"): + return [] + gast_ips = {str(g.get("ip")) for g in (b or {}).get("gaeste") or [] if isinstance(g, dict) and g.get("ip")} + neu: list[dict] = [] + with _lock: + daten = _lesen() + erstes_mal = daten is None + daten = daten or {"geraete": {}} + register: dict = daten["geraete"] + namen = {e.get("name"): mac for mac, e in register.items() if e.get("name")} + for g in netz["geraete"]: + mac, ip, name = str(g.get("mac") or "").lower(), str(g.get("ip") or ""), g.get("name") + if not mac or not ip: + continue + if mac in register: + register[mac].update(ip=ip, zuletzt=jetzt, **({"name": name} if name else {})) + continue + gleicher_name = name and namen.get(name) + bekannt = erstes_mal or ip in gast_ips or bool(gleicher_name) + register[mac] = {"ip": ip, "name": name, "erstmals": jetzt, "zuletzt": jetzt, "bestaetigt": bekannt} + if gleicher_name: + register[mac]["wie"] = gleicher_name + if not bekannt: + neu.append({"mac": mac, **register[mac]}) + daten["netz"], daten["stand"] = netz["netz"], float(netz.get("zeit") or jetzt) + _schreiben(daten) + for g in neu: + log.info("netz: neues Gerät %s (%s, %s)", g["ip"], g["mac"], g.get("name") or "ohne Namen") + if neu: + _melden(neu) + return neu + + +def _beschreibung(g: dict) -> str: + teile = [g["ip"], f"MAC {g['mac']}"] + if lokal_verwaltet(g["mac"]): + teile.append("zufällige MAC, oft ein Handy oder Tablet") + return " · ".join(teile) + + +def _melden(neu: list[dict]) -> None: + zeilen = [f"• {_kurzname(g.get('name')) or 'ohne Namen'} ({_beschreibung(g)})" for g in neu[:8]] + text = (("Ein neues Gerät ist" if len(neu) == 1 else f"{len(neu)} neue Geräte sind") + " im Heimnetz aufgetaucht:\n" + + "\n".join(zeilen) + "\nKennst du es, drück im Homelab-Cockpit „Ist bekannt“.") + try: + announce.notify_telegram(BETREFF, text) + except Exception: + log.warning("netz: Meldung ging nicht raus", exc_info=True) + + +def bestaetigen(mac: str) -> dict: + """Knopf „Ist bekannt“ (Aktion des Hinweises).""" + mac = str(mac or "").lower() + with _lock: + daten = _lesen() + eintrag = (daten or {}).get("geraete", {}).get(mac) + if not daten or not eintrag: + return {"ok": False, "detail": "Dieses Gerät kennt das Register nicht."} + eintrag["bestaetigt"] = True + _schreiben(daten) + return {"ok": True, "text": f"Gemerkt: {_kurzname(eintrag.get('name')) or eintrag['ip']} ist bekannt. Der Hinweis " + "verschwindet in wenigen Minuten."} + + +def lage() -> dict: + """Für GET /api/homelab/netz: alle Geräte, neue zuerst, dann nach IP.""" + daten = _lesen() or {"geraete": {}} + geraete = [{"mac": mac, **e, "name": _kurzname(e.get("name")), "zufaellig": lokal_verwaltet(mac)} + for mac, e in daten["geraete"].items()] + + def ip_schluessel(g: dict) -> tuple: + try: + return tuple(int(t) for t in g["ip"].split(".")) + except (ValueError, AttributeError): + return (999,) + geraete.sort(key=lambda g: (g.get("bestaetigt", True), ip_schluessel(g))) + return {"netz": daten.get("netz"), "stand": daten.get("stand"), "geraete": geraete} + + +def pruefe_netzgeraete() -> list: + """Für den Wächter (Rolle homelab): jedes unbestätigte Gerät als gelber Hinweis mit dem Knopf „Ist bekannt“.""" + from services.waechter import Befund, _aktion + + befunde = [] + for mac, e in ((_lesen() or {}).get("geraete") or {}).items(): + if e.get("bestaetigt", True): + continue + g = {"mac": mac, **e} + seit = datetime.fromtimestamp(float(e.get("erstmals") or 0), LOCAL_TZ).strftime("%d.%m. %H:%M") + befunde.append(Befund( + id=f"netz:{mac}", stufe="gelb", titel=f"Neues Gerät im Netz: {_kurzname(e.get('name')) or e.get('ip')}", + text=f"{_beschreibung(g)}, zuerst gesehen {seit}. Kennst du es, drück „Ist bekannt“; sonst in der Fritzbox " + "nachsehen, wem es gehört.", + quelle="netz", aktionen=[_aktion("bekannt", "Ist bekannt", mac=mac)], sofort=True)) + return befunde diff --git a/backend/services/waechter.py b/backend/services/waechter.py index 98c63e4..3aa07a9 100644 --- a/backend/services/waechter.py +++ b/backend/services/waechter.py @@ -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 () +# 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 () BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"), "homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE] @@ -920,6 +923,9 @@ 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 == "bekannt": # neues Gerät im Heimnetz (seit 26.09.2026) + from services.homelab import netzgeraete + return netzgeraete.bestaetigen(str(aktion.get("mac") or "")) if aktion_id == "ausblenden": quittieren(hinweis_id, str(aktion.get("lauf", ""))) return {"ok": True, "text": "Ausgeblendet. Der Hinweis verschwindet in spätestens einer Minute " diff --git a/backend/tests/test_netzgeraete.py b/backend/tests/test_netzgeraete.py new file mode 100644 index 0000000..f51adb0 --- /dev/null +++ b/backend/tests/test_netzgeraete.py @@ -0,0 +1,114 @@ +"""Neue Geräte im Heimnetz (Ausbauplan „Später“, Punkt 21, 26.09.2026): Der Ausführer liest die Nachbartabelle des +Proxmox-Hosts, der Homelab-Teil führt ein Register und meldet Neues einmal; der Wächter zeigt es, bis „Ist bekannt“.""" + +import importlib.util +import json +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from kern import einstellungen as einstellungen_mod +from services.homelab import netzgeraete + +AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py" +T0 = 1_790_400_000.0 + + +@pytest.fixture(scope="module") +def a(): + spec = importlib.util.spec_from_file_location("ausfuehrer_netz", AUSFUEHRER) + modul = importlib.util.module_from_spec(spec) + spec.loader.exec_module(modul) + return modul + + +def test_netz_der_bruecke(a): + addr = json.dumps([{"ifname": "vmbr0", "addr_info": [{"family": "inet", "local": "192.168.178.108", "prefixlen": 24}]}]) + assert str(a.netz_der_bruecke(addr)) == "192.168.178.0/24" + gross = json.dumps([{"addr_info": [{"family": "inet", "local": "10.0.0.5", "prefixlen": 16}]}]) + assert a.netz_der_bruecke(gross) is None # größer als /24 wird nicht abgesucht + assert a.netz_der_bruecke("kaputt") is None + + +def test_nachbarn(a): + import ipaddress + neigh = json.dumps([ + {"dst": "192.168.178.1", "lladdr": "74:42:7F:00:00:01", "state": ["REACHABLE"]}, + {"dst": "192.168.178.23", "lladdr": "6e:81:03:00:00:02", "state": ["STALE"]}, + {"dst": "192.168.178.40", "state": ["FAILED"]}, + {"dst": "192.168.178.41", "lladdr": "aa:bb:cc:00:00:03", "state": ["INCOMPLETE"]}, + {"dst": "fe80::1", "lladdr": "74:42:7f:00:00:01", "state": ["REACHABLE"]}, + {"dst": "10.1.1.1", "lladdr": "11:22:33:00:00:04", "state": ["REACHABLE"]}, + ]) + assert a.nachbarn(neigh, ipaddress.ip_network("192.168.178.0/24")) == [ + {"ip": "192.168.178.1", "mac": "74:42:7f:00:00:01"}, {"ip": "192.168.178.23", "mac": "6e:81:03:00:00:02"}] + + +@pytest.fixture +def daten(tmp_path, monkeypatch): + monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path)) + einstellungen_mod.einstellungen.cache_clear() + gemeldet: list[str] = [] + monkeypatch.setattr(netzgeraete.announce, "notify_telegram", lambda betreff, text, **k: gemeldet.append(text)) + yield gemeldet + einstellungen_mod.einstellungen.cache_clear() + + +def _bericht(*geraete: tuple[str, str, str | None], gaeste: tuple[str, ...] = ()) -> dict: + return {"host": {"netz": {"zeit": T0, "netz": "192.168.178.0/24", + "geraete": [{"ip": ip, "mac": mac, "name": name} for ip, mac, name in geraete]}}, + "gaeste": [{"vmid": 100 + i, "ip": ip} for i, ip in enumerate(gaeste)]} + + +FRITZ = ("192.168.178.1", "74:42:7f:00:00:01", "fritz.box") +PC = ("192.168.178.22", "34:5a:60:00:00:05", "TobisNicerPC.fritz.box") + + +def test_erstes_mal_ist_alles_bekannt_dann_meldet_es_neues(daten): + assert netzgeraete.abgleichen(_bericht(FRITZ, PC), jetzt=T0) == [] + assert daten == [] and netzgeraete.pruefe_netzgeraete() == [] + handy = ("192.168.178.23", "6e:81:03:00:00:02", None) + neu = netzgeraete.abgleichen(_bericht(FRITZ, PC, handy), jetzt=T0 + 1800) + assert [g["mac"] for g in neu] == ["6e:81:03:00:00:02"] + assert daten == [("Ein neues Gerät ist im Heimnetz aufgetaucht:\n• ohne Namen (192.168.178.23 · MAC " + "6e:81:03:00:00:02 · zufällige MAC, oft ein Handy oder Tablet)\nKennst du es, drück im " + "Homelab-Cockpit „Ist bekannt“.")] + (befund,) = netzgeraete.pruefe_netzgeraete() + assert (befund.id, befund.stufe, befund.titel) == ("netz:6e:81:03:00:00:02", "gelb", "Neues Gerät im Netz: 192.168.178.23") + assert befund.aktionen == [{"id": "bekannt", "label": "Ist bekannt", "mac": "6e:81:03:00:00:02"}] and befund.sofort + # Beim nächsten Bericht kein zweites Mal. + assert netzgeraete.abgleichen(_bericht(FRITZ, PC, handy), jetzt=T0 + 3600) == [] and len(daten) == 1 + assert netzgeraete.bestaetigen("6E:81:03:00:00:02")["ok"] + assert netzgeraete.pruefe_netzgeraete() == [] + assert not netzgeraete.bestaetigen("00:00:00:00:00:00")["ok"] + + +def test_gaeste_und_neue_mac_mit_bekanntem_namen_sind_nicht_neu(daten): + netzgeraete.abgleichen(_bericht(FRITZ, PC), jetzt=T0) + gast = ("192.168.178.160", "bc:24:11:00:00:09", None) + pc_neue_mac = ("192.168.178.22", "36:5a:60:00:00:99", "TobisNicerPC.fritz.box") + assert netzgeraete.abgleichen(_bericht(FRITZ, gast, pc_neue_mac, gaeste=("192.168.178.160",)), jetzt=T0 + 60) == [] + lage = netzgeraete.lage() + pc = next(g for g in lage["geraete"] if g["mac"] == "36:5a:60:00:00:99") + assert pc["wie"] == "34:5a:60:00:00:05" and pc["name"] == "TobisNicerPC" and pc["bestaetigt"] + + +def test_ohne_netz_im_bericht_passiert_nichts(daten): + assert netzgeraete.abgleichen({"host": {}}, jetzt=T0) == [] + assert not (Path(einstellungen_mod.einstellungen().daten_dir) / "netz-bekannt.json").exists() + + +def test_schnittstelle_und_knopf_des_hinweises(daten, monkeypatch): + from routers import homelab + from services import waechter + netzgeraete.abgleichen(_bericht(FRITZ), jetzt=T0) + netzgeraete.abgleichen(_bericht(FRITZ, PC), jetzt=T0 + 60) + app = FastAPI() + app.include_router(homelab.router) + lage = TestClient(app).get("/api/homelab/netz").json() + assert lage["netz"] == "192.168.178.0/24" and [g["bestaetigt"] for g in lage["geraete"]] == [False, True] + (befund,) = netzgeraete.pruefe_netzgeraete() + monkeypatch.setattr(waechter, "lese_stand", lambda: {"hinweise": [{"id": befund.id, "aktionen": befund.aktionen}]}) + ergebnis = waechter.fuehre_aktion_aus(befund.id, "bekannt") + assert ergebnis["ok"] and ergebnis["text"].startswith("Gemerkt: TobisNicerPC ist bekannt.") diff --git a/deploy/homelab/ausfuehrer.py b/deploy/homelab/ausfuehrer.py index 538a6d2..0d64f13 100644 --- a/deploy/homelab/ausfuehrer.py +++ b/deploy/homelab/ausfuehrer.py @@ -54,17 +54,20 @@ Aufruf: ausfuehrer.py Dauerbetrieb (systemd) import argparse import base64 import glob +import ipaddress import json import logging import os import platform import re +import socket import subprocess import sys import threading import time import urllib.error import urllib.request +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone KONFIG = os.environ.get("MC2_AUSFUEHRER_KONFIG", "/etc/mc2-ausfuehrer.json") @@ -1108,10 +1111,87 @@ def bericht() -> dict: host["zertifikate"] = _zertifikate(gaeste) except Exception as exc: host["zertifikate_fehler"] = str(exc)[:200] + try: + host["netz"] = _netz_stand() + except Exception as exc: + host["netz_fehler"] = str(exc)[:200] return {"zeit": datetime.now(timezone.utc).isoformat(timespec="seconds"), "host": host, "gaeste": sorted(gaeste, key=lambda g: g["vmid"])} +# --- Geräte im Heimnetz (seit 26.09.2026, nur lesend) ---------------------------------------------------- +# AdGuard sieht in diesem Netz fast nur die Fritzbox (sie reicht die Anfragen der meisten Geräte weiter). Wer im +# Heimnetz ist, verrät deshalb die Nachbartabelle dieses Hosts: Alle NETZ_ALLE_S ein Ping an jede Adresse des Netzes +# der Brücke (antwortet ein Gerät nicht auf Ping, beantwortet es trotzdem die ARP-Anfrage davor), dann `ip neigh`. +# Namen kommen aus der Rückwärtsauflösung (die Fritzbox kennt ihre DHCP-Namen). Größer als /24 wird nicht abgesucht. + +NETZ_ALLE_S = 1800 +NETZ_BRUECKE = "vmbr0" +_netz: dict = {"zeit": 0.0, "stand": None} + + +def netz_der_bruecke(addr_json: str) -> ipaddress.IPv4Network | None: + """Das IPv4-Netz der Brücke aus `ip -j -4 addr show dev vmbr0`; None, wenn keins oder größer als /24.""" + try: + for eintrag in json.loads(addr_json or "[]"): + for a in eintrag.get("addr_info") or []: + if a.get("family") in (None, "inet") and a.get("local") and int(a.get("prefixlen") or 0) >= 24: + return ipaddress.ip_interface(f"{a['local']}/{a['prefixlen']}").network + except (ValueError, TypeError, AttributeError): + return None + return None + + +def nachbarn(neigh_json: str, netz: ipaddress.IPv4Network) -> list[dict]: + """Die Einträge von `ip -j neigh`, die eine MAC haben und im Netz liegen (FAILED/INCOMPLETE fallen weg).""" + geraete = {} + try: + eintraege = json.loads(neigh_json or "[]") + except ValueError: + return [] + for e in eintraege: + zustand = set(e.get("state") or []) + mac = str(e.get("lladdr") or "").lower() + try: + ip = ipaddress.ip_address(str(e.get("dst"))) + except ValueError: + continue + if not mac or zustand & {"FAILED", "INCOMPLETE"} or ip.version != 4 or ip not in netz: + continue + geraete[mac] = {"ip": str(ip), "mac": mac} + return sorted(geraete.values(), key=lambda g: ipaddress.ip_address(g["ip"])) + + +def _name(ip: str) -> str | None: + try: + return socket.gethostbyaddr(ip)[0] + except (OSError, UnicodeError): + return None + + +def netz_geraete() -> dict: + code, addr = _laufen(["ip", "-j", "-4", "addr", "show", "dev", NETZ_BRUECKE], 10) + netz = netz_der_bruecke(addr) if code == 0 else None + if netz is None: + return {"zeit": time.time(), "netz": None, "geraete": [], "grund": f"kein /24-Netz an {NETZ_BRUECKE}"} + with ThreadPoolExecutor(max_workers=64) as pool: + list(pool.map(lambda ip: _laufen(["ping", "-c", "1", "-W", "1", "-q", str(ip)], 5), netz.hosts())) + code, neigh = _laufen(["ip", "-j", "neigh", "show", "dev", NETZ_BRUECKE], 10) + geraete = nachbarn(neigh, netz) if code == 0 else [] + with ThreadPoolExecutor(max_workers=16) as pool: + for g, name in zip(geraete, pool.map(_name, [g["ip"] for g in geraete]), strict=True): + g["name"] = name + return {"zeit": time.time(), "netz": str(netz), "geraete": geraete} + + +def _netz_stand() -> dict: + """Höchstens alle NETZ_ALLE_S neu absuchen; dazwischen der letzte Stand.""" + if _netz["stand"] is None or time.time() - _netz["zeit"] >= NETZ_ALLE_S: + _netz["stand"] = netz_geraete() + _netz["zeit"] = time.time() + return _netz["stand"] + + # --- Aufträge (feste Liste) --------------------------------------------------------------- def _gast_pruefen(vmid: object) -> tuple[str, int]: diff --git a/frontend/src/lib/abfragen.ts b/frontend/src/lib/abfragen.ts index b2f9409..b317af3 100644 --- a/frontend/src/lib/abfragen.ts +++ b/frontend/src/lib/abfragen.ts @@ -199,7 +199,7 @@ export const useHinweisAktion = (basis = "/api/hinweise") => post(`${basis}/${encodeURIComponent(hinweis)}/aktion/${aktion}`), { erfolg: (v, e) => - (v.aktion === "freigeben" || ZUSTAND_KNOEPFE.has(v.aktion)) && e.text + (v.aktion === "freigeben" || v.aktion === "bekannt" || ZUSTAND_KNOEPFE.has(v.aktion)) && e.text ? e.text : `${v.label}: erledigt. Der Wächter prüft in einer Minute nach.`, neuLaden: basis === "/api/hinweise" ? ["start"] : ["homelab-hinweise"],