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,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
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 …
|
||||
|
||||
@@ -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}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user