Files
mission-control-v2/backend/services/homelab/konfig.py
T
HitonabiandClaude Opus 5.5 75c6037431 Später · 20 Konfig-Sicherung ins private Gitea-Repo, Geheimnisse geschwärzt
- kern/schwaerzen.py + dieselben Regeln im Ausführer (Test vergleicht beide): private Schlüssel, Werte hinter
  password/secret/token/key…, Passwort-Hashes, htpasswd, Zugangsdaten in Adressen, Authorization-Kopfzeilen,
  Token-Formate, lange Hex-Ketten
- Ausführer: sammelt Proxmox (Gäste, Speicher, Aufträge, Netz), AdGuard, NPMplus (ohne tls/), schwärzt auf dem Host,
  sendet alle 6 h und auf Abruf (Aktion konfig_senden) an POST /api/homelab/ausfuehrer/konfig
- Homelab-Teil: GET /api/homelab/konfig (frisch=1), dazu Compose und .env der Arcane-Projekte, geschwärzt
- KI-Box: services/konfig_sicherung.py legt das Repo beim ersten Lauf privat an, Arbeitskopie je Lauf auf Gitea-Stand,
  Commit nur bei Änderungen; Timer täglich 02:30, Knopf „Jetzt sichern“ unter Einstellungen → Homelab
- Totmannschalter und Wächter kennen den Timer; Doku

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 14:23:19 +02:00

111 lines
5.0 KiB
Python

"""Konfig-Sicherung — der Anteil des Homelab-Teils (Ausbauplan „Später“, Punkt 20, seit 26.09.2026).
Der Ausführer sammelt die Einstellungen von Proxmox, AdGuard und NPMplus, schwärzt sie schon auf dem Host und schickt
sie alle sechs Stunden (und auf Abruf, Aktion konfig_senden) an POST /api/homelab/ausfuehrer/konfig; hier liegen sie
in <Datenordner>/konfig-host.json (0600). Dazu kommen die Compose-Dateien und .env der Arcane-Projekte über Arcanes
Schnittstelle, geschwärzt mit kern/schwaerzen.py. Die KI-Box holt beides über GET /api/homelab/konfig und legt es in
ein privates Gitea-Repo (services/konfig_sicherung.py) — so braucht dieser Container keinen Gitea-Zugang.
"""
import json
import logging
import os
import re
import threading
import time
from pathlib import Path
import httpx
from kern.einstellungen import einstellungen
from kern.schwaerzen import schwaerzen
from services.homelab import arcane, kanal
log = logging.getLogger(__name__)
AUFFRISCHEN_S = 120
_NAME = re.compile(r"[\w.@+-]{1,80}")
_lock = threading.Lock()
def _pfad() -> Path:
return einstellungen().daten_dir / "konfig-host.json"
def speichern(daten: object) -> dict:
"""POST /api/homelab/ausfuehrer/konfig — schon geschwärzt; nur Text, nur sinnvolle Pfade."""
d = daten if isinstance(daten, dict) else {}
dateien = {str(k): str(v) for k, v in (d.get("dateien") or {}).items()
if isinstance(k, str) and isinstance(v, str) and ".." not in k and not k.startswith("/")}
stand = {"zeit": float(d.get("zeit") or time.time()), "dateien": dateien,
"geschwaerzt": int(d.get("geschwaerzt") or 0), "fehler": [str(f)[:200] for f in d.get("fehler") or []]}
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(stand, f, ensure_ascii=False)
os.replace(tmp, pfad)
return {"ok": True, "dateien": len(dateien)}
def _lesen() -> dict:
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
return {"zeit": None, "dateien": {}, "geschwaerzt": 0, "fehler": ["Der Ausführer hat noch keine Konfig geschickt."]}
return daten if isinstance(daten, dict) else {"zeit": None, "dateien": {}, "geschwaerzt": 0, "fehler": []}
def auffrischen() -> str | None:
"""Den Ausführer jetzt sammeln lassen. None = geklappt, sonst der Grund."""
auftrag = kanal.anlegen("konfig_senden")
ergebnis = kanal.warten(auftrag, AUFFRISCHEN_S)
if not ergebnis:
return "Der Ausführer hat nicht rechtzeitig geantwortet; es gilt sein letzter Stand."
if ergebnis.get("status") != "fertig":
return f"Der Ausführer konnte nicht sammeln: {str(ergebnis.get('text') or '')[:200]}"
return None
def arcane_projekte() -> tuple[dict[str, str], int, list[str]]:
"""Compose-Datei und .env jedes Arcane-Projekts, geschwärzt: ({Pfad: Text}, Stellen, Fehler)."""
from services.homelab.einstellungen import arcane_url
url = arcane_url()
if not url or not arcane.schluessel():
return {}, 0, ["Arcane: ohne Adresse oder API-Schlüssel keine Projekte."]
dateien: dict[str, str] = {}
zahl, fehler = 0, []
try:
for umgebung in arcane.umgebungen(url):
projekte = (arcane._get(url, f"/environments/{umgebung['id']}/projects?limit=200") or {}).get("data") or []
for p in projekte:
name = str(p.get("name") or "")
if not _NAME.fullmatch(name) or p.get("id") is None:
continue
d = (arcane._get(url, f"/environments/{umgebung['id']}/projects/{p['id']}/compose") or {}).get("data") or {}
datei = str(d.get("composeFileName") or "compose.yaml")
for pfad, inhalt in ((f"arcane/{name}/{datei if _NAME.fullmatch(datei) else 'compose.yaml'}",
d.get("composeContent")), (f"arcane/{name}/.env", d.get("envContent"))):
if isinstance(inhalt, str) and inhalt.strip():
text, n = schwaerzen(inhalt)
dateien[pfad], zahl = text, zahl + n
except (httpx.HTTPError, ValueError, TypeError, AttributeError) as exc:
fehler.append(f"Arcane: {exc.__class__.__name__}")
return dateien, zahl, fehler
def lage(frisch: bool = False) -> dict:
"""Für GET /api/homelab/konfig (die KI-Box): alle geschwärzten Dateien des Homelabs."""
fehler: list[str] = []
if frisch and (grund := auffrischen()):
fehler.append(grund)
host = _lesen()
arcane_dateien, arcane_zahl, arcane_fehler = arcane_projekte()
return {"zeit": host.get("zeit"), "dateien": {**(host.get("dateien") or {}), **arcane_dateien},
"geschwaerzt": int(host.get("geschwaerzt") or 0) + arcane_zahl,
"fehler": fehler + list(host.get("fehler") or []) + arcane_fehler}