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,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}
|
||||
Reference in New Issue
Block a user