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:
co-authored by
Claude Opus 5.5
parent
2972cdfeca
commit
75c6037431
@@ -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}
|
||||
Reference in New Issue
Block a user