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
+43
View File
@@ -0,0 +1,43 @@
"""Geheimnisse schwärzen (Ausbauplan „Später“, Punkt 20, seit 26.09.2026) — für die Konfig-Sicherung in Gitea.
User-Entscheid 25.09.2026: privates Repo, Geheimnisse geschwärzt. Geschwärzt wird, was nach Zugangsdaten aussieht:
• private Schlüssel (PEM-Blöcke)
• Werte hinter Schlüsseln wie password, secret, token, api_key, key, salt, cookie, credential (YAML, INI, .env,
„- NAME=wert“ in Compose)
• Passwort-Hashes (bcrypt, argon2, sha-crypt, apr1) und htpasswd-Zeilen
• Zugangsdaten in Adressen (scheme://benutzer:passwort@…), Authorization-/API-Kopfzeilen in nginx
• typische Token-Formate (sk-…, hf_…, gh?_…) und lange Hex-Ketten (≥ 32 Zeichen, etwa API-Schlüssel)
Lieber zu viel als zu wenig: Beim Zurückholen trägt der User Geheimnisse ohnehin selbst wieder ein.
Dieselben Regeln stehen im Ausführer (deploy/homelab/ausfuehrer.py, SCHWAERZEN), damit Geheimnisse den Proxmox-Host gar
nicht erst verlassen; ein Test prüft, dass beide gleich schwärzen.
"""
import re
ERSATZ = "«geschwärzt»"
_WORT = (r"(?:pass(?:word|wd|phrase)?|secret|token|api[_-]?key|apikey|private[_-]?key|credential|salt|cookie|"
r"auth[_-]?key|access[_-]?key|client[_-]?secret|jwt|key(?![a-z]))")
REGELN: tuple[tuple[re.Pattern, str], ...] = (
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.DOTALL),
f"-----BEGIN PRIVATE KEY-----\n{ERSATZ}\n-----END PRIVATE KEY-----"),
(re.compile(rf"(?im)^([ \t]*(?:-[ \t]*)?[\"']?[\w.-]*{_WORT}[\w.-]*[\"']?[ \t]*[:=][ \t]*)(?![ \t]*$)(?!{ERSATZ})(.+)$"),
rf"\g<1>{ERSATZ}"),
(re.compile(r"(?im)^(\s*proxy_set_header\s+(?:authorization|x-api-key|x-auth-token|cookie)\s+)(\"[^\"]*\"|\S+?)(;?)$"),
rf'\g<1>"{ERSATZ}"\g<3>'),
(re.compile(r"\$(?:2[abxy]|argon2(?:id|i|d)|apr1|[156])\$[^\s\"',;]+"), ERSATZ),
(re.compile(r"(?m)^([\w.@-]+):(?:\{SHA\}|\$)\S+$"), rf"\g<1>:{ERSATZ}"),
(re.compile(r"(\b[a-z][a-z0-9+.-]*://[^\s:/@]+:)[^\s@/]+@"), rf"\g<1>{ERSATZ}@"),
(re.compile(r"(?i)\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}"), rf"\g<1> {ERSATZ}"),
(re.compile(r"\b(?:sk-|hf_|gh[pousr]_)[A-Za-z0-9_-]{16,}"), ERSATZ),
(re.compile(r"\b[0-9a-fA-F]{32,}\b"), ERSATZ),
)
def schwaerzen(text: str) -> tuple[str, int]:
"""(geschwärzter Text, Zahl der Stellen)."""
zahl = 0
for muster, ersatz in REGELN:
text, n = muster.subn(ersatz, text)
zahl += n
return text, zahl
+17
View File
@@ -156,6 +156,23 @@ def start() -> dict:
}
@router.get("/konfig-sicherung")
def konfig_sicherung_stand() -> dict:
"""Der letzte Lauf der Konfig-Sicherung und wo das Repo liegt (seit 26.09.2026)."""
from services import konfig_sicherung
return konfig_sicherung.stand()
@router.post("/konfig-sicherung")
def konfig_sicherung_starten(warten: bool = True) -> dict:
"""Timer (warten=1, scheitert mit 409) und Knopf (warten=0, läuft im Hintergrund)."""
from services import konfig_sicherung
ergebnis = konfig_sicherung.sichern() if warten else konfig_sicherung.im_hintergrund()
if not ergebnis.get("ok"):
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Die Sicherung ging nicht.")
return ergebnis
@router.get("/ausreisser")
def ausreisser() -> dict:
"""Lernstand der Ausreißer-Erkennung und was gerade auffällt (seit 26.09.2026)."""
+15
View File
@@ -134,6 +134,14 @@ def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict:
return _antwort(waechter.fuehre_aktion_aus(hinweis_id, aktion_id))
@router.get("/konfig")
async def konfig_lage(frisch: bool = False) -> dict:
"""Alle geschwärzten Einstellungen des Homelabs für die Konfig-Sicherung der KI-Box (seit 26.09.2026). frisch=1
lässt den Ausführer erst neu sammeln (bis zu zwei Minuten) — deshalb neben dem Ereignis-Takt."""
from services.homelab import konfig
return await asyncio.to_thread(konfig.lage, frisch)
@router.get("/ausreisser")
def ausreisser() -> dict:
"""Lernstand der Ausreißer-Erkennung im Homelab und was gerade auffällt (seit 26.09.2026)."""
@@ -429,6 +437,13 @@ def bericht(daten: dict) -> dict:
return {"ok": True}
@router.post("/ausfuehrer/konfig", dependencies=[Depends(_nur_ausfuehrer)])
def ausfuehrer_konfig(daten: dict) -> dict:
"""Die geschwärzten Einstellungen vom Ausführer (Konfig-Sicherung, seit 26.09.2026)."""
from services.homelab import konfig
return konfig.speichern(daten)
class Ergebnis(BaseModel):
code: int
text: str = ""
+1
View File
@@ -54,6 +54,7 @@ ZEITPLAENE: tuple[Zeitplan, ...] = (
Zeitplan("mc2-probe-wiederherstellung", "Probe-Wiederherstellung", 35 * TAG, "erster Montag im Monat 05:15"),
Zeitplan("projekte-sync", "Projekte-Abgleich", 3 * STUNDE, "stündlich"),
Zeitplan("mc2-wochenbericht", "Wochenbericht", 7 * TAG + 6 * STUNDE, "sonntags 07:30"),
Zeitplan("mc2-konfig-sicherung", "Konfig-Sicherung", TAG + 3 * STUNDE, "täglich 02:30"),
)
HERMES_KARENZ_S = STUNDE
CACHE_S = 300 # der Wächter fragt jede Minute; die Zeitpläne ändern sich nicht so schnell
+1 -1
View File
@@ -34,7 +34,7 @@ except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
"host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen",
"kernel_aufraeumen", "sicherung_probe", "etikett_setzen", "changelog", "journal"}
"kernel_aufraeumen", "sicherung_probe", "etikett_setzen", "changelog", "journal", "konfig_senden"}
VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet)
BEHALTEN = 500 # so viele erledigte Aufträge bleiben sichtbar (Protokoll; bis 24.09.2026: 60)
VOLLTEXT = 60 # die jüngsten erledigten behalten ihre ganze Ausgabe …
+110
View File
@@ -0,0 +1,110 @@
"""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}
+4 -1
View File
@@ -74,8 +74,11 @@ _AKTION = {
"Änderungslisten"),
# Seit 25.09.2026: Lucy liest das Protokoll eines Containers, wenn sie einen Hinweis untersucht.
"journal": ("Protokoll gelesen (Lucy)", "Protokoll lesen gescheitert", "Protokoll wird gelesen", "Protokoll lesen"),
# Seit 26.09.2026: Konfig-Sicherung — der Ausführer sammelt und schwärzt die Einstellungen.
"konfig_senden": ("Konfig gesammelt", "Konfig sammeln gescheitert", "Konfig wird gesammelt", "Konfig sammeln"),
}
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe", "changelog"}
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe", "changelog",
"konfig_senden"}
_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
"sicherheit": "Sicherheitsupdate",
"neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"}
+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}
+2
View File
@@ -117,6 +117,8 @@ TIMER_DIENSTE: dict[str, tuple[str, bool]] = {
"pbs-backup": ("Sicherung auf den PBS", False),
# Seit 25.09.2026 (Ausbauplan Welle 3): sonntags 07:30 der Wochenbericht in Lucys Stimme.
"mc2-wochenbericht": ("Wochenbericht", False),
# Seit 26.09.2026: täglich 02:30 die Konfig-Sicherung ins private Gitea-Repo (Ausbauplan „Später“, Punkt 20).
"mc2-konfig-sicherung": ("Konfig-Sicherung", False),
}
HERMES_JOBS_PATH = HERMES_HOME / "cron" / "jobs.json"
+183
View File
@@ -0,0 +1,183 @@
"""Konfig-Sicherung ins eigene Gitea (Ausbauplan „Später“, Punkt 20, 26.09.2026). User-Entscheid: privates Repo,
Geheimnisse geschwärzt. Der Ausführer schwärzt schon auf dem Proxmox-Host (dieselben Regeln wie kern/schwaerzen.py),
die KI-Box committet nur bei Änderungen."""
import importlib.util
import io
import json
import shutil
import subprocess
import tarfile
from pathlib import Path
import pytest
from kern import einstellungen as einstellungen_mod
from kern.schwaerzen import ERSATZ, REGELN, schwaerzen
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
GIT = shutil.which("git")
ADGUARD = """http:
address: 0.0.0.0:80
session_ttl: 720h
users:
- name: admin
password: $2y$10$abcdefghijklmnopqrstuuWXYZ0123456789abcdefghijklmnopq
tls:
private_key: ''
certificate_path: /opt/cert.pem
"""
PEM = "-----BEGIN RSA PRIVATE KEY-----\nMIIEow\nabc\n-----END RSA PRIVATE KEY-----"
COMPOSE = """services:
db:
image: postgres@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
environment:
- POSTGRES_PASSWORD=supergeheim
- POSTGRES_USER=rippy
"""
NGINX = """location / {
proxy_set_header Authorization "Basic dXNlcjpwYXNzd29yZA==";
auth_basic_user_file /data/access/1;
proxy_pass http://paperless:Geheim99@192.168.178.40:8000;
}
"""
@pytest.fixture(scope="module")
def a():
spec = importlib.util.spec_from_file_location("ausfuehrer_konfig", AUSFUEHRER)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
def test_schwaerzen():
text, n = schwaerzen(ADGUARD)
assert "$2y$" not in text and f"password: {ERSATZ}" in text and f"private_key: {ERSATZ}" in text
assert "session_ttl: 720h" in text and "certificate_path: /opt/cert.pem" in text and n == 2
assert schwaerzen(PEM)[0] == f"-----BEGIN PRIVATE KEY-----\n{ERSATZ}\n-----END PRIVATE KEY-----"
compose, _ = schwaerzen(COMPOSE)
assert f"- POSTGRES_PASSWORD={ERSATZ}" in compose and "- POSTGRES_USER=rippy" in compose
assert "0123456789abcdef" not in compose # lange Hex-Ketten (hier ein Digest) fallen mit
nginx, _ = schwaerzen(NGINX)
assert f'proxy_set_header Authorization "{ERSATZ}";' in nginx and "Geheim99" not in nginx
assert "auth_basic_user_file /data/access/1;" in nginx
assert schwaerzen("Hitonabi:$apr1$xyz$abcdefgh")[0] == f"Hitonabi:{ERSATZ}"
assert schwaerzen("keyboard: de\nkey: abc\napi_key: sk-1234567890abcdefgh\n")[0] == \
f"keyboard: de\nkey: {ERSATZ}\napi_key: {ERSATZ}\n"
assert schwaerzen("hf_token: hf_abcdefghijklmnopqrstu")[0] == f"hf_token: {ERSATZ}"
assert schwaerzen("password:\n - x")[0] == "password:\n - x" # leerer Wert (Liste folgt) bleibt
def test_ausfuehrer_schwaerzt_genauso(a):
assert [(m.pattern, m.flags, e) for m, e in a.SCHWAERZEN] == [(m.pattern, m.flags, e) for m, e in REGELN]
for probe in (ADGUARD, PEM, COMPOSE, NGINX):
assert a.schwaerzen(probe) == schwaerzen(probe)
def test_ausfuehrer_liest_aus_dem_container(a, monkeypatch):
puffer = io.BytesIO()
with tarfile.open(fileobj=puffer, mode="w") as archiv:
for name, inhalt in (("opt/npmplus/nginx/proxy_host/14.conf", b"server { }"), ("opt/npmplus/access/1", b"u:$apr1$x"),
("opt/npmplus/bild.png", b"\x89PNG\x00\x00")):
teil = tarfile.TarInfo(name)
teil.size = len(inhalt)
archiv.addfile(teil, io.BytesIO(inhalt))
befehle = []
def lauf(befehl, capture_output, timeout):
befehle.append(befehl)
return subprocess.CompletedProcess(befehl, 0, stdout=puffer.getvalue(), stderr=b"")
monkeypatch.setattr(a.subprocess, "run", lauf)
dateien = a._gast_konfig(101, a.KONFIG_GAST["npmplus"], "npmplus")
assert dateien == {"npmplus/nginx/proxy_host/14.conf": "server { }", "npmplus/access/1": "u:$apr1$x"}
assert befehle[0][:5] == ["pct", "exec", "101", "--", "sh"] and "opt/npmplus/tls" not in befehle[0][-1]
# --- Homelab-Teil ----------------------------------------------------------------------------------------------
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
einstellungen_mod.einstellungen.cache_clear()
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
def test_homelab_nimmt_an_und_legt_arcane_dazu(daten, monkeypatch):
from services.homelab import konfig
assert konfig.speichern({"zeit": 1.0, "dateien": {"proxmox/storage.cfg": "dir: local", "../boese": "x", "/etc/x": "y"},
"geschwaerzt": 3, "fehler": []}) == {"ok": True, "dateien": 1}
monkeypatch.setattr(konfig, "arcane_projekte", lambda: ({"arcane/rippy/.env": f"TOKEN={ERSATZ}"}, 1, []))
lage = konfig.lage()
assert lage["dateien"] == {"proxmox/storage.cfg": "dir: local", "arcane/rippy/.env": f"TOKEN={ERSATZ}"}
assert lage["geschwaerzt"] == 4 and lage["fehler"] == []
def test_arcane_projekte_geschwaerzt(daten, monkeypatch):
from services.homelab import einstellungen as he
from services.homelab import konfig
antworten = {
"/environments/0/projects?limit=200": {"data": [{"id": "p1", "name": "rippy"}, {"id": "p2", "name": "../x"}]},
"/environments/0/projects/p1/compose": {"data": {"composeFileName": "compose.yaml", "composeContent": COMPOSE,
"envContent": "TMDB_KEY=abc123\nZEITZONE=Europe/Berlin\n"}},
}
monkeypatch.setattr(he, "arcane_url", lambda: "http://arcane:3552")
monkeypatch.setattr(konfig.arcane, "schluessel", lambda: "k")
monkeypatch.setattr(konfig.arcane, "umgebungen", lambda url: [{"id": 0}])
monkeypatch.setattr(konfig.arcane, "_get", lambda url, pfad: antworten[pfad])
dateien, zahl, fehler = konfig.arcane_projekte()
assert sorted(dateien) == ["arcane/rippy/.env", "arcane/rippy/compose.yaml"] and fehler == []
assert dateien["arcane/rippy/.env"] == f"TMDB_KEY={ERSATZ}\nZEITZONE=Europe/Berlin\n" and zahl == 3
# --- KI-Box: Git ------------------------------------------------------------------------------------------------
@pytest.mark.skipif(GIT is None, reason="braucht git")
def test_box_committet_nur_aenderungen(daten, tmp_path, monkeypatch):
from services import konfig_sicherung as ks
fern = tmp_path / "fern.git"
subprocess.run([GIT, "init", "-q", "--bare", "-b", "main", str(fern)], check=True)
monkeypatch.setattr(ks, "ARBEIT", tmp_path / "arbeit")
monkeypatch.setattr(ks, "zugang", lambda: ("Hitonabi", "t"))
monkeypatch.setattr(ks, "repo_sicherstellen", lambda benutzer, token: fern.as_posix())
monkeypatch.setattr(ks, "box_dateien", lambda: {"ki-box/hermes/config.yaml": "model: hirn\napi_key: sk-abcdefghijklmnopqrs\n"})
homelab = {"dateien": {"proxmox/storage.cfg": "dir: local", "adguard/AdGuardHome.yaml": "dns: {}"}, "geschwaerzt": 0,
"fehler": [], }
monkeypatch.setattr(ks, "_homelab", lambda frisch: homelab)
erstes = ks.sichern(jetzt=1_790_450_000.0)
assert erstes["ok"] and erstes["text"].startswith("4 Änderungen gesichert (0 geändert, 4 neu, 0 weg)")
log = subprocess.run([GIT, "--git-dir", str(fern), "log", "--format=%s", "main"], capture_output=True, text=True).stdout
assert log.startswith("Konfig-Sicherung ")
inhalt = subprocess.run([GIT, "--git-dir", str(fern), "show", "main:ki-box/hermes/config.yaml"], capture_output=True,
text=True, encoding="utf-8").stdout
assert inhalt == f"model: hirn\napi_key: {ERSATZ}\n"
assert ks.sichern(jetzt=1_790_450_100.0) == {"ok": True, "text": "Keine Änderung (3 Dateien)."}
homelab["dateien"] = {"proxmox/storage.cfg": "dir: local\npbs: pbs-qnap", "adguard/AdGuardHome.yaml": "dns: {}"}
drittes = ks.sichern(jetzt=1_790_450_200.0)
assert drittes["text"].startswith("1 Änderungen gesichert (1 geändert, 0 neu, 0 weg)")
stand = ks.stand()
assert stand["zuletzt"]["ergebnis"] == "gesichert" and stand["repo"].endswith("/Hitonabi/homelab-konfig")
def test_box_ohne_dateien_oder_zugang(daten, tmp_path, monkeypatch):
from services import konfig_sicherung as ks
monkeypatch.setattr(ks, "box_dateien", dict)
monkeypatch.setattr(ks, "_homelab", lambda frisch: {"dateien": {}, "fehler": ["Homelab nicht erreichbar (ConnectError)."]})
assert ks.sichern() == {"ok": False, "detail": "Keine Einstellungen bekommen. Homelab nicht erreichbar (ConnectError)."}
monkeypatch.setattr(ks, "_homelab", lambda frisch: {"dateien": {"proxmox/a.cfg": "x"}})
monkeypatch.setattr(ks, "zugang", lambda: None)
assert ks.sichern()["detail"] == "Kein Gitea-Zugang in ~/.git-credentials."
assert json.loads((daten / "konfig-sicherung.json").read_text(encoding="utf-8"))["ergebnis"] == "fehler"
def test_zugang_aus_git_credentials(tmp_path, monkeypatch):
from services import konfig_sicherung as ks
datei = tmp_path / "creds"
datei.write_text("https://Hitonabi:anderer@git.tobisniceshomelab.ddnsfree.com\n"
"http://Hitonabi:t%40ken@192.168.178.153%3a3000\n", encoding="utf-8")
monkeypatch.setattr(ks, "GIT_ZUGANG", datei)
assert ks.zugang() == ("Hitonabi", "t@ken")