Files
mission-control-v2/backend/services/homelab/einstellungen.py
T
HitonabiandClaude Opus 5.5 ad8dbbc1b3 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>
2026-09-25 16:52:41 +02:00

192 lines
10 KiB
Python

"""Einstellungen des Homelab-Teils (24.09.2026): was sich unter „Einstellungen“ für das Homelab einstellen lässt.
• Arcane-API-Schlüssel eintragen oder löschen. Vor dem Speichern prüft Arcane ihn mit denselben Aufrufen, die die
Übersicht braucht; lehnt es ab oder antwortet nicht, wird nichts gespeichert. Er liegt in
<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.
"""
import json
import logging
import re
import time
from services.homelab import adguard, apps, arcane, inventar, kanal, karenz
log = logging.getLogger(__name__)
UMGEBUNG_SCHLUESSEL = ("Der Arcane-Schlüssel kommt aus der Umgebung (MC_ARCANE_KEY in /etc/mc2/homelab.env) und "
"gilt vor einem hier eingetragenen. Erst dort entfernen, dann hier eintragen.")
_FORM = re.compile(r"[\x21-\x7e]{8,512}") # druckbares ASCII ohne Leerzeichen, wie ihn Arcane ausgibt
def arcane_url() -> str | None:
"""Adresse der Arcane-Oberfläche aus dem Bericht des Ausführers (die VM, deren Name „arcane“ ist)."""
eingang = kanal.bericht()
for gast in ((eingang 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 == "arcane" and (url := apps.adresse(app, gast.get("ip"))):
return url.rstrip("/")
return None
def stand() -> dict:
"""GET /api/homelab/einstellungen."""
zuletzt = kanal.zuletzt()
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
sicherung = host.get("sicherung") if isinstance(host.get("sicherung"), dict) else {}
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")),
"sicherung_speicher": sicherung.get("speicher") or None},
}
def schluessel_aus_body(roh: bytes) -> str | None:
"""Der Schlüssel aus dem Body {"schluessel": "…"} — ohne Pydantic, dessen Fehlermeldung (422) die Eingabe
wiederholen würde. None, wenn der Body nicht passt."""
try:
daten = json.loads(roh.decode("utf-8")) if roh else None
except (UnicodeDecodeError, ValueError):
return None
wert = daten.get("schluessel") if isinstance(daten, dict) else None
return wert if isinstance(wert, str) else None
def _abgelehnt(ergebnis: dict, url: str) -> str:
status, art = ergebnis.get("status"), ergebnis.get("art")
if art == "abgelehnt":
text = f"Arcane lehnt den Schlüssel ab (HTTP {status})."
if status == 403:
text += " Ihm fehlt das Recht, Umgebungen und Images zu lesen."
return text
if art == "http":
return f"Arcane antwortet mit HTTP {status}. Der Schlüssel ist nicht gespeichert."
if art == "netz":
return f"Arcane ist nicht erreichbar ({url}). Der Schlüssel ist nicht gespeichert."
return "Arcane antwortet nicht wie erwartet. Der Schlüssel ist nicht gespeichert."
def arcane_schluessel_setzen(wert: str | None) -> dict:
"""POST /api/homelab/einstellungen/arcane-schluessel: erst gegen Arcane prüfen, dann speichern."""
if arcane.schluessel_quelle() == "umgebung":
return {"ok": False, "detail": UMGEBUNG_SCHLUESSEL}
wert = (wert or "").strip()
if not _FORM.fullmatch(wert):
return {"ok": False, "detail": "Das sieht nicht wie ein Arcane-API-Schlüssel aus (leer, zu kurz oder mit "
"Leerzeichen). Nichts gespeichert."}
url = arcane_url()
if not url:
return {"ok": False, "detail": "Arcane steht nicht im Bericht des Ausführers, die Adresse ist unbekannt. "
"Nichts gespeichert."}
ergebnis = arcane.schluessel_pruefen(url, wert)
if not ergebnis["ok"]:
log.info("homelab: Arcane-Schlüssel nicht übernommen (%s, HTTP %s)", ergebnis.get("art"), ergebnis.get("status"))
return {"ok": False, "detail": _abgelehnt(ergebnis, url)}
try:
arcane.schluessel_speichern(wert)
except OSError as exc:
return {"ok": False, "detail": f"Arcane nimmt den Schlüssel an, aber er ließ sich nicht speichern "
f"({exc.__class__.__name__})."}
images, neu, anzahl = ergebnis["images"], ergebnis["mit_update"], ergebnis["umgebungen"]
log.info("homelab: Arcane-Schlüssel gespeichert (%d Images geprüft)", images)
return {"ok": True, "text": (f"Arcane nimmt den Schlüssel an: {images} Images in {anzahl} "
f"{'Umgebung' if anzahl == 1 else 'Umgebungen'} geprüft, {neu} davon mit neuerem "
"Stand. Die Übersicht zeigt die Docker-Images ab sofort.")}
def arcane_schluessel_loeschen() -> dict:
"""DELETE /api/homelab/einstellungen/arcane-schluessel: die Datei weg."""
try:
da = arcane.schluessel_loeschen()
except OSError as exc:
return {"ok": False, "detail": f"Der Schlüssel ließ sich nicht löschen ({exc.__class__.__name__})."}
text = "Der Arcane-Schlüssel ist gelöscht." if da else "Es war kein Arcane-Schlüssel hinterlegt."
if arcane.schluessel_quelle() == "umgebung":
text += " Die Umgebung setzt weiter einen (MC_ARCANE_KEY); der gilt."
return {"ok": True, "text": text}
def arcane_echt_setzen(an: bool) -> dict:
"""POST /api/homelab/einstellungen/arcane-echt: echte Docker-Updates statt Probelauf."""
if arcane.echt_quelle() == "umgebung":
return {"ok": False, "detail": f"Die Umgebung legt das fest (MC_ARCANE_ECHT in /etc/mc2/homelab.env: Docker-"
f"Updates laufen {'echt' if arcane.echt() else 'als Probelauf'}). Sie gewinnt "
"vor diesem Schalter."}
try:
arcane.echt_speichern(an)
except OSError as exc:
return {"ok": False, "detail": f"Der Schalter ließ sich nicht speichern ({exc.__class__.__name__})."}
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."}