Files
mission-control-v2/backend/tests/test_konfig_sicherung.py
T
HitonabiandClaude Opus 5.5 75c6037431 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>
2026-09-26 14:23:19 +02:00

184 lines
9.1 KiB
Python

"""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")