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
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user