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
+147 -2
View File
@@ -54,6 +54,7 @@ Aufruf: ausfuehrer.py Dauerbetrieb (systemd)
import argparse
import base64
import glob
import io
import ipaddress
import json
import logging
@@ -63,6 +64,7 @@ import re
import socket
import subprocess
import sys
import tarfile
import threading
import time
import urllib.error
@@ -1153,6 +1155,142 @@ def _platten_stand() -> list[dict]:
return _platten["stand"]
# --- Konfig-Sicherung (seit 26.09.2026, nur lesend) --------------------------------------------------------
# Die Einstellungen von Proxmox (Gäste, Speicher, Sicherungsaufträge, Netz), AdGuard und NPMplus, geschwärzt HIER —
# Geheimnisse verlassen den Host nicht. Gesendet an den Homelab-Teil (POST /api/homelab/ausfuehrer/konfig), alle
# KONFIG_ALLE_S und auf Abruf (Aktion konfig_senden); die KI-Box legt sie in ein privates Gitea-Repo. Die Regeln sind
# dieselben wie in backend/kern/schwaerzen.py (ein Test prüft das).
KONFIG_ALLE_S = 6 * 3600
KONFIG_HOST = ("/etc/pve/lxc", "/etc/pve/qemu-server", "/etc/pve/storage.cfg", "/etc/pve/jobs.cfg",
"/etc/pve/datacenter.cfg", "/etc/pve/replication.cfg", "/etc/network/interfaces")
KONFIG_GAST = {
"adguard": ("opt/AdGuardHome/AdGuardHome.yaml",),
"npmplus": ("opt/npmplus/nginx/proxy_host", "opt/npmplus/nginx/redirection_host", "opt/npmplus/nginx/stream",
"opt/npmplus/nginx/dead_host", "opt/npmplus/access", "opt/npmplus/custom_nginx"),
}
KONFIG_NAMEN = {"adguard": ("adguard", "adguardhome", "adguard-home"), "npmplus": ("npmplus", "nginxproxymanager")}
KONFIG_DATEI_MAX = 256 * 1024
KONFIG_GESAMT_MAX = 4 * 1024 * 1024
SCHWARZ = "«geschwärzt»"
_SCHWARZ_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]))")
SCHWAERZEN = (
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.DOTALL),
f"-----BEGIN PRIVATE KEY-----\n{SCHWARZ}\n-----END PRIVATE KEY-----"),
(re.compile(rf"(?im)^([ \t]*(?:-[ \t]*)?[\"']?[\w.-]*{_SCHWARZ_WORT}[\w.-]*[\"']?[ \t]*[:=][ \t]*)(?![ \t]*$)(?!{SCHWARZ})(.+)$"),
rf"\g<1>{SCHWARZ}"),
(re.compile(r"(?im)^(\s*proxy_set_header\s+(?:authorization|x-api-key|x-auth-token|cookie)\s+)(\"[^\"]*\"|\S+?)(;?)$"),
rf'\g<1>"{SCHWARZ}"\g<3>'),
(re.compile(r"\$(?:2[abxy]|argon2(?:id|i|d)|apr1|[156])\$[^\s\"',;]+"), SCHWARZ),
(re.compile(r"(?m)^([\w.@-]+):(?:\{SHA\}|\$)\S+$"), rf"\g<1>:{SCHWARZ}"),
(re.compile(r"(\b[a-z][a-z0-9+.-]*://[^\s:/@]+:)[^\s@/]+@"), rf"\g<1>{SCHWARZ}@"),
(re.compile(r"(?i)\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}"), rf"\g<1> {SCHWARZ}"),
(re.compile(r"\b(?:sk-|hf_|gh[pousr]_)[A-Za-z0-9_-]{16,}"), SCHWARZ),
(re.compile(r"\b[0-9a-fA-F]{32,}\b"), SCHWARZ),
)
_konfig_zuletzt = {"zeit": 0.0}
def schwaerzen(text: str) -> tuple[str, int]:
zahl = 0
for muster, ersatz in SCHWAERZEN:
text, n = muster.subn(ersatz, text)
zahl += n
return text, zahl
def _textdatei(roh: bytes) -> str | None:
if b"\x00" in roh[:4096]:
return None
return roh.decode("utf-8", errors="replace")
def _host_konfig() -> dict[str, str]:
dateien: dict[str, str] = {}
for pfad in KONFIG_HOST:
kandidaten = sorted(glob.glob(os.path.join(pfad, "*"))) if os.path.isdir(pfad) else [pfad]
for datei in kandidaten:
try:
if os.path.isfile(datei) and os.path.getsize(datei) <= KONFIG_DATEI_MAX:
with open(datei, "rb") as f:
if (text := _textdatei(f.read())) is not None:
dateien["proxmox/" + datei.removeprefix("/etc/").removeprefix("pve/")] = text
except OSError:
continue
return dateien
def _gast_konfig(vmid: int, pfade: tuple[str, ...], ziel: str) -> dict[str, str]:
"""Dateien aus einem Container per tar über pct exec — nur die, die es gibt (BusyBox-tar bricht sonst ab)."""
liste = " ".join(pfade)
befehl = f'cd / && vorhanden="" && for p in {liste}; do [ -e "$p" ] && vorhanden="$vorhanden $p"; done; ' \
'[ -n "$vorhanden" ] && tar -cf - $vorhanden'
try:
r = subprocess.run(["pct", "exec", str(vmid), "--", "sh", "-c", befehl], capture_output=True, timeout=60)
except (OSError, subprocess.SubprocessError):
return {}
dateien: dict[str, str] = {}
try:
with tarfile.open(fileobj=io.BytesIO(r.stdout), mode="r:") as archiv:
for teil in archiv.getmembers():
if not teil.isfile() or teil.size > KONFIG_DATEI_MAX:
continue
f = archiv.extractfile(teil)
if f is None or (text := _textdatei(f.read())) is None:
continue
name = teil.name.removeprefix("./").removeprefix("opt/").removeprefix("AdGuardHome/")
dateien[f"{ziel}/{name.removeprefix(ziel + '/')}"] = text
except (tarfile.TarError, EOFError):
return dateien
return dateien
def _gast_fuer(art: str) -> int | None:
for eintrag in _pvesh("/cluster/resources", "--type", "vm") or []:
if eintrag.get("node") == NODE and eintrag.get("type") == "lxc" \
and str(eintrag.get("name") or "").lower() in KONFIG_NAMEN[art] \
and erlaubt(_etiketten(eintrag.get("tags"))):
return int(eintrag["vmid"])
return None
def konfig_sammeln() -> dict:
"""{"zeit", "dateien": {Pfad im Repo: geschwärzter Text}, "geschwaerzt": Zahl, "fehler": [...]}."""
dateien, fehler = _host_konfig(), []
for art, pfade in KONFIG_GAST.items():
vmid = _gast_fuer(art)
if vmid is None:
fehler.append(f"{art}: kein Container gefunden")
continue
gefunden = _gast_konfig(vmid, pfade, art)
if not gefunden:
fehler.append(f"{art}: keine Dateien gelesen (Container {vmid})")
dateien.update(gefunden)
geschwaerzt, gesamt, ergebnis = 0, 0, {}
for pfad in sorted(dateien):
text, n = schwaerzen(dateien[pfad])
gesamt += len(text)
if gesamt > KONFIG_GESAMT_MAX:
fehler.append("zu groß — der Rest fehlt")
break
ergebnis[pfad], geschwaerzt = text, geschwaerzt + n
return {"zeit": time.time(), "dateien": ergebnis, "geschwaerzt": geschwaerzt, "fehler": fehler}
def konfig_senden() -> dict:
daten = konfig_sammeln()
_anfrage("POST", "/api/homelab/ausfuehrer/konfig", daten)
_konfig_zuletzt["zeit"] = time.time()
return daten
def a_konfig_senden(p: dict) -> tuple[int, str]:
"""Auf Abruf (Knopf „Jetzt sichern“, Lauf der KI-Box): sammeln, schwärzen, senden. Nur lesend."""
daten = konfig_senden()
return 0, f"dateien={len(daten['dateien'])}\ngeschwaerzt={daten['geschwaerzt']}\n" + "\n".join(daten["fehler"])
# --- Geräte im Heimnetz (seit 26.09.2026, nur lesend) ----------------------------------------------------
# AdGuard sieht in diesem Netz fast nur die Fritzbox (sie reicht die Anfragen der meisten Geräte weiter). Wer im
# Heimnetz ist, verrät deshalb die Nachbartabelle dieses Hosts: Alle NETZ_ALLE_S ein Ping an jede Adresse des Netzes
@@ -1532,9 +1670,10 @@ AKTIONEN = {"snapshot": a_snapshot, "update": a_update, "os_update": a_os_update
"host_neustart": a_host_neustart, "bericht": a_bericht,
"sichern": a_sichern, "sicherung_zurueck": a_sicherung_zurueck, "sicherung_loeschen": a_sicherung_loeschen,
"kernel_aufraeumen": a_kernel_aufraeumen, "sicherung_probe": a_sicherung_probe,
"etikett_setzen": a_etikett_setzen, "changelog": a_changelog, "journal": a_journal}
"etikett_setzen": a_etikett_setzen, "changelog": a_changelog, "journal": a_journal,
"konfig_senden": a_konfig_senden}
# Was auch im Nur-Lesen-Modus geht: Es liest nur.
NUR_LESEND = {a_bericht, a_sicherung_probe, a_changelog, a_journal}
NUR_LESEND = {a_bericht, a_sicherung_probe, a_changelog, a_journal, a_konfig_senden}
def ausfuehren(auftrag: dict) -> dict:
@@ -1784,6 +1923,12 @@ def dauerbetrieb() -> None:
daten = bericht()
_anfrage("POST", "/api/homelab/ausfuehrer/bericht", daten)
naechster_bericht = time.time() + naechster_bericht_in(daten)
if time.time() - _konfig_zuletzt["zeit"] >= KONFIG_ALLE_S:
try:
konfig_senden()
except Exception as exc: # die Sicherung darf den Dauerbetrieb nie aufhalten
log.warning("Konfig nicht gesendet: %s", exc)
_konfig_zuletzt["zeit"] = time.time()
auftrag = _anfrage("GET", "/api/homelab/ausfuehrer/auftrag")
if isinstance(auftrag, dict) and auftrag.get("id"):
log.info("Auftrag %s: %s %s", auftrag["id"], auftrag.get("aktion"), auftrag.get("parameter"))