Später · 21 Neue Geräte im Heimnetz (Nachbartabelle des Proxmox-Hosts statt AdGuard)
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
241e91c03f
commit
5325544d89
@@ -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)
|
||||
|
||||
@@ -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 <Datenordner>/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
|
||||
@@ -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 "
|
||||
|
||||
@@ -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.")
|
||||
@@ -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]:
|
||||
|
||||
@@ -199,7 +199,7 @@ export const useHinweisAktion = (basis = "/api/hinweise") =>
|
||||
post<AktionsErgebnis>(`${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"],
|
||||
|
||||
Reference in New Issue
Block a user