#!/usr/bin/env python3 """Ausführer des Homelab Orchestrators auf dem Proxmox-Host (Phase 3/4, 24.09.2026). Läuft als root auf dem Proxmox-PC (mc2-ausfuehrer.service) und holt sich seine Arbeit selbst beim Homelab-Teil ab („Pull“, User-Entscheid 24.09.): Der Proxmox-Host öffnet keinen Port, und der Container des Homelab-Teils braucht keinen Proxmox-Schlüssel. • Bericht alle 10 Minuten (oder auf Anfrage): Host-Updates, Gäste mit Status, App-Version, OS-Updates, Snapshots und ob ein Snapshot überhaupt geht. Nur lesend. • Aufträge eine feste Liste von Aktionen (AKTIONEN); alles andere wird abgelehnt. Welche Gäste angefasst werden dürfen, entscheidet dieser Host selbst anhand der Etiketten: community-script oder watcher, aber nicht watcher-aus. Dem Server wird dabei nicht vertraut — er kann nur aus der Liste wählen. Nur die Standardbibliothek (Debian-Python des Hosts). Konfiguration: /etc/mc2-ausfuehrer.json {"server": "http://192.168.178.x:9001", "token": "", "node": "pve"} Aufruf: ausfuehrer.py Dauerbetrieb (systemd) ausfuehrer.py --bericht Bericht einmal auf die Konsole (nur lesend, zum Prüfen) """ import argparse import json import logging import os import platform import re import subprocess import sys import time import urllib.error import urllib.request from datetime import datetime, timezone KONFIG = os.environ.get("MC2_AUSFUEHRER_KONFIG", "/etc/mc2-ausfuehrer.json") BERICHT_ALLE_S = 600 ABFRAGE_ALLE_S = 5 ERLAUBT = {"community-script", "watcher"} AUSGENOMMEN = "watcher-aus" SNAPSHOT_NAME = re.compile(r"^mc2-\d{8}-\d{6}$") # Speicherarten, auf denen Proxmox Snapshots kann. SNAPSHOT_SPEICHER = {"lvmthin", "zfspool", "rbd", "btrfs", "cephfs"} # App-Version je Community-Script (Kennung aus /usr/bin/update im Gast). Neuere Skripte legen die Version # in /root/. ab; für die älteren steht hier, wie man sie erfährt. Festes Wissen dieses Hosts. VERSION_PROBEN = { "adguard": "/opt/AdGuardHome/AdGuardHome --version", "netbird": "netbird version", "proxmox-backup-server": "dpkg-query -W proxmox-backup-server", "npmplus": "docker image inspect zoeyvid/npmplus:latest --format '{{.Created}}'", } VERSIONSDATEIEN = {"pve-scripts-local": "/root/.proxmoxve-local"} log = logging.getLogger("ausfuehrer") # --- Hilfen ----------------------------------------------------------------------------- def _laufen(befehl: list[str], zeitlimit: int = 60) -> tuple[int, str]: try: r = subprocess.run(befehl, capture_output=True, text=True, timeout=zeitlimit) except subprocess.TimeoutExpired: return 124, f"Zeitlimit ({zeitlimit} s) überschritten" except OSError as exc: return 127, str(exc) return r.returncode, (r.stdout + r.stderr).strip() def _pvesh(pfad: str, *parameter: str) -> object: code, text = _laufen(["pvesh", "get", pfad, *parameter, "--output-format", "json"], 30) if code != 0: raise RuntimeError(f"pvesh {pfad}: {text[:200]}") return json.loads(text or "null") def _im_gast(vmid: int, befehl: str, zeitlimit: int = 30) -> tuple[int, str]: return _laufen(["pct", "exec", str(vmid), "--", "sh", "-c", befehl], zeitlimit) def _etiketten(roh: str | None) -> list[str]: return [t for t in (roh or "").replace(",", ";").split(";") if t] def erlaubt(etiketten: list[str]) -> bool: menge = set(etiketten) return bool(menge & ERLAUBT) and AUSGENOMMEN not in menge # --- Bericht (nur lesend) ------------------------------------------------------------------- def _speicherart() -> dict[str, str]: try: return {s["storage"]: s["type"] for s in _pvesh(f"/nodes/{NODE}/storage")} except Exception: return {} def snapshot_moeglich(konfig: dict, arten: dict[str, str]) -> tuple[bool, str | None]: """Geht ein Snapshot? Bind-Mounts (mpX mit Host-Pfad) und Speicher ohne Snapshots verhindern ihn.""" for schluessel, wert in konfig.items(): if re.fullmatch(r"mp\d+", schluessel) and str(wert).split(",")[0].startswith("/"): return False, f"Bind-Mount {schluessel} ({str(wert).split(',')[0]}) — nur Backup möglich" for schluessel in ("rootfs", "scsi0", "virtio0", "sata0", "ide0"): if schluessel in konfig: speicher = str(konfig[schluessel]).split(":")[0] art = arten.get(speicher) if art and art not in SNAPSHOT_SPEICHER: return False, f"Speicher {speicher} ({art}) kann keine Snapshots" return True, None def _app_kennung(vmid: int) -> str | None: """Welches Community-Script steckt im Gast? /usr/bin/update lädt ct/.sh.""" code, text = _im_gast(vmid, "cat /usr/bin/update 2>/dev/null", 10) m = re.search(r"/ct/([a-z0-9-]+)\.sh", text) if code == 0 else None return m.group(1) if m else None def _app_version(vmid: int, kennung: str) -> str | None: befehl = VERSION_PROBEN.get(kennung) if befehl is None: datei = VERSIONSDATEIEN.get(kennung, f"/root/.{kennung}") befehl = f"cat {datei} 2>/dev/null" code, text = _im_gast(vmid, befehl, 20) if code != 0 or not text: return None zeile = text.splitlines()[-1].strip() # Docker-Apps (NPMplus) haben keine Versionsnummer, nur das Datum ihres Images. if m := re.match(r"(\d{4}-\d{2}-\d{2})T", zeile): return f"Image vom {m.group(1)}" m = re.search(r"v?(\d+(?:\.\d+){1,3}(?:[-+][\w.]+)?)", zeile) return m.group(1) if m else zeile[:40] def _os_updates(vmid: int, ostype: str) -> dict: """Aktualisierbare Pakete nach den vorhandenen Paketlisten (liest nur) und wie alt die Listen sind.""" if ostype == "alpine": code, text = _im_gast(vmid, "apk version -l '<' 2>/dev/null | tail -n +2 | wc -l; " "stat -c %Y /var/cache/apk 2>/dev/null || echo 0", 30) else: code, text = _im_gast(vmid, "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable; " "stat -c %Y /var/lib/apt/lists 2>/dev/null || echo 0", 30) zeilen = text.split() if code in (0, 1) else [] try: return {"anzahl": int(zeilen[0]), "listen_stand": int(zeilen[1]) or None} except (IndexError, ValueError): return {"anzahl": None, "listen_stand": None, "fehler": text[:160]} def _ip_lxc(vmid: int) -> str | None: try: for schnitt in _pvesh(f"/nodes/{NODE}/lxc/{vmid}/interfaces"): if schnitt.get("name") in ("eth0", "ens18"): for adresse in schnitt.get("ip-addresses", []): if adresse.get("ip-address-type") == "inet": return adresse["ip-address"].split("/")[0] except Exception: pass return None def _ip_vm(vmid: int) -> str | None: try: daten = _pvesh(f"/nodes/{NODE}/qemu/{vmid}/agent/network-get-interfaces") for schnitt in (daten or {}).get("result", []): if schnitt.get("name") == "lo": continue for adresse in schnitt.get("ip-addresses", []): ip = adresse.get("ip-address", "") if adresse.get("ip-address-type") == "ipv4" and not ip.startswith(("127.", "172.")): return ip except Exception: pass return None def _snapshots(art: str, vmid: int) -> list[str]: try: return [s["name"] for s in _pvesh(f"/nodes/{NODE}/{art}/{vmid}/snapshot") if s.get("name") != "current"] except Exception: return [] def _gast(eintrag: dict, arten: dict[str, str]) -> dict: art, vmid = eintrag["type"], int(eintrag["vmid"]) etiketten = _etiketten(eintrag.get("tags")) gast = {"vmid": vmid, "art": art, "name": eintrag.get("name"), "status": eintrag.get("status"), "etiketten": etiketten, "erlaubt": erlaubt(etiketten), "uptime": eintrag.get("uptime"), "snapshots": _snapshots(art, vmid)} try: konfig = _pvesh(f"/nodes/{NODE}/{art}/{vmid}/config") except Exception as exc: gast["fehler"] = str(exc)[:200] return gast gast["onboot"] = bool(konfig.get("onboot")) gast["snapshot_moeglich"], gast["snapshot_grund"] = snapshot_moeglich(konfig, arten) if art == "lxc": gast["ostype"] = konfig.get("ostype") gast["ip"] = _ip_lxc(vmid) if gast["status"] == "running" else None if gast["status"] == "running" and gast["erlaubt"]: kennung = _app_kennung(vmid) gast["app"] = {"kennung": kennung, "version": _app_version(vmid, kennung) if kennung else None} gast["os_updates"] = _os_updates(vmid, str(konfig.get("ostype") or "debian")) else: gast["ip"] = _ip_vm(vmid) if gast["status"] == "running" else None return gast def _host() -> dict: host: dict = {"node": NODE, "neustart_noetig": os.path.exists("/var/run/reboot-required")} try: host["version"] = _pvesh("/version").get("version") host["updates"] = [{"paket": p.get("Package"), "alt": p.get("OldVersion"), "neu": p.get("Version"), "herkunft": p.get("Origin")} for p in _pvesh(f"/nodes/{NODE}/apt/update")] except Exception as exc: host["fehler"] = str(exc)[:200] code, kernel = _laufen(["uname", "-r"], 5) host["kernel"] = kernel if code == 0 else None return host def bericht() -> dict: arten = _speicherart() gaeste = [] for eintrag in _pvesh("/cluster/resources", "--type", "vm") or []: if eintrag.get("node") == NODE and not eintrag.get("template"): gaeste.append(_gast(eintrag, arten)) return {"zeit": datetime.now(timezone.utc).isoformat(timespec="seconds"), "host": _host(), "gaeste": sorted(gaeste, key=lambda g: g["vmid"])} # --- Aufträge (feste Liste) --------------------------------------------------------------- def _gast_pruefen(vmid: object) -> tuple[str, int]: """Gibt (art, vmid) zurück, wenn dieser Host den Gast anfassen darf — sonst ValueError.""" if not isinstance(vmid, int) or not 100 <= vmid <= 999_999_999: raise ValueError("ungültige Gast-Nummer") for eintrag in _pvesh("/cluster/resources", "--type", "vm") or []: if int(eintrag.get("vmid", -1)) == vmid: if not erlaubt(_etiketten(eintrag.get("tags"))): raise ValueError(f"Gast {vmid} hat kein erlaubtes Etikett (community-script/watcher) " f"oder ist mit {AUSGENOMMEN} ausgenommen") return eintrag["type"], vmid raise ValueError(f"Gast {vmid} gibt es nicht") def _werkzeug(art: str) -> str: return "pct" if art == "lxc" else "qm" def a_snapshot(p: dict) -> tuple[int, str]: art, vmid = _gast_pruefen(p.get("vmid")) name = "mc2-" + datetime.now().strftime("%Y%m%d-%H%M%S") code, text = _laufen([_werkzeug(art), "snapshot", str(vmid), name, "--description", "Vor einem Update durch den Homelab Orchestrator"], 600) return code, f"snapshot={name}\n{text}" if code == 0 else text def a_update(p: dict) -> tuple[int, str]: art, vmid = _gast_pruefen(p.get("vmid")) if art != "lxc": raise ValueError("Update per Community-Script gibt es nur für Container") # PHS_SILENT=1: das Community-Script fragt nichts. Es lädt ct/.sh live von GitHub (main). return _im_gast(vmid, "PHS_SILENT=1 bash /usr/bin/update", int(p.get("zeitlimit") or 1800)) def a_suchen(p: dict) -> tuple[int, str]: art, vmid = _gast_pruefen(p.get("vmid")) if art != "lxc": raise ValueError("nur für Container") return _im_gast(vmid, "if command -v apk >/dev/null; then apk update; else apt-get update -q; fi", 300) def a_os_update(p: dict) -> tuple[int, str]: """Die Pakete des Gasts (unabhängig vom App-Update des Community-Scripts).""" art, vmid = _gast_pruefen(p.get("vmid")) if art != "lxc": raise ValueError("nur für Container") befehl = ("if command -v apk >/dev/null; then apk update && apk upgrade; else apt-get update -q && " "DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confold dist-upgrade; fi") return _im_gast(vmid, befehl, int(p.get("zeitlimit") or 1800)) def a_zurueck(p: dict) -> tuple[int, str]: art, vmid = _gast_pruefen(p.get("vmid")) name = str(p.get("snapshot") or "") if not SNAPSHOT_NAME.match(name): raise ValueError("nur Snapshots des Orchestrators (mc2-…) lassen sich zurückspielen") code, text = _laufen([_werkzeug(art), "rollback", str(vmid), name], 900) if code == 0: code2, text2 = _laufen([_werkzeug(art), "start", str(vmid)], 120) text += "\n" + text2 code = 0 if code2 == 0 or "already running" in text2 else code2 return code, text def a_snapshot_loeschen(p: dict) -> tuple[int, str]: art, vmid = _gast_pruefen(p.get("vmid")) name = str(p.get("snapshot") or "") if not SNAPSHOT_NAME.match(name): raise ValueError("nur Snapshots des Orchestrators (mc2-…) werden gelöscht") return _laufen([_werkzeug(art), "delsnapshot", str(vmid), name], 600) def a_host_update(p: dict) -> tuple[int, str]: # Kein Neustart: der ist ein eigener Knopf (User-Entscheid 24.09.2026). befehl = ("apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get -y " "-o Dpkg::Options::=--force-confold dist-upgrade") return _laufen(["bash", "-c", befehl], 3600) def a_host_neustart(p: dict) -> tuple[int, str]: return _laufen(["systemctl", "reboot"], 30) def a_bericht(p: dict) -> tuple[int, str]: return 0, json.dumps(bericht(), ensure_ascii=False) AKTIONEN = {"snapshot": a_snapshot, "update": a_update, "os_update": a_os_update, "suchen": a_suchen, "zurueck": a_zurueck, "snapshot_loeschen": a_snapshot_loeschen, "host_update": a_host_update, "host_neustart": a_host_neustart, "bericht": a_bericht} def ausfuehren(auftrag: dict) -> dict: aktion = AKTIONEN.get(str(auftrag.get("aktion"))) if aktion is None: return {"code": 2, "text": f"Unbekannte Aktion: {auftrag.get('aktion')!r}"} try: code, text = aktion(auftrag.get("parameter") or {}) except (ValueError, RuntimeError) as exc: return {"code": 2, "text": f"Abgelehnt: {exc}"} return {"code": code, "text": text[-20000:]} # --- Verbindung zum Homelab-Teil ------------------------------------------------------------- def _anfrage(methode: str, pfad: str, daten: object = None) -> object: roh = None if daten is None else json.dumps(daten).encode() anfrage = urllib.request.Request(SERVER + pfad, data=roh, method=methode, headers={"Content-Type": "application/json", "X-MC2-Ausfuehrer": TOKEN}) with urllib.request.urlopen(anfrage, timeout=30) as antwort: text = antwort.read().decode() or "null" return json.loads(text) def dauerbetrieb() -> None: naechster_bericht = 0.0 while True: try: if time.time() >= naechster_bericht: _anfrage("POST", "/api/homelab/ausfuehrer/bericht", bericht()) naechster_bericht = time.time() + BERICHT_ALLE_S 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")) ergebnis = ausfuehren(auftrag) _anfrage("POST", f"/api/homelab/ausfuehrer/ergebnis/{auftrag['id']}", ergebnis) if auftrag.get("aktion") != "bericht": naechster_bericht = 0.0 # nach jeder Änderung einen frischen Bericht continue except (urllib.error.URLError, OSError, ValueError) as exc: log.warning("Homelab-Teil nicht erreichbar: %s", exc) except Exception: log.exception("Unerwarteter Fehler") time.sleep(ABFRAGE_ALLE_S) def _konfig_laden() -> dict: try: with open(KONFIG, encoding="utf-8") as f: return json.load(f) except (OSError, ValueError): return {} _konfig = _konfig_laden() SERVER = str(_konfig.get("server") or "").rstrip("/") TOKEN = str(_konfig.get("token") or "") NODE = str(_konfig.get("node") or platform.node()) def main() -> int: logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") teile = argparse.ArgumentParser(description=__doc__.splitlines()[0]) teile.add_argument("--bericht", action="store_true", help="Bericht einmal ausgeben (nur lesend)") args = teile.parse_args() if args.bericht: json.dump(bericht(), sys.stdout, ensure_ascii=False, indent=2) print() return 0 if not SERVER or not TOKEN: log.error("Konfiguration %s fehlt oder ist unvollständig (server, token).", KONFIG) return 1 dauerbetrieb() return 0 if __name__ == "__main__": sys.exit(main())