- 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>
111 lines
5.0 KiB
Python
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}
|