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")
|
||||
+4
-3
@@ -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
|
||||
projekte-sync.service projekte-sync.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
|
||||
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
|
||||
# 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
|
||||
@@ -181,7 +182,7 @@ stufe2() {
|
||||
systemctl --user enable $aktiv >/dev/null 2>&1
|
||||
# shellcheck disable=SC2086
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -54,6 +54,7 @@ Aufruf: ausfuehrer.py Dauerbetrieb (systemd)
|
||||
import argparse
|
||||
import base64
|
||||
import glob
|
||||
import io
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
@@ -63,6 +64,7 @@ import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -1153,6 +1155,142 @@ def _platten_stand() -> list[dict]:
|
||||
return _platten["stand"]
|
||||
|
||||
|
||||
# --- Konfig-Sicherung (seit 26.09.2026, nur lesend) --------------------------------------------------------
|
||||
# Die Einstellungen von Proxmox (Gäste, Speicher, Sicherungsaufträge, Netz), AdGuard und NPMplus, geschwärzt HIER —
|
||||
# Geheimnisse verlassen den Host nicht. Gesendet an den Homelab-Teil (POST /api/homelab/ausfuehrer/konfig), alle
|
||||
# KONFIG_ALLE_S und auf Abruf (Aktion konfig_senden); die KI-Box legt sie in ein privates Gitea-Repo. Die Regeln sind
|
||||
# dieselben wie in backend/kern/schwaerzen.py (ein Test prüft das).
|
||||
|
||||
KONFIG_ALLE_S = 6 * 3600
|
||||
KONFIG_HOST = ("/etc/pve/lxc", "/etc/pve/qemu-server", "/etc/pve/storage.cfg", "/etc/pve/jobs.cfg",
|
||||
"/etc/pve/datacenter.cfg", "/etc/pve/replication.cfg", "/etc/network/interfaces")
|
||||
KONFIG_GAST = {
|
||||
"adguard": ("opt/AdGuardHome/AdGuardHome.yaml",),
|
||||
"npmplus": ("opt/npmplus/nginx/proxy_host", "opt/npmplus/nginx/redirection_host", "opt/npmplus/nginx/stream",
|
||||
"opt/npmplus/nginx/dead_host", "opt/npmplus/access", "opt/npmplus/custom_nginx"),
|
||||
}
|
||||
KONFIG_NAMEN = {"adguard": ("adguard", "adguardhome", "adguard-home"), "npmplus": ("npmplus", "nginxproxymanager")}
|
||||
KONFIG_DATEI_MAX = 256 * 1024
|
||||
KONFIG_GESAMT_MAX = 4 * 1024 * 1024
|
||||
SCHWARZ = "«geschwärzt»"
|
||||
_SCHWARZ_WORT = (r"(?:pass(?:word|wd|phrase)?|secret|token|api[_-]?key|apikey|private[_-]?key|credential|salt|cookie|"
|
||||
r"auth[_-]?key|access[_-]?key|client[_-]?secret|jwt|key(?![a-z]))")
|
||||
SCHWAERZEN = (
|
||||
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.DOTALL),
|
||||
f"-----BEGIN PRIVATE KEY-----\n{SCHWARZ}\n-----END PRIVATE KEY-----"),
|
||||
(re.compile(rf"(?im)^([ \t]*(?:-[ \t]*)?[\"']?[\w.-]*{_SCHWARZ_WORT}[\w.-]*[\"']?[ \t]*[:=][ \t]*)(?![ \t]*$)(?!{SCHWARZ})(.+)$"),
|
||||
rf"\g<1>{SCHWARZ}"),
|
||||
(re.compile(r"(?im)^(\s*proxy_set_header\s+(?:authorization|x-api-key|x-auth-token|cookie)\s+)(\"[^\"]*\"|\S+?)(;?)$"),
|
||||
rf'\g<1>"{SCHWARZ}"\g<3>'),
|
||||
(re.compile(r"\$(?:2[abxy]|argon2(?:id|i|d)|apr1|[156])\$[^\s\"',;]+"), SCHWARZ),
|
||||
(re.compile(r"(?m)^([\w.@-]+):(?:\{SHA\}|\$)\S+$"), rf"\g<1>:{SCHWARZ}"),
|
||||
(re.compile(r"(\b[a-z][a-z0-9+.-]*://[^\s:/@]+:)[^\s@/]+@"), rf"\g<1>{SCHWARZ}@"),
|
||||
(re.compile(r"(?i)\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}"), rf"\g<1> {SCHWARZ}"),
|
||||
(re.compile(r"\b(?:sk-|hf_|gh[pousr]_)[A-Za-z0-9_-]{16,}"), SCHWARZ),
|
||||
(re.compile(r"\b[0-9a-fA-F]{32,}\b"), SCHWARZ),
|
||||
)
|
||||
_konfig_zuletzt = {"zeit": 0.0}
|
||||
|
||||
|
||||
def schwaerzen(text: str) -> tuple[str, int]:
|
||||
zahl = 0
|
||||
for muster, ersatz in SCHWAERZEN:
|
||||
text, n = muster.subn(ersatz, text)
|
||||
zahl += n
|
||||
return text, zahl
|
||||
|
||||
|
||||
def _textdatei(roh: bytes) -> str | None:
|
||||
if b"\x00" in roh[:4096]:
|
||||
return None
|
||||
return roh.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _host_konfig() -> dict[str, str]:
|
||||
dateien: dict[str, str] = {}
|
||||
for pfad in KONFIG_HOST:
|
||||
kandidaten = sorted(glob.glob(os.path.join(pfad, "*"))) if os.path.isdir(pfad) else [pfad]
|
||||
for datei in kandidaten:
|
||||
try:
|
||||
if os.path.isfile(datei) and os.path.getsize(datei) <= KONFIG_DATEI_MAX:
|
||||
with open(datei, "rb") as f:
|
||||
if (text := _textdatei(f.read())) is not None:
|
||||
dateien["proxmox/" + datei.removeprefix("/etc/").removeprefix("pve/")] = text
|
||||
except OSError:
|
||||
continue
|
||||
return dateien
|
||||
|
||||
|
||||
def _gast_konfig(vmid: int, pfade: tuple[str, ...], ziel: str) -> dict[str, str]:
|
||||
"""Dateien aus einem Container per tar über pct exec — nur die, die es gibt (BusyBox-tar bricht sonst ab)."""
|
||||
liste = " ".join(pfade)
|
||||
befehl = f'cd / && vorhanden="" && for p in {liste}; do [ -e "$p" ] && vorhanden="$vorhanden $p"; done; ' \
|
||||
'[ -n "$vorhanden" ] && tar -cf - $vorhanden'
|
||||
try:
|
||||
r = subprocess.run(["pct", "exec", str(vmid), "--", "sh", "-c", befehl], capture_output=True, timeout=60)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return {}
|
||||
dateien: dict[str, str] = {}
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(r.stdout), mode="r:") as archiv:
|
||||
for teil in archiv.getmembers():
|
||||
if not teil.isfile() or teil.size > KONFIG_DATEI_MAX:
|
||||
continue
|
||||
f = archiv.extractfile(teil)
|
||||
if f is None or (text := _textdatei(f.read())) is None:
|
||||
continue
|
||||
name = teil.name.removeprefix("./").removeprefix("opt/").removeprefix("AdGuardHome/")
|
||||
dateien[f"{ziel}/{name.removeprefix(ziel + '/')}"] = text
|
||||
except (tarfile.TarError, EOFError):
|
||||
return dateien
|
||||
return dateien
|
||||
|
||||
|
||||
def _gast_fuer(art: str) -> int | None:
|
||||
for eintrag in _pvesh("/cluster/resources", "--type", "vm") or []:
|
||||
if eintrag.get("node") == NODE and eintrag.get("type") == "lxc" \
|
||||
and str(eintrag.get("name") or "").lower() in KONFIG_NAMEN[art] \
|
||||
and erlaubt(_etiketten(eintrag.get("tags"))):
|
||||
return int(eintrag["vmid"])
|
||||
return None
|
||||
|
||||
|
||||
def konfig_sammeln() -> dict:
|
||||
"""{"zeit", "dateien": {Pfad im Repo: geschwärzter Text}, "geschwaerzt": Zahl, "fehler": [...]}."""
|
||||
dateien, fehler = _host_konfig(), []
|
||||
for art, pfade in KONFIG_GAST.items():
|
||||
vmid = _gast_fuer(art)
|
||||
if vmid is None:
|
||||
fehler.append(f"{art}: kein Container gefunden")
|
||||
continue
|
||||
gefunden = _gast_konfig(vmid, pfade, art)
|
||||
if not gefunden:
|
||||
fehler.append(f"{art}: keine Dateien gelesen (Container {vmid})")
|
||||
dateien.update(gefunden)
|
||||
geschwaerzt, gesamt, ergebnis = 0, 0, {}
|
||||
for pfad in sorted(dateien):
|
||||
text, n = schwaerzen(dateien[pfad])
|
||||
gesamt += len(text)
|
||||
if gesamt > KONFIG_GESAMT_MAX:
|
||||
fehler.append("zu groß — der Rest fehlt")
|
||||
break
|
||||
ergebnis[pfad], geschwaerzt = text, geschwaerzt + n
|
||||
return {"zeit": time.time(), "dateien": ergebnis, "geschwaerzt": geschwaerzt, "fehler": fehler}
|
||||
|
||||
|
||||
def konfig_senden() -> dict:
|
||||
daten = konfig_sammeln()
|
||||
_anfrage("POST", "/api/homelab/ausfuehrer/konfig", daten)
|
||||
_konfig_zuletzt["zeit"] = time.time()
|
||||
return daten
|
||||
|
||||
|
||||
def a_konfig_senden(p: dict) -> tuple[int, str]:
|
||||
"""Auf Abruf (Knopf „Jetzt sichern“, Lauf der KI-Box): sammeln, schwärzen, senden. Nur lesend."""
|
||||
daten = konfig_senden()
|
||||
return 0, f"dateien={len(daten['dateien'])}\ngeschwaerzt={daten['geschwaerzt']}\n" + "\n".join(daten["fehler"])
|
||||
|
||||
|
||||
# --- Geräte im Heimnetz (seit 26.09.2026, nur lesend) ----------------------------------------------------
|
||||
# AdGuard sieht in diesem Netz fast nur die Fritzbox (sie reicht die Anfragen der meisten Geräte weiter). Wer im
|
||||
# Heimnetz ist, verrät deshalb die Nachbartabelle dieses Hosts: Alle NETZ_ALLE_S ein Ping an jede Adresse des Netzes
|
||||
@@ -1532,9 +1670,10 @@ AKTIONEN = {"snapshot": a_snapshot, "update": a_update, "os_update": a_os_update
|
||||
"host_neustart": a_host_neustart, "bericht": a_bericht,
|
||||
"sichern": a_sichern, "sicherung_zurueck": a_sicherung_zurueck, "sicherung_loeschen": a_sicherung_loeschen,
|
||||
"kernel_aufraeumen": a_kernel_aufraeumen, "sicherung_probe": a_sicherung_probe,
|
||||
"etikett_setzen": a_etikett_setzen, "changelog": a_changelog, "journal": a_journal}
|
||||
"etikett_setzen": a_etikett_setzen, "changelog": a_changelog, "journal": a_journal,
|
||||
"konfig_senden": a_konfig_senden}
|
||||
# Was auch im Nur-Lesen-Modus geht: Es liest nur.
|
||||
NUR_LESEND = {a_bericht, a_sicherung_probe, a_changelog, a_journal}
|
||||
NUR_LESEND = {a_bericht, a_sicherung_probe, a_changelog, a_journal, a_konfig_senden}
|
||||
|
||||
|
||||
def ausfuehren(auftrag: dict) -> dict:
|
||||
@@ -1784,6 +1923,12 @@ def dauerbetrieb() -> None:
|
||||
daten = bericht()
|
||||
_anfrage("POST", "/api/homelab/ausfuehrer/bericht", daten)
|
||||
naechster_bericht = time.time() + naechster_bericht_in(daten)
|
||||
if time.time() - _konfig_zuletzt["zeit"] >= KONFIG_ALLE_S:
|
||||
try:
|
||||
konfig_senden()
|
||||
except Exception as exc: # die Sicherung darf den Dauerbetrieb nie aufhalten
|
||||
log.warning("Konfig nicht gesendet: %s", exc)
|
||||
_konfig_zuletzt["zeit"] = time.time()
|
||||
auftrag = _anfrage("GET", "/api/homelab/ausfuehrer/auftrag")
|
||||
if isinstance(auftrag, dict) and auftrag.get("id"):
|
||||
log.info("Auftrag %s: %s %s", auftrag["id"], auftrag.get("aktion"), auftrag.get("parameter"))
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
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.
|
||||
- **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
|
||||
und speichern“ fragt Arcane zuerst, ob der Schlüssel gilt; erst dann wird er gespeichert — im Homelab-Teil, nicht
|
||||
|
||||
@@ -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:
|
||||
`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.)
|
||||
|
||||
`backend/kern/ausreisser.py` lernt je Messreihe (Box: `box`; Homelab: `pve`, `ct-*`, `vm-*`) und Größe (cpu, gpu, ram,
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
- **Zugang von außen:** Der Eintrag `mc.tobisniceshomelab.ddnsfree.com` in NPMplus (Proxy-Host 14 → Box :9001) hatte
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
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
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
-11
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -9,11 +9,11 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<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/button-BHVKhtQ6.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/useMatch-DDVpPKJI.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/useRouterState-DJr4hrdj.js">
|
||||
|
||||
@@ -11,7 +11,7 @@ import { stromVerbunden } from "./strom"
|
||||
import type {
|
||||
Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
|
||||
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,
|
||||
ZielStand, Zeitraum,
|
||||
} from "./typen"
|
||||
@@ -512,6 +512,21 @@ export const useNeuerungen = (zid: string, baustein: string, aktiv: boolean) =>
|
||||
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. */
|
||||
export const useAusreisser = (bereich: "box" | "homelab", aktiv = true) =>
|
||||
useQuery({
|
||||
|
||||
@@ -926,3 +926,18 @@ export interface AusreisserLage {
|
||||
stunde: number | null
|
||||
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
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Freiheiten, WochenberichtAbschnitt } from "@/components/lucy/LucyEinste
|
||||
import { OffeneRueckfragen, RueckfragenVerlauf } from "@/components/lucy/Rueckfragen"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
useAdguardZugang, useArcaneEcht, useNasSnmp, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner,
|
||||
useAdguardZugang, useArcaneEcht, useKonfigSichern, useKonfigSicherung, useNasSnmp, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner,
|
||||
useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen,
|
||||
} from "@/lib/abfragen"
|
||||
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 }) {
|
||||
const f = h.ausfuehrer
|
||||
return (
|
||||
@@ -680,6 +726,7 @@ function HomelabKarte() {
|
||||
<ArcaneSchluessel h={h.data} />
|
||||
<AdguardZugang h={h.data} />
|
||||
<NasSnmp h={h.data} />
|
||||
<KonfigSicherungAbschnitt />
|
||||
<Abschnitt titel="Wartungsfenster und Regel je Gerät"><Wartungsfenster aktiv={aktiv} /></Abschnitt>
|
||||
<HomelabBetrieb h={h.data} />
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user