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>
This commit is contained in:
Hitonabi
2026-09-26 14:23:19 +02:00
co-authored by Claude Opus 5.5
parent 2972cdfeca
commit 75c6037431
43 changed files with 937 additions and 40 deletions
+247
View File
@@ -0,0 +1,247 @@
"""Konfig-Sicherung ins eigene Gitea (Ausbauplan „Später“, Punkt 20, seit 26.09.2026).
User-Entscheid 25.09.2026: privates Repo, Geheimnisse geschwärzt. Täglich (mc2-konfig-sicherung.timer, 02:30) und per
Knopf holt die KI-Box die geschwärzten Einstellungen des Homelabs (Proxmox, AdGuard, NPMplus, Arcane-Projekte —
GET /api/homelab/konfig?frisch=1, services/homelab/konfig.py) und legt ihre eigenen dazu (llama-swap, Hermes-Config,
SOUL.md, Hermes-Cron-Jobs; geschwärzt mit kern/schwaerzen.py). Alles wandert in das private Repo
<Gitea>/<Benutzer>/homelab-konfig: Jede Änderung ist dort als Vorher-nachher sichtbar und lässt sich zurückholen.
Committet wird nur, wenn sich etwas geändert hat.
Die Arbeitskopie (~/konfig-sicherung) ist Wegwerfware: Vor jedem Lauf wird sie auf den Stand in Gitea gesetzt, dann mit
dem heutigen Stand überschrieben. Der Zugang ist der vorhandene der Box (~/.git-credentials, auch für Gitea-API und
Anlegen des Repos beim ersten Lauf); er erscheint nie in einer Befehlszeile oder Ausgabe.
"""
import json
import logging
import os
import re
import shutil
import subprocess
import threading
import time
from datetime import datetime
from pathlib import Path
from urllib.parse import unquote, urlsplit
import httpx
from kern.einstellungen import einstellungen
from kern.schwaerzen import schwaerzen
from kern.zeit import LOCAL_TZ
log = logging.getLogger(__name__)
REPO = "homelab-konfig"
GITEA_URL = os.environ.get("MC_GITEA_URL", "http://192.168.178.153:3000").rstrip("/")
GIT_ZUGANG = Path(os.environ.get("MC_GIT_ZUGANG", str(Path.home() / ".git-credentials")))
ARBEIT = Path(os.environ.get("MC_KONFIG_ARBEIT", str(Path.home() / "konfig-sicherung")))
AUTOR = ("Homelab Orchestrator", "orchestrator@homelab.local")
BEREICHE = ("proxmox", "adguard", "npmplus", "arcane", "ki-box")
_PFAD = re.compile(r"(?:proxmox|adguard|npmplus|arcane|ki-box)/[\w./@+-]{1,200}")
BOX_DATEIEN = {
"ki-box/llama-swap/config.yaml": Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml")),
"ki-box/hermes/config.yaml": Path.home() / ".hermes" / "config.yaml",
"ki-box/hermes/SOUL.md": Path.home() / ".hermes" / "SOUL.md",
"ki-box/hermes/cron-jobs.json": Path.home() / ".hermes" / "cron" / "jobs.json",
}
README = """# Konfig-Sicherung des Homelab Orchestrators
Geschrieben vom Homelab Orchestrator (KI-Box), täglich um 02:30 und per Knopf unter Einstellungen → Homelab.
Committet wird nur, wenn sich etwas geändert hat — die Geschichte dieses Repos ist die Geschichte der Einstellungen.
| Ordner | Woher |
|---|---|
| `proxmox/` | Proxmox-Host: Gäste (`lxc/`, `qemu-server/`), Speicher, Sicherungsaufträge, Netz |
| `adguard/` | AdGuard Home (`AdGuardHome.yaml`) |
| `npmplus/` | NPMplus: Proxy-Hosts, Weiterleitungen, Streams, Zugangslisten, eigene nginx-Schnipsel |
| `arcane/` | Arcane: Compose-Datei und `.env` je Projekt |
| `ki-box/` | KI-Box: llama-swap, Hermes-Config, `SOUL.md`, Hermes-Cron-Jobs |
**Geheimnisse sind geschwärzt** (`«geschwärzt»`): Passwörter und ihre Hashes, Tokens, API-Schlüssel, private Schlüssel.
Wer eine Datei zurückholt, trägt sie selbst wieder ein.
Zurückholen: in Gitea die Datei öffnen → „Verlauf“ → den gewünschten Stand ansehen und den Inhalt übernehmen.
"""
_lock = threading.Lock()
def _stand_pfad() -> Path:
return einstellungen().daten_dir / "konfig-sicherung.json"
def zugang() -> tuple[str, str] | None:
"""(Benutzer, Token) für Gitea aus ~/.git-credentials. Erscheint nie in einer Ausgabe."""
try:
zeilen = GIT_ZUGANG.read_text(encoding="utf-8").splitlines()
except OSError:
return None
ziel = urlsplit(GITEA_URL).hostname
ersatz = None
for zeile in zeilen:
u = urlsplit(zeile.strip())
if not (u.username and u.password and u.hostname):
continue
host = unquote(u.hostname).split(":", 1)[0]
if host == ziel:
return unquote(u.username), unquote(u.password)
if "ddnsfree" in host and ersatz is None:
ersatz = (unquote(u.username), unquote(u.password))
return ersatz
def repo_sicherstellen(benutzer: str, token: str) -> str:
"""Das private Repo — beim ersten Lauf angelegt. Rückgabe: die Klon-Adresse (ohne Zugangsdaten)."""
kopf = {"Authorization": f"token {token}"}
r = httpx.get(f"{GITEA_URL}/api/v1/repos/{benutzer}/{REPO}", headers=kopf, timeout=15)
if r.status_code == 404:
neu = httpx.post(f"{GITEA_URL}/api/v1/user/repos", headers=kopf, timeout=15, json={
"name": REPO, "private": True, "auto_init": False, "default_branch": "main",
"description": "Konfig-Sicherung des Homelab Orchestrators (Geheimnisse geschwärzt)"})
neu.raise_for_status()
log.info("konfig-sicherung: Repo %s/%s angelegt (privat)", benutzer, REPO)
else:
r.raise_for_status()
return f"{GITEA_URL}/{benutzer}/{REPO}.git"
def _git(*args: str, zeitlimit: int = 90) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(ARBEIT), *args], capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=zeitlimit, env={**os.environ, "GIT_TERMINAL_PROMPT": "0"})
def box_dateien() -> dict[str, str]:
dateien = {}
for ziel, quelle in BOX_DATEIEN.items():
try:
dateien[ziel] = quelle.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
return dateien
def _homelab(frisch: bool) -> dict:
"""Direkt beim Homelab-Teil (nicht über die Durchreiche: frisch=1 dauert bis zu zwei Minuten, die Durchreiche
wartet nur 30 Sekunden)."""
basis = einstellungen().partner_url
if not basis:
return {"dateien": {}, "geschwaerzt": 0, "fehler": ["Es ist kein Homelab-Teil eingerichtet."]}
try:
r = httpx.get(f"{basis}/api/homelab/konfig", params={"frisch": int(frisch)}, timeout=200)
r.raise_for_status()
daten = r.json()
except (httpx.HTTPError, ValueError) as exc:
return {"dateien": {}, "geschwaerzt": 0, "fehler": [f"Homelab nicht erreichbar ({exc.__class__.__name__})."]}
return daten if isinstance(daten, dict) else {"dateien": {}, "geschwaerzt": 0, "fehler": []}
def _schreiben(dateien: dict[str, str]) -> None:
"""Die Arbeitskopie mit dem heutigen Stand überschreiben (was es nicht mehr gibt, fällt weg)."""
for bereich in BEREICHE:
shutil.rmtree(ARBEIT / bereich, ignore_errors=True)
for pfad, text in dateien.items():
ziel = ARBEIT / pfad
ziel.parent.mkdir(parents=True, exist_ok=True)
ziel.write_text(text, encoding="utf-8", newline="\n")
(ARBEIT / "README.md").write_text(README, encoding="utf-8", newline="\n")
def _vorbereiten(url: str) -> None:
if not (ARBEIT / ".git").exists():
ARBEIT.mkdir(parents=True, exist_ok=True)
_git("init", "-q", "-b", "main")
_git("remote", "add", "origin", url)
else:
_git("remote", "set-url", "origin", url)
_git("fetch", "-q", "origin")
if _git("rev-parse", "--verify", "-q", "origin/main").returncode == 0:
_git("reset", "-q", "--hard", "origin/main")
def sichern(frisch: bool = True, jetzt: float | None = None) -> dict:
"""Ein Lauf: sammeln, schreiben, bei Änderungen committen und pushen. Rückgabe ok + text oder detail."""
if not _lock.acquire(blocking=False):
return {"ok": False, "detail": "Die Konfig-Sicherung läuft gerade schon."}
jetzt = time.time() if jetzt is None else jetzt
try:
homelab = _homelab(frisch)
dateien: dict[str, str] = {p: t for p, t in (homelab.get("dateien") or {}).items()
if isinstance(p, str) and isinstance(t, str) and _PFAD.fullmatch(p) and ".." not in p}
geschwaerzt = int(homelab.get("geschwaerzt") or 0)
for pfad, text in box_dateien().items():
dateien[pfad], n = schwaerzen(text)
geschwaerzt += n
fehler = [str(f) for f in homelab.get("fehler") or []]
if not dateien:
return _ende(jetzt, "fehler", "Keine Einstellungen bekommen." + (f" {'; '.join(fehler)}" if fehler else ""))
z = zugang()
if not z:
return _ende(jetzt, "fehler", "Kein Gitea-Zugang in ~/.git-credentials.")
try:
url = repo_sicherstellen(*z)
except httpx.HTTPError as exc:
return _ende(jetzt, "fehler", f"Gitea antwortet nicht wie erwartet ({exc.__class__.__name__}).")
for versuch in range(2):
_vorbereiten(url)
_schreiben(dateien)
_git("add", "-A")
aenderungen = [zeile.split("\t", 1) for zeile in _git("diff", "--cached", "--name-status").stdout.splitlines()
if "\t" in zeile]
if not aenderungen:
return _ende(jetzt, "unveraendert", f"Keine Änderung ({len(dateien)} Dateien).", dateien=len(dateien),
geschwaerzt=geschwaerzt, fehler=fehler)
zaehlung = {"A": 0, "M": 0, "D": 0}
for art, _ in aenderungen:
zaehlung[art[:1]] = zaehlung.get(art[:1], 0) + 1
wann = datetime.fromtimestamp(jetzt, LOCAL_TZ).strftime("%d.%m.%Y %H:%M")
nachricht = (f"Konfig-Sicherung {wann}: {zaehlung['M']} geändert, {zaehlung['A']} neu, {zaehlung['D']} weg\n\n"
+ "\n".join(f"{art} {pfad}" for art, pfad in aenderungen[:80]))
_git("-c", f"user.name={AUTOR[0]}", "-c", f"user.email={AUTOR[1]}", "commit", "-q", "-m", nachricht)
gesendet = _git("push", "-q", "origin", "HEAD:main", zeitlimit=120)
if gesendet.returncode == 0:
commit = _git("rev-parse", "--short", "HEAD").stdout.strip()
return _ende(jetzt, "gesichert", f"{len(aenderungen)} Änderungen gesichert ({zaehlung['M']} geändert, "
f"{zaehlung['A']} neu, {zaehlung['D']} weg).", commit=commit, dateien=len(dateien),
geschwaerzt=geschwaerzt, fehler=fehler, aenderungen=[p for _, p in aenderungen[:40]])
log.warning("konfig-sicherung: push abgelehnt (Versuch %d): %s", versuch + 1, gesendet.stderr.strip()[:200])
return _ende(jetzt, "fehler", "Gitea nimmt den Stand nicht an (push abgelehnt).")
except (OSError, subprocess.SubprocessError) as exc:
return _ende(jetzt, "fehler", f"Git ging nicht ({exc.__class__.__name__}).")
finally:
_lock.release()
def _ende(jetzt: float, ergebnis: str, text: str, **mehr: object) -> dict:
z = zugang()
stand = {"zeit": jetzt, "ergebnis": ergebnis, "text": text,
"repo": f"{GITEA_URL}/{z[0]}/{REPO}" if z else None, **mehr}
try:
pfad = _stand_pfad()
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_suffix(".tmp")
tmp.write_text(json.dumps(stand, ensure_ascii=False), encoding="utf-8")
tmp.replace(pfad)
except OSError:
log.warning("konfig-sicherung: Stand nicht schreibbar", exc_info=True)
log.info("konfig-sicherung: %s — %s", ergebnis, text)
if ergebnis == "fehler":
return {"ok": False, "detail": text}
return {"ok": True, "text": text}
def im_hintergrund() -> dict:
"""Knopf „Jetzt sichern“: im Hintergrund, die Oberfläche fragt danach stand()."""
if _lock.locked():
return {"ok": False, "detail": "Die Konfig-Sicherung läuft gerade schon."}
threading.Thread(target=sichern, name="konfig-sicherung", daemon=True).start()
return {"ok": True, "text": "Die Sicherung läuft (bis zu zwei Minuten); das Ergebnis steht gleich hier."}
def stand() -> dict:
"""Für GET /api/konfig-sicherung: der letzte Lauf und wo das Repo liegt."""
try:
zuletzt = json.loads(_stand_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
zuletzt = None
z = zugang()
return {"zuletzt": zuletzt if isinstance(zuletzt, dict) else None,
"repo": f"{GITEA_URL}/{z[0]}/{REPO}" if z else None}