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
+43
View File
@@ -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
+17
View File
@@ -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") @router.get("/ausreisser")
def ausreisser() -> dict: def ausreisser() -> dict:
"""Lernstand der Ausreißer-Erkennung und was gerade auffällt (seit 26.09.2026).""" """Lernstand der Ausreißer-Erkennung und was gerade auffällt (seit 26.09.2026)."""
+15
View File
@@ -134,6 +134,14 @@ def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict:
return _antwort(waechter.fuehre_aktion_aus(hinweis_id, aktion_id)) 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") @router.get("/ausreisser")
def ausreisser() -> dict: def ausreisser() -> dict:
"""Lernstand der Ausreißer-Erkennung im Homelab und was gerade auffällt (seit 26.09.2026).""" """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} 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): class Ergebnis(BaseModel):
code: int code: int
text: str = "" text: str = ""
+1
View File
@@ -54,6 +54,7 @@ ZEITPLAENE: tuple[Zeitplan, ...] = (
Zeitplan("mc2-probe-wiederherstellung", "Probe-Wiederherstellung", 35 * TAG, "erster Montag im Monat 05:15"), Zeitplan("mc2-probe-wiederherstellung", "Probe-Wiederherstellung", 35 * TAG, "erster Montag im Monat 05:15"),
Zeitplan("projekte-sync", "Projekte-Abgleich", 3 * STUNDE, "stündlich"), Zeitplan("projekte-sync", "Projekte-Abgleich", 3 * STUNDE, "stündlich"),
Zeitplan("mc2-wochenbericht", "Wochenbericht", 7 * TAG + 6 * STUNDE, "sonntags 07:30"), 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 HERMES_KARENZ_S = STUNDE
CACHE_S = 300 # der Wächter fragt jede Minute; die Zeitpläne ändern sich nicht so schnell CACHE_S = 300 # der Wächter fragt jede Minute; die Zeitpläne ändern sich nicht so schnell
+1 -1
View File
@@ -34,7 +34,7 @@ except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen", AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
"host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_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) 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) 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 … VOLLTEXT = 60 # die jüngsten erledigten behalten ihre ganze Ausgabe …
+110
View File
@@ -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}
+4 -1
View File
@@ -74,8 +74,11 @@ _AKTION = {
"Änderungslisten"), "Änderungslisten"),
# Seit 25.09.2026: Lucy liest das Protokoll eines Containers, wenn sie einen Hinweis untersucht. # 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"), "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", _LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
"sicherheit": "Sicherheitsupdate", "sicherheit": "Sicherheitsupdate",
"neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"} "neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"}
+247
View File
@@ -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}
+2
View File
@@ -117,6 +117,8 @@ TIMER_DIENSTE: dict[str, tuple[str, bool]] = {
"pbs-backup": ("Sicherung auf den PBS", False), "pbs-backup": ("Sicherung auf den PBS", False),
# Seit 25.09.2026 (Ausbauplan Welle 3): sonntags 07:30 der Wochenbericht in Lucys Stimme. # Seit 25.09.2026 (Ausbauplan Welle 3): sonntags 07:30 der Wochenbericht in Lucys Stimme.
"mc2-wochenbericht": ("Wochenbericht", False), "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" HERMES_JOBS_PATH = HERMES_HOME / "cron" / "jobs.json"
+183
View File
@@ -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")
+4 -3
View File
@@ -36,10 +36,11 @@ UNITS="mission-control-2.service mc2-gateway.service mc2-steward.service lucy-st
mc2-morgenmeldung.service mc2-morgenmeldung.timer mc2-autoupdate.service mc2-autoupdate.timer mc2-morgenmeldung.service mc2-morgenmeldung.timer mc2-autoupdate.service mc2-autoupdate.timer
projekte-sync.service projekte-sync.timer projekte-sync.service projekte-sync.timer
mc2-probe-wiederherstellung.service mc2-probe-wiederherstellung.timer mc2-probe-wiederherstellung.service mc2-probe-wiederherstellung.timer
pbs-backup.service pbs-backup.timer mc2-wochenbericht.service mc2-wochenbericht.timer" pbs-backup.service pbs-backup.timer mc2-wochenbericht.service mc2-wochenbericht.timer
mc2-konfig-sicherung.service mc2-konfig-sicherung.timer"
AKTIV="mission-control-2.service mc2-gateway.service mc2-steward.service lucy-stimme.service AKTIV="mission-control-2.service mc2-gateway.service mc2-steward.service lucy-stimme.service
mc2-radar.timer mc2-backup.timer mc2-morgenmeldung.timer mc2-autoupdate.timer projekte-sync.timer mc2-radar.timer mc2-backup.timer mc2-morgenmeldung.timer mc2-autoupdate.timer projekte-sync.timer
mc2-probe-wiederherstellung.timer pbs-backup.timer mc2-wochenbericht.timer" mc2-probe-wiederherstellung.timer pbs-backup.timer mc2-wochenbericht.timer mc2-konfig-sicherung.timer"
# Skills, die abgelöst sind: aus Lucys Skill-Index nehmen (verschoben, nicht gelöscht). Dazu die # Skills, die abgelöst sind: aus Lucys Skill-Index nehmen (verschoben, nicht gelöscht). Dazu die
# alten Bindestrich-Doppel der übrigen Skills (deploy.sh schreibt die Unterstrich-Fassung). # alten Bindestrich-Doppel der übrigen Skills (deploy.sh schreibt die Unterstrich-Fassung).
SKILLS_ALT="autonomie konzept-fliessband llm-wiki morning-report orchestrator projekt-start pruefstand SKILLS_ALT="autonomie konzept-fliessband llm-wiki morning-report orchestrator projekt-start pruefstand
@@ -181,7 +182,7 @@ stufe2() {
systemctl --user enable $aktiv >/dev/null 2>&1 systemctl --user enable $aktiv >/dev/null 2>&1
# shellcheck disable=SC2086 # shellcheck disable=SC2086
systemctl --user start mc2-backup.timer mc2-morgenmeldung.timer projekte-sync.timer mc2-probe-wiederherstellung.timer \ systemctl --user start mc2-backup.timer mc2-morgenmeldung.timer projekte-sync.timer mc2-probe-wiederherstellung.timer \
pbs-backup.timer mc2-wochenbericht.timer pbs-backup.timer mc2-wochenbericht.timer mc2-konfig-sicherung.timer
# Den Radar-Timer nur ohne Nachholen starten (Persistent=true): Läuft er nicht, erst den Stempel auf jetzt # Den Radar-Timer nur ohne Nachholen starten (Persistent=true): Läuft er nicht, erst den Stempel auf jetzt
# setzen — sonst holt er den verpassten Lauf sofort nach, mitten am Tag. # setzen — sonst holt er den verpassten Lauf sofort nach, mitten am Tag.
if [ "$radar_an" = 1 ] && ! systemctl --user is-active --quiet mc2-radar.timer; then if [ "$radar_an" = 1 ] && ! systemctl --user is-active --quiet mc2-radar.timer; then
+147 -2
View File
@@ -54,6 +54,7 @@ Aufruf: ausfuehrer.py Dauerbetrieb (systemd)
import argparse import argparse
import base64 import base64
import glob import glob
import io
import ipaddress import ipaddress
import json import json
import logging import logging
@@ -63,6 +64,7 @@ import re
import socket import socket
import subprocess import subprocess
import sys import sys
import tarfile
import threading import threading
import time import time
import urllib.error import urllib.error
@@ -1153,6 +1155,142 @@ def _platten_stand() -> list[dict]:
return _platten["stand"] 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) ---------------------------------------------------- # --- 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 # 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 # 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, "host_neustart": a_host_neustart, "bericht": a_bericht,
"sichern": a_sichern, "sicherung_zurueck": a_sicherung_zurueck, "sicherung_loeschen": a_sicherung_loeschen, "sichern": a_sichern, "sicherung_zurueck": a_sicherung_zurueck, "sicherung_loeschen": a_sicherung_loeschen,
"kernel_aufraeumen": a_kernel_aufraeumen, "sicherung_probe": a_sicherung_probe, "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. # 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: def ausfuehren(auftrag: dict) -> dict:
@@ -1784,6 +1923,12 @@ def dauerbetrieb() -> None:
daten = bericht() daten = bericht()
_anfrage("POST", "/api/homelab/ausfuehrer/bericht", daten) _anfrage("POST", "/api/homelab/ausfuehrer/bericht", daten)
naechster_bericht = time.time() + naechster_bericht_in(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") auftrag = _anfrage("GET", "/api/homelab/ausfuehrer/auftrag")
if isinstance(auftrag, dict) and auftrag.get("id"): if isinstance(auftrag, dict) and auftrag.get("id"):
log.info("Auftrag %s: %s %s", auftrag["id"], auftrag.get("aktion"), auftrag.get("parameter")) log.info("Auftrag %s: %s %s", auftrag["id"], auftrag.get("aktion"), auftrag.get("parameter"))
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=MC2 Konfig-Sicherung (Einstellungen von Proxmox, AdGuard, NPMplus, Arcane, KI-Box geschwärzt nach Gitea)
After=network-online.target mission-control-2.service
[Service]
Type=oneshot
# Die Arbeit macht MC2 (services/konfig_sicherung.py); -f lässt die Unit scheitern, wenn MC2 ablehnt — dann meldet
# der Wächter „Konfig-Sicherung scheitert beim letzten Lauf“.
ExecStart=/usr/bin/curl -sf -o /dev/null -m 900 -X POST "http://127.0.0.1:9001/api/konfig-sicherung?warten=1"
TimeoutStartSec=960
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Täglich 02:30 die Konfig-Sicherung ins private Gitea-Repo
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.target
+5
View File
@@ -268,6 +268,11 @@ Eine eigene Seite mit fünf Karten: KI-Box, Homelab, Lucy, Meldungen, Software-S
- **NAS-Platten (SNMP, Homelab):** Am QNAP unter Systemsteuerung → Netzwerk- und Dateidienste → SNMP den Dienst - **NAS-Platten (SNMP, Homelab):** Am QNAP unter Systemsteuerung → Netzwerk- und Dateidienste → SNMP den Dienst
einschalten (SNMP v1/v2) und einen Zugangsnamen („Community“) wählen, der nicht „public“ heißt; den hier eintragen. einschalten (SNMP v1/v2) und einen Zugangsnamen („Community“) wählen, der nicht „public“ heißt; den hier eintragen.
„Verbindung testen und speichern“ fragt das NAS erst damit — antwortet es, erscheinen seine Platten im Cockpit. „Verbindung testen und speichern“ fragt das NAS erst damit — antwortet es, erscheinen seine Platten im Cockpit.
- **Konfig-Sicherung (Homelab, seit 26.09.):** Täglich um 02:30 legt der Orchestrator die Einstellungen von Proxmox
(Gäste, Speicher, Sicherungsaufträge, Netz), AdGuard, NPMplus, den Arcane-Projekten und der KI-Box in dein privates
Gitea-Repo `homelab-konfig` — Passwörter, Schlüssel und Tokens geschwärzt. Gespeichert wird nur, wenn sich etwas
geändert hat; in Gitea siehst du jede Änderung als Vorher-nachher und holst eine Datei über ihren Verlauf zurück
(Geheimnisse trägst du dann selbst wieder ein). Hier: der letzte Lauf, „Jetzt sichern“ und „In Gitea ansehen“.
- **Arcane-API-Schlüssel (Homelab):** in Arcane unter Einstellungen → API-Schlüssel anlegen und hier einfügen. „Prüfen - **Arcane-API-Schlüssel (Homelab):** in Arcane unter Einstellungen → API-Schlüssel anlegen und hier einfügen. „Prüfen
und speichern“ fragt Arcane zuerst, ob der Schlüssel gilt; erst dann wird er gespeichert — im Homelab-Teil, nicht und speichern“ fragt Arcane zuerst, ob der Schlüssel gilt; erst dann wird er gespeichert — im Homelab-Teil, nicht
+21
View File
@@ -359,6 +359,27 @@ roten Hinweise per HTTP dorthin (`MC_PARTNER_URL` aus `/etc/mc2/homelab.env`).
hermes-gateway` (der Deploy startet das Gateway bewusst nicht neu). Prüfen: hermes-gateway` (der Deploy startet das Gateway bewusst nicht neu). Prüfen:
`grep "homelab-orchestrator" ~/.hermes/logs/agent.log | tail -1` nennt die Zahl der Werkzeuge. `grep "homelab-orchestrator" ~/.hermes/logs/agent.log | tail -1` nennt die Zahl der Werkzeuge.
## Konfig-Sicherung (seit 26.09.)
Privates Gitea-Repo `Hitonabi/homelab-konfig` (User-Entscheid 25.09.: privat, Geheimnisse geschwärzt). Ablauf:
- **Ausführer** (`konfig_sammeln`, nur lesend): `/etc/pve/{lxc,qemu-server}/*.conf`, `storage.cfg`, `jobs.cfg`,
`datacenter.cfg`, `replication.cfg`, `/etc/network/interfaces`; aus AdGuard (Container mit Namen `adguard`)
`AdGuardHome.yaml`, aus NPMplus (`npmplus`) die nginx-Konfigs, Zugangslisten und eigenen Schnipsel — nie `tls/`.
Geschwärzt wird schon auf dem Host (`SCHWAERZEN`, dieselben Regeln wie `backend/kern/schwaerzen.py`, ein Test
vergleicht beide); gesendet alle 6 h und auf Abruf (Aktion `konfig_senden`) an `POST /api/homelab/ausfuehrer/konfig`
→ `/var/lib/mc2/konfig-host.json` (0600).
- **Homelab-Teil:** `GET /api/homelab/konfig?frisch=1` = Ausführer neu sammeln lassen, dazu Compose-Datei und `.env`
jedes Arcane-Projekts (Arcane-API `/environments/{id}/projects/{id}/compose`), geschwärzt.
- **KI-Box** (`services/konfig_sicherung.py`, Timer `mc2-konfig-sicherung.timer` täglich 02:30, Knopf in den
Einstellungen): holt das direkt beim Homelab-Teil, legt `/etc/llama-swap/config.yaml`, `~/.hermes/config.yaml`,
`~/.hermes/SOUL.md` und `~/.hermes/cron/jobs.json` geschwärzt dazu. Arbeitskopie `~/konfig-sicherung` (Wegwerfware:
vor jedem Lauf `reset --hard origin/main`), Commit nur bei Änderungen, Push über den vorhandenen Zugang in
`~/.git-credentials` (auch zum Anlegen des Repos beim ersten Lauf). Stand: `/srv/models/konfig-sicherung.json`,
`curl -s http://127.0.0.1:9001/api/konfig-sicherung`. Von Hand: `curl -s -X POST
'http://127.0.0.1:9001/api/konfig-sicherung?warten=1'`.
- **Zurückholen:** in Gitea die Datei → Verlauf → Stand ansehen, Inhalt übernehmen; geschwärzte Stellen
(`«geschwärzt»`) von Hand wieder füllen.
## Ausreißer ohne feste Schwellen (seit 26.09.) ## Ausreißer ohne feste Schwellen (seit 26.09.)
`backend/kern/ausreisser.py` lernt je Messreihe (Box: `box`; Homelab: `pve`, `ct-*`, `vm-*`) und Größe (cpu, gpu, ram, `backend/kern/ausreisser.py` lernt je Messreihe (Box: `box`; Homelab: `pve`, `ct-*`, `vm-*`) und Größe (cpu, gpu, ram,
+8
View File
@@ -58,6 +58,14 @@ Commits (flacher Klon, `--ancestry-path`).
Beobachten am 27.09.: das erste Wartungsfenster (6 Sicherheitsupdates am Proxmox-Host, weitere in Gästen), der erste Beobachten am 27.09.: das erste Wartungsfenster (6 Sicherheitsupdates am Proxmox-Host, weitere in Gästen), der erste
Wochenbericht und der Sonntagslauf der Box mit dem Hermes-Update (281 Commits über den neuen Paketmanager). Wochenbericht und der Sonntagslauf der Box mit dem Hermes-Update (281 Commits über den neuen Paketmanager).
**„Später“ erledigt (26.09.2026 nachmittags):** 19 Handy-Ansicht `/handy` als installierbare App (kein Web-Push);
21 neue Geräte im Netz — AdGuard sieht fast nur die Fritzbox, deshalb über die Nachbartabelle des Proxmox-Hosts;
18 Plattengesundheit (Host über Proxmox, Box über smartctl, NAS über SNMP); 22 Ausreißer ohne feste Schwellen (meldet
ab etwa Mitte Oktober, wenn drei Wochen Erfahrung da sind); 20 Konfig-Sicherung ins private Gitea-Repo
`homelab-konfig`, täglich 02:30, Geheimnisse geschwärzt.
Offen beim User: **SNMP am QNAP** einschalten und den Zugangsnamen unter Einstellungen → Homelab eintragen (sonst
keine NAS-Platten). Danach: die Android-App (Phase 5) gemeinsam planen.
## Offene Einzelpunkte (Homelab) ## Offene Einzelpunkte (Homelab)
- **Zugang von außen:** Der Eintrag `mc.tobisniceshomelab.ddnsfree.com` in NPMplus (Proxy-Host 14 → Box :9001) hatte - **Zugang von außen:** Der Eintrag `mc.tobisniceshomelab.ddnsfree.com` in NPMplus (Proxy-Host 14 → Box :9001) hatte
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{i as e,r as t}from"./dist-aE7UmubC.js";import{G as n,K as r}from"./index-Cdq4iteU.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-DG7u1mB-.js";var l=t(),u=e();function d(e){let t=(0,l.c)(8),{onSchliessen:d}=e,f;t[0]===d?f=t[1]:(f=e=>!e&&d(),t[0]=d,t[1]=f);let p;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(n,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=p):p=t[2];let m;t[3]===d?m=t[4]:(m=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-7 border-white/[0.06] bg-[#0a0d14] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[p,(0,u.jsx)(r,{onGewaehlt:d})]}),t[3]=d,t[4]=m);let h;return t[5]!==f||t[6]!==m?(h=(0,u.jsx)(c,{open:!0,onOpenChange:f,children:m}),t[5]=f,t[6]=m,t[7]=h):h=t[7],h}export{d as MenueSchublade}; import{i as e,r as t}from"./dist-aE7UmubC.js";import{G as n,K as r}from"./index-hnnYWPzT.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-DG7u1mB-.js";var l=t(),u=e();function d(e){let t=(0,l.c)(8),{onSchliessen:d}=e,f;t[0]===d?f=t[1]:(f=e=>!e&&d(),t[0]=d,t[1]=f);let p;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(n,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=p):p=t[2];let m;t[3]===d?m=t[4]:(m=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-7 border-white/[0.06] bg-[#0a0d14] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[p,(0,u.jsx)(r,{onGewaehlt:d})]}),t[3]=d,t[4]=m);let h;return t[5]!==f||t[6]!==m?(h=(0,u.jsx)(c,{open:!0,onOpenChange:f,children:m}),t[5]=f,t[6]=m,t[7]=h):h=t[7],h}export{d as MenueSchublade};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -9,11 +9,11 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Homelab Orchestrator</title> <title>Homelab Orchestrator</title>
<script type="module" crossorigin src="/assets/index-Cdq4iteU.js"></script> <script type="module" crossorigin src="/assets/index-hnnYWPzT.js"></script>
<link rel="modulepreload" crossorigin href="/assets/dist-aE7UmubC.js"> <link rel="modulepreload" crossorigin href="/assets/dist-aE7UmubC.js">
<link rel="modulepreload" crossorigin href="/assets/button-BHVKhtQ6.js"> <link rel="modulepreload" crossorigin href="/assets/button-BHVKhtQ6.js">
<link rel="modulepreload" crossorigin href="/assets/dist-CakcBaML.js"> <link rel="modulepreload" crossorigin href="/assets/dist-CakcBaML.js">
<link rel="modulepreload" crossorigin href="/assets/abfragen-DNMpmgyt.js"> <link rel="modulepreload" crossorigin href="/assets/abfragen-CLTLeU-R.js">
<link rel="modulepreload" crossorigin href="/assets/useSelector-DBNxxw17.js"> <link rel="modulepreload" crossorigin href="/assets/useSelector-DBNxxw17.js">
<link rel="modulepreload" crossorigin href="/assets/useMatch-DDVpPKJI.js"> <link rel="modulepreload" crossorigin href="/assets/useMatch-DDVpPKJI.js">
<link rel="modulepreload" crossorigin href="/assets/useRouterState-DJr4hrdj.js"> <link rel="modulepreload" crossorigin href="/assets/useRouterState-DJr4hrdj.js">
+16 -1
View File
@@ -11,7 +11,7 @@ import { stromVerbunden } from "./strom"
import type { import type {
Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen, Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Neuerungen, HomelabWartung, WartungsRegel, HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Neuerungen, HomelabWartung, WartungsRegel,
LucyAutonomie, LucyStufe, Rueckfragen, Wochenbericht, Platte, HomelabPlatten, AusreisserLage, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus, LucyAutonomie, LucyStufe, Rueckfragen, Wochenbericht, Platte, HomelabPlatten, AusreisserLage, KonfigSicherung, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion, ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
ZielStand, Zeitraum, ZielStand, Zeitraum,
} from "./typen" } from "./typen"
@@ -512,6 +512,21 @@ export const useNeuerungen = (zid: string, baustein: string, aktiv: boolean) =>
retry: false, retry: false,
}) })
/** Konfig-Sicherung ins private Gitea-Repo (seit 26.09.2026). Nach „Jetzt sichern“ alle 5 s nachsehen. */
export const useKonfigSicherung = (schnell: boolean) =>
useQuery({
queryKey: ["konfig-sicherung"],
queryFn: () => api<KonfigSicherung>("/api/konfig-sicherung"),
refetchInterval: schnell ? 5000 : 60_000,
retry: false,
})
export const useKonfigSichern = () =>
useAktion(() => post<AktionsErgebnis>("/api/konfig-sicherung?warten=0"), {
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Die Sicherung läuft.",
neuLaden: ["konfig-sicherung"],
})
/** Ausreißer ohne feste Schwellen (seit 26.09.2026): Lernstand und was gerade auffällt, je Bereich. */ /** Ausreißer ohne feste Schwellen (seit 26.09.2026): Lernstand und was gerade auffällt, je Bereich. */
export const useAusreisser = (bereich: "box" | "homelab", aktiv = true) => export const useAusreisser = (bereich: "box" | "homelab", aktiv = true) =>
useQuery({ useQuery({
+15
View File
@@ -926,3 +926,18 @@ export interface AusreisserLage {
stunde: number | null stunde: number | null
auffaellig: { quelle: string; name: string; folge: number; wert: number; normal: number; fach: number; titel: string }[] auffaellig: { quelle: string; name: string; folge: number; wert: number; normal: number; fach: number; titel: string }[]
} }
// --- Konfig-Sicherung (GET /api/konfig-sicherung; seit 26.09.2026) -------------------------------------------------
export interface KonfigSicherung {
zuletzt: {
zeit: number
ergebnis: "gesichert" | "unveraendert" | "fehler"
text: string
commit?: string
dateien?: number
geschwaerzt?: number
fehler?: string[]
} | null
repo: string | null
}
+48 -1
View File
@@ -9,7 +9,7 @@ import { Freiheiten, WochenberichtAbschnitt } from "@/components/lucy/LucyEinste
import { OffeneRueckfragen, RueckfragenVerlauf } from "@/components/lucy/Rueckfragen" import { OffeneRueckfragen, RueckfragenVerlauf } from "@/components/lucy/Rueckfragen"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
useAdguardZugang, useArcaneEcht, useNasSnmp, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner, useAdguardZugang, useArcaneEcht, useKonfigSichern, useKonfigSicherung, useNasSnmp, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner,
useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen, useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen,
} from "@/lib/abfragen" } from "@/lib/abfragen"
import { api, post } from "@/lib/api" import { api, post } from "@/lib/api"
@@ -643,6 +643,52 @@ function NasSnmp({ h }: { h: HomelabEinstellungen }) {
) )
} }
/** Konfig-Sicherung (seit 26.09.2026): täglich 02:30 geschwärzt ins private Gitea-Repo; hier der letzte Lauf. */
function KonfigSicherungAbschnitt() {
const [seit, setSeit] = useState<number | null>(null)
const q = useKonfigSicherung(seit !== null)
const sichern = useKonfigSichern()
const z = q.data?.zuletzt
// Nach dem Knopf schnell nachsehen, bis ein neuer Lauf dasteht (höchstens drei Minuten).
useEffect(() => {
if (seit === null) return
if (z && z.zeit * 1000 >= seit) {
setSeit(null)
return
}
const uhr = window.setTimeout(() => setSeit(null), 180_000)
return () => window.clearTimeout(uhr)
}, [seit, z])
const art = z?.ergebnis === "fehler" ? "rot" : z?.ergebnis === "gesichert" ? "gruen" : "grau"
return (
<Abschnitt titel="Konfig-Sicherung">
<p className="text-sm text-text-2">
Täglich um 02:30 wandern die Einstellungen von Proxmox, AdGuard, NPMplus, den Arcane-Projekten und der KI-Box in
dein privates Gitea-Repo — Geheimnisse geschwärzt. Jede Änderung ist dort als Vorher-nachher zu sehen.
</p>
{z ? (
<div className="flex flex-wrap items-center gap-2 text-sm text-text-2">
<Chip art={art}>{z.ergebnis === "gesichert" ? "gesichert" : z.ergebnis === "fehler" ? "gescheitert" : "unverändert"}</Chip>
<span>{flugplanZeit(new Date(z.zeit * 1000).toISOString()).toLowerCase()} · {z.text}</span>
</div>
) : (
<p className="text-sm text-text-3">{q.isPending ? "Wird gelesen …" : "Noch kein Lauf."}</p>
)}
<div className="flex flex-wrap items-center gap-3">
<Button size="sm" variant="outline" disabled={sichern.isPending || seit !== null}
onClick={() => sichern.mutate(undefined, { onSuccess: () => setSeit(Date.now()) })}>
{seit !== null ? "Sichert …" : "Jetzt sichern"}
</Button>
{q.data?.repo && (
<a href={q.data.repo} target="_blank" rel="noopener" className="inline-flex items-center gap-1 text-sm text-cyan hover:underline">
In Gitea ansehen <ExternalLink className="size-3.5" aria-hidden />
</a>
)}
</div>
</Abschnitt>
)
}
function HomelabBetrieb({ h }: { h: HomelabEinstellungen }) { function HomelabBetrieb({ h }: { h: HomelabEinstellungen }) {
const f = h.ausfuehrer const f = h.ausfuehrer
return ( return (
@@ -680,6 +726,7 @@ function HomelabKarte() {
<ArcaneSchluessel h={h.data} /> <ArcaneSchluessel h={h.data} />
<AdguardZugang h={h.data} /> <AdguardZugang h={h.data} />
<NasSnmp h={h.data} /> <NasSnmp h={h.data} />
<KonfigSicherungAbschnitt />
<Abschnitt titel="Wartungsfenster und Regel je Gerät"><Wartungsfenster aktiv={aktiv} /></Abschnitt> <Abschnitt titel="Wartungsfenster und Regel je Gerät"><Wartungsfenster aktiv={aktiv} /></Abschnitt>
<HomelabBetrieb h={h.data} /> <HomelabBetrieb h={h.data} />
</> </>