Welle 1 · Live-Zahlen je Dienst auf den Kacheln, AdGuard-Zugang in den Einstellungen

Jede Gerätekachel (Homelab-Cockpit und Übersicht) zeigt eine Live-Zahl, wo es eine gibt: Proxmox-Host „8 von 8
Gästen an“, PBS „letzte Sicherung vor 13 h“, NPMplus „10 Zertifikate gültig“ (aus der Zertifikats-Wache), Arcane
„11 von 12 Containern an“ (Arcane-API mit dem vorhandenen Schlüssel) und AdGuard „12 % geblockt“. Für AdGuard gibt es
unter Einstellungen → Homelab einen Zugang, der vor dem Speichern gegen AdGuard geprüft wird und nur im Homelab-Teil
liegt (0600, das Passwort steht in keiner Antwort). Arcane und AdGuard werden höchstens alle zwei Minuten gefragt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 16:52:41 +02:00
co-authored by Claude Opus 5.5
parent 53ea0cd9b6
commit ad8dbbc1b3
17 changed files with 561 additions and 10 deletions
+108
View File
@@ -0,0 +1,108 @@
"""AdGuard Home: der Anteil geblockter Anfragen für die Kachel (Ausbauplan Welle 1, seit 25.09.2026).
Die Statistik (GET /control/stats) gibt AdGuard nur mit Anmeldung heraus. Den Zugang trägt der User selbst unter
Einstellungen ein; vor dem Speichern fragt dieser Teil AdGuard damit einmal — lehnt es ab oder antwortet nicht, wird
nichts gespeichert. Er liegt in <Datenordner>/adguard.json (0600) und wird bei jedem Zugriff gelesen: ein neuer gilt
ohne Neustart. Das Passwort steht nie in einer Antwort, einem Protokoll oder einer Fehlermeldung; den Benutzernamen
zeigt die Oberfläche, damit man weiß, welcher hinterlegt ist. Am besten ein eigener Benutzer nur dafür.
"""
import json
import os
import threading
import time
from pathlib import Path
import httpx
from kern.einstellungen import einstellungen
from services.homelab import apps, kanal
ZEITLIMIT = httpx.Timeout(10.0, connect=5.0)
CACHE_S = 120
_lock = threading.Lock()
_cache: dict[str, tuple[float, dict | None]] = {}
def _pfad() -> Path:
return einstellungen().daten_dir / "adguard.json"
def zugang() -> tuple[str, str] | None:
"""(Benutzer, Passwort) oder None."""
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError, UnicodeDecodeError):
return None
if not isinstance(daten, dict):
return None
benutzer, passwort = daten.get("benutzer"), daten.get("passwort")
return (benutzer, passwort) if isinstance(benutzer, str) and isinstance(passwort, str) and benutzer else None
def zugang_speichern(benutzer: str, passwort: str) -> None:
"""Atomar und von Anfang an nur für den Dienst lesbar (0600)."""
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({"benutzer": benutzer, "passwort": passwort}, 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 url() -> str | None:
"""Die Oberfläche von AdGuard aus dem Bericht des Ausführers."""
for gast in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []:
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
if app and app.kennung == "adguard" and (adresse := apps.adresse(app, gast.get("ip"))):
return adresse.rstrip("/")
return None
def statistik(adresse: str, benutzer: str, passwort: str) -> dict:
"""{"ok": True, "anfragen", "geblockt", "anteil"} oder {"ok": False, "status", "art": abgelehnt|http|netz|antwort}.
Nichts davon enthält das Passwort."""
try:
r = httpx.get(f"{adresse}/control/stats", auth=(benutzer, passwort), timeout=ZEITLIMIT)
r.raise_for_status()
daten = r.json()
anfragen, geblockt = int(daten["num_dns_queries"]), int(daten["num_blocked_filtering"])
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
return {"ok": False, "status": status, "art": "abgelehnt" if status in (401, 403) else "http"}
except httpx.HTTPError:
return {"ok": False, "status": None, "art": "netz"}
except (ValueError, KeyError, TypeError):
return {"ok": False, "status": None, "art": "antwort"}
return {"ok": True, "anfragen": anfragen, "geblockt": geblockt,
"anteil": round(geblockt / anfragen * 100, 1) if anfragen else None}
def geblockt() -> dict | None:
"""Für die Kachel: die Statistik mit dem hinterlegten Zugang, höchstens alle CACHE_S neu gefragt. None ohne Zugang,
ohne AdGuard im Bericht oder wenn AdGuard nicht antwortet."""
z, adresse = zugang(), url()
if not z or not adresse:
return None
jetzt = time.time()
with _lock:
if (eintrag := _cache.get(adresse)) and jetzt - eintrag[0] < CACHE_S:
return eintrag[1]
ergebnis = statistik(adresse, *z)
wert = ergebnis if ergebnis["ok"] else None
with _lock:
_cache[adresse] = (jetzt, wert)
return wert
+20
View File
@@ -200,6 +200,26 @@ def _images_mit_update(url: str) -> list[dict]:
return ergebnis
def container_zahlen(url: str) -> dict | None:
"""Laufende und alle Container über alle Umgebungen, für die Kachel (seit 25.09.2026): {"laufend", "gesamt"}.
None ohne Schlüssel oder wenn Arcane nicht antwortet."""
if not schluessel():
return None
return _gemerkt(f"container:{url}", lambda: _container_zahlen(url))
def _container_zahlen(url: str) -> dict | None:
laufend = gesamt = 0
try:
for umgebung in umgebungen(url):
daten = (_get(url, f"/environments/{umgebung['id']}/containers/counts") or {}).get("data") or {}
laufend += int(daten.get("runningContainers") or 0)
gesamt += int(daten.get("totalContainers") or 0)
except (httpx.HTTPError, ValueError, TypeError, AttributeError):
return None
return {"laufend": laufend, "gesamt": gesamt}
def schluessel_pruefen(url: str, wert: str) -> dict:
"""Nimmt Arcane diesen Schlüssel an? Geprüft wird mit denselben Aufrufen, die die Übersicht braucht: die
Umgebungen, dann ihre Images. {"ok": True, "umgebungen", "images", "mit_update"} oder {"ok": False, "status":
+63 -1
View File
@@ -5,6 +5,8 @@
<Datenordner>/arcane.key (0600); MC_ARCANE_KEY in /etc/mc2/homelab.env geht vor (arcane.py).
• Docker echt echte Docker-Updates über Arcane statt Probelauf (<Datenordner>/homelab-einstellungen.json);
MC_ARCANE_ECHT in der Umgebung gewinnt.
• AdGuard-Zugang (seit 25.09.2026) Benutzer und Passwort für die Statistik („% geblockt“ auf der Kachel). Vor
dem Speichern fragt AdGuard einmal damit; lehnt es ab, wird nichts gespeichert (adguard.py).
stand() zeigt dazu die Wartezeit nach Skriptänderungen (karenz.py) und den Ausführer (verbunden, Nur-Lesen,
Sicherungsspeicher). Der Schlüssel verlässt diesen Teil nie: Keine Antwort, kein Protokolleintrag und keine
Fehlermeldung enthält ihn, auch kein Stück davon.
@@ -15,7 +17,7 @@ import logging
import re
import time
from services.homelab import apps, arcane, inventar, kanal, karenz
from services.homelab import adguard, apps, arcane, inventar, kanal, karenz
log = logging.getLogger(__name__)
@@ -42,6 +44,8 @@ def stand() -> dict:
return {
"arcane": {"url": arcane_url() or "", "schluessel": arcane.schluessel_quelle(), "echt": arcane.echt(),
"echt_quelle": arcane.echt_quelle()},
"adguard": {"url": adguard.url() or "", "zugang": "hinterlegt" if (z := adguard.zugang()) else "fehlt",
"benutzer": z[0] if z else None},
"karenz_h": round(karenz.karenz_s() / 3600),
"ausfuehrer": {"verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
"zuletzt": zuletzt, "nur_lesen": bool(host.get("nur_lesen")),
@@ -127,3 +131,61 @@ def arcane_echt_setzen(an: bool) -> dict:
return {"ok": True, "text": ("Docker-Updates über Arcane laufen ab jetzt echt: Arcane holt die neuen Images und "
"startet die Container neu." if an else
"Docker-Updates über Arcane laufen wieder nur als Probelauf; geändert wird nichts.")}
# --- AdGuard-Zugang (seit 25.09.2026) --------------------------------------------------------------------------------
def zugang_aus_body(roh: bytes) -> tuple[str, str] | None:
"""(Benutzer, Passwort) aus dem Body {"benutzer", "passwort"} — ohne Pydantic, dessen 422 die Eingabe wiederholte."""
try:
daten = json.loads(roh.decode("utf-8")) if roh else None
except (UnicodeDecodeError, ValueError):
return None
if not isinstance(daten, dict):
return None
benutzer, passwort = daten.get("benutzer"), daten.get("passwort")
return (benutzer, passwort) if isinstance(benutzer, str) and isinstance(passwort, str) else None
def _adguard_abgelehnt(ergebnis: dict, adresse: str) -> str:
art, status = ergebnis.get("art"), ergebnis.get("status")
if art == "abgelehnt":
return f"AdGuard lehnt Benutzer oder Passwort ab (HTTP {status}). Nichts gespeichert."
if art == "http":
return f"AdGuard antwortet mit HTTP {status}. Nichts gespeichert."
if art == "netz":
return f"AdGuard ist nicht erreichbar ({adresse}). Nichts gespeichert."
return "AdGuard antwortet nicht wie erwartet. Nichts gespeichert."
def adguard_zugang_setzen(werte: tuple[str, str] | None) -> dict:
"""POST /api/homelab/einstellungen/adguard-zugang: erst bei AdGuard die Statistik holen, dann speichern."""
benutzer, passwort = (werte[0].strip(), werte[1]) if werte else ("", "")
if not benutzer or not passwort or len(benutzer) > 200 or len(passwort) > 500:
return {"ok": False, "detail": "Benutzer und Passwort eintragen. Nichts gespeichert."}
adresse = adguard.url()
if not adresse:
return {"ok": False, "detail": "AdGuard steht nicht im Bericht des Ausführers, die Adresse ist unbekannt. "
"Nichts gespeichert."}
ergebnis = adguard.statistik(adresse, benutzer, passwort)
if not ergebnis["ok"]:
log.info("homelab: AdGuard-Zugang nicht übernommen (%s, HTTP %s)", ergebnis.get("art"), ergebnis.get("status"))
return {"ok": False, "detail": _adguard_abgelehnt(ergebnis, adresse)}
try:
adguard.zugang_speichern(benutzer, passwort)
except OSError as exc:
return {"ok": False, "detail": f"AdGuard nimmt den Zugang an, aber er ließ sich nicht speichern "
f"({exc.__class__.__name__})."}
anteil = ergebnis["anteil"]
return {"ok": True, "text": (f"AdGuard nimmt den Zugang an: {ergebnis['anfragen']} Anfragen"
+ (f", davon {str(anteil).replace('.', ',')} % geblockt" if anteil is not None else "")
+ ". Die Kachel zeigt den Anteil ab sofort.")}
def adguard_zugang_loeschen() -> dict:
"""DELETE /api/homelab/einstellungen/adguard-zugang."""
try:
da = adguard.zugang_loeschen()
except OSError as exc:
return {"ok": False, "detail": f"Der Zugang ließ sich nicht löschen ({exc.__class__.__name__})."}
return {"ok": True, "text": "Der AdGuard-Zugang ist gelöscht." if da else "Es war kein AdGuard-Zugang hinterlegt."}
+12
View File
@@ -298,13 +298,25 @@ def ziele() -> dict:
liste = [_host_ziel(b.get("host") or {})]
# Der Container des Orchestrators selbst (Etikett „mc2“) ist kein Gerät, das er pflegt.
liste += [_gast_ziel(g, jetzt) for g in b.get("gaeste") or [] if EIGENES_ETIKETT not in (g.get("etiketten") or [])]
zahlen = _kennzahlen(b)
for z in liste:
z.geprueft = eingang["empfangen"]
z.kennzahl = zahlen.get(z.id)
return {"ziele": [z.als_dict() for z in liste],
"bericht": {"empfangen": eingang["empfangen"], "veraltet": jetzt - eingang["empfangen"] > BERICHT_ALT_S},
"ausfuehrer": ausfuehrer}
def _kennzahlen(bericht: dict) -> dict:
"""Die Live-Zahlen der Kacheln (kennzahlen.py). Scheitert etwas, zeigen die Kacheln eben keine — die Liste der
Ziele darf daran nie hängen."""
from services.homelab import kennzahlen # braucht zertifikate, adguard und arcane — erst hier
try:
return kennzahlen.fuer_bericht(bericht)
except Exception:
return {}
def erreichbarkeit() -> list[dict]:
"""Für den Wächter: jede Weboberfläche laufender, freigegebener Gäste — ohne GitHub-Abfragen. Gäste mitten in
einem Update-Lauf bleiben außen vor: Sicherung, Update und Zurückspielen legen sie kurz still, und was dabei
+68
View File
@@ -0,0 +1,68 @@
"""Eine Live-Zahl je Gerät für die Kacheln des Homelab-Cockpits und der Übersicht (Ausbauplan Welle 1, seit 25.09.2026).
Proxmox-Host wie viele Gäste laufen (aus dem Bericht)
PBS wann zuletzt gesichert wurde (jüngste Sicherung auf den Zielen der Aufträge)
NPMplus wie es um seine Zertifikate steht (Zertifikats-Wache, zertifikate.py)
Arcane laufende von allen Containern (Arcane-API mit dem Schlüssel aus den Einstellungen)
AdGuard Home Anteil geblockter Anfragen (nur mit hinterlegtem Zugang, Einstellungen; adguard.py)
Je Ziel-ID {"text", "zeit"?, "stufe"?}: „zeit“ setzt die Oberfläche als „vor …“ dahinter, „stufe“ (gelb/rot) färbt die
Zeile. Was sich nicht ermitteln lässt, fehlt — die Kachel zeigt dann nur ihren Stand. Arcane und AdGuard werden
höchstens alle zwei Minuten gefragt.
"""
import logging
from services.homelab import adguard, apps, arcane, zertifikate
log = logging.getLogger(__name__)
NPM = {"npmplus", "nginxproxymanager"}
def _komma(zahl: float) -> str:
return f"{zahl:.1f}".replace(".", ",") if zahl < 10 else str(round(zahl))
def _npm(vmid: int) -> dict | None:
eigene = [z for z in zertifikate.lage()["zertifikate"] if z["quelle"] == "npm" and z["gast"] == vmid]
if not eigene:
return None
probleme = [z for z in eigene if z["stufe"] != "ok"]
if not probleme:
return {"text": f"{len(eigene)} Zertifikat{'e' if len(eigene) != 1 else ''} gültig"}
stufe = "rot" if any(z["stufe"] == "rot" for z in probleme) else "gelb"
return {"text": f"{len(probleme)} von {len(eigene)} Zertifikaten mit Problem", "stufe": stufe}
def fuer_bericht(bericht: dict) -> dict[str, dict]:
"""Ziel-ID → Kennzahl. Ein Fehler bei einem Gerät kostet nur dessen Zahl."""
zahlen: dict[str, dict] = {}
gaeste = [g for g in bericht.get("gaeste") or [] if isinstance(g, dict) and "vmid" in g]
if gaeste:
laufend = sum(1 for g in gaeste if g.get("status") == "running")
zahlen["pve"] = {"text": f"{laufend} von {len(gaeste)} Gästen an"}
ziele = (bericht.get("host") or {}).get("sicherungsziele") or {}
for g in gaeste:
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
if not app or g.get("status") != "running":
continue
zid = f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
try:
if app.kennung == "proxmox-backup-server":
zuletzt = max((t for z in ziele.values() if isinstance(z, dict)
for t in (z.get("neueste") or {}).values() if isinstance(t, int)), default=None)
if zuletzt:
zahlen[zid] = {"text": "letzte Sicherung", "zeit": zuletzt}
elif app.kennung in NPM:
if zahl := _npm(g["vmid"]):
zahlen[zid] = zahl
elif app.kennung == "arcane" and (url := apps.adresse(app, g.get("ip"))):
container = arcane.container_zahlen(url.rstrip("/"))
if container and container["gesamt"]:
zahlen[zid] = {"text": f"{container['laufend']} von {container['gesamt']} Containern an"}
elif app.kennung == "adguard":
statistik = adguard.geblockt()
if statistik and statistik.get("anteil") is not None:
zahlen[zid] = {"text": f"{_komma(statistik['anteil'])} % geblockt"}
except Exception:
log.warning("kennzahlen: %s ließ sich nicht ermitteln", zid, exc_info=True)
return zahlen