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:
Hitonabi
2026-09-26 09:43:45 +02:00
co-authored by Claude Opus 5.5
parent 241e91c03f
commit 5325544d89
6 changed files with 376 additions and 2 deletions
+80
View File
@@ -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]: