"""Der App-Katalog pflegt sich selbst (Ausbauplan Welle 2, Punkt 7, seit 25.09.2026). Bis zum 25.09. kannte der Orchestrator nur die Apps, die in apps.KATALOG von Hand stehen; ein neuer Container zeigte sich ohne Versionsvergleich und ohne Webprüfung. Jetzt liest er für jede unbekannte Community-Scripts-Kennung einmal deren Update-Skript (ct/.sh, öffentlich auf GitHub, derselbe Ort, den `update` im Gast lädt) und nimmt daraus: • den Namen (APP="…"), • das GitHub-Projekt für die neueste Version (Kopfzeile „Github: https://github.com//“), • die Adresse der Oberfläche (die letzte Zeile „http(s)://${IP}:“, die das Skript am Ende ausgibt), • den Weg des Updates: sagt das Skript, die App lasse sich nur über ihre eigene Oberfläche aktualisieren, dann „eigene-oberflaeche“ (kein Knopf), sonst „skript“ (`update` im Gast, mit der Wartezeit aus karenz.py). Den Port prüft er gleich mit: Antwortet dort nichts (der User hat ihn geändert), bleibt die Webprüfung aus — ein geratener Port darf keinen roten Hinweis „antwortet nicht“ auslösen. Die Angaben liegen in /app-katalog.json; gelungene werden nach 7 Tagen neu gelesen, gescheiterte nach einem Tag. Die Angaben der Community-Scripts selbst (JSON) sind im Sommer 2026 aus dem Repository verschwunden, das Skript bleibt. """ import json import logging import re import socket import threading import time from pathlib import Path import httpx from kern.einstellungen import einstellungen from services.homelab import apps log = logging.getLogger(__name__) ROH = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/{kennung}.sh" NEU_LESEN_S, FEHLER_NEU_S = 7 * 86400, 86400 ZEITLIMIT = httpx.Timeout(15.0, connect=5.0) _KENNUNG = re.compile(r"[a-z0-9][a-z0-9-]{0,62}") _GITHUB = re.compile(r"Github:\s*https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?\s*$", re.IGNORECASE | re.MULTILINE) _APP = re.compile(r'^APP="([^"]+)"', re.MULTILINE) _ADRESSE = re.compile(r"(https?)://\$\{IP\}:(\d{2,5})(/[^\s$\"']*)?") _NUR_OBERFLAECHE = re.compile(r"only be updated via|update(?:d)? (?:it )?(?:via|through|from) the (?:web ?)?(?:ui|user interface|" r"web interface|dashboard)", re.IGNORECASE) _lock = threading.Lock() _stand: dict = {"mtime": None, "daten": {}} _laufend: set[str] = set() def _pfad() -> Path: return einstellungen().daten_dir / "app-katalog.json" def _lesen() -> dict: """Der Katalog aus der Datei, gemerkt solange sie sich nicht ändert (app_fuer fragt oft).""" pfad = _pfad() try: mtime = pfad.stat().st_mtime_ns except OSError: return {} with _lock: if _stand["mtime"] == mtime: return _stand["daten"] try: daten = json.loads(pfad.read_text(encoding="utf-8")) except (OSError, ValueError): daten = {} daten = daten if isinstance(daten, dict) else {} with _lock: _stand.update(mtime=mtime, daten=daten) return daten def _schreiben(daten: dict) -> None: pfad = _pfad() pfad.parent.mkdir(parents=True, exist_ok=True) tmp = pfad.with_suffix(".tmp") tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8") tmp.replace(pfad) def aus_skript(kennung: str, text: str) -> dict: """Was das Update-Skript über die App verrät: {"name", "quelle", "port", "https", "pfad", "weg"}.""" name = _APP.search(text) quelle = _GITHUB.search(text) adressen = _ADRESSE.findall(text) https, port, pfad = (adressen[-1][0] == "https", int(adressen[-1][1]), adressen[-1][2] or "/") if adressen \ else (False, None, "/") return {"name": name.group(1).strip() if name else kennung, "quelle": quelle.group(1) if quelle else None, "port": port, "https": https, "pfad": pfad, "weg": "eigene-oberflaeche" if _NUR_OBERFLAECHE.search(text) else "skript"} def _antwortet(ip: str, port: int) -> bool: try: with socket.create_connection((ip, port), timeout=3): return True except OSError: return False def app(kennung: str | None) -> apps.App | None: """Die App zu einer Kennung aus dem selbst gepflegten Katalog (None, wenn unbekannt oder das Lesen scheiterte).""" e = _lesen().get(kennung or "") if not isinstance(e, dict) or e.get("fehler") or not e.get("name"): return None return apps.App(kennung, str(e["name"]), e.get("quelle"), port=e.get("port"), https=bool(e.get("https")), pfad=str(e.get("pfad") or "/"), weg=str(e.get("weg") or "skript")) def eintragen(kennung: str, ip: str | None, holen=None, jetzt: float | None = None) -> dict: """Eine Kennung (neu) eintragen: Skript lesen, auswerten, Port prüfen. Rückgabe: der Eintrag.""" jetzt = time.time() if jetzt is None else jetzt holen = holen or (lambda k: httpx.get(ROH.format(kennung=k), timeout=ZEITLIMIT, follow_redirects=True)) try: antwort = holen(kennung) antwort.raise_for_status() eintrag = {**aus_skript(kennung, antwort.text), "geholt": jetzt} except Exception as exc: eintrag = {"fehler": f"{exc.__class__.__name__}: {exc}"[:200], "geholt": jetzt} if eintrag.get("port") and ip and not _antwortet(ip, eintrag["port"]): eintrag.update(port_skript=eintrag["port"], port=None) daten = dict(_lesen()) daten[kennung] = eintrag _schreiben(daten) log.info("katalog: %s eingetragen (%s)", kennung, eintrag.get("fehler") or eintrag.get("name")) return eintrag def fehlende(bericht: dict, jetzt: float | None = None) -> list[tuple[str, str | None]]: """(Kennung, IP) der Apps im Bericht, die weder von Hand im Katalog stehen noch frisch gelesen sind.""" jetzt = time.time() if jetzt is None else jetzt daten = _lesen() ergebnis = [] for g in bericht.get("gaeste") or []: kennung = ((g.get("app") if isinstance(g.get("app"), dict) else {}) or {}).get("kennung") if not kennung or not _KENNUNG.fullmatch(kennung) or kennung in apps.KATALOG: continue e = daten.get(kennung) alter = jetzt - float((e or {}).get("geholt") or 0) if e is None or alter > (FEHLER_NEU_S if e.get("fehler") else NEU_LESEN_S): ergebnis.append((kennung, g.get("ip"))) return ergebnis def ergaenzen(bericht: dict) -> None: """Nach jedem Bericht: fehlende Apps im eigenen Faden nachtragen — der Bericht wartet nie auf GitHub.""" for kennung, ip in fehlende(bericht): with _lock: if kennung in _laufend: continue _laufend.add(kennung) def los(k: str = kennung, adresse: str | None = ip) -> None: try: eintragen(k, adresse) except Exception: log.warning("katalog: %s ließ sich nicht eintragen", k, exc_info=True) finally: with _lock: _laufend.discard(k) threading.Thread(target=los, name=f"katalog-{kennung}", daemon=True).start()