"""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 /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}