Der Ausführer liest alle 30 Minuten die https-Zertifikate (NPMplus samt dem, was es für den Namen ausliefert, Proxmox-Host, PBS). Weil NPMplus das kurzlebige Profil von Let's Encrypt nutzt (6,7 Tage), gilt keine feste 30-Tage-Grenze: gelb „wird nicht erneuert“, sobald weniger als ein Drittel der Laufzeit übrig ist (langlebige: 30 Tage vorher), rot unter einem Sechstel (1–7 Tage vor Ablauf), gelb auch, wenn NPMplus noch das alte ausliefert. Je Quelle ein Hinweis; das Homelab-Cockpit zeigt die Liste. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
338 lines
14 KiB
Python
338 lines
14 KiB
Python
"""Schnittstellen des Homelab-Teils (Rolle homelab, Phase 3/4, 24.09.2026).
|
|
|
|
GET /api/homelab/ziele Proxmox-Host und Gäste im gemeinsamen Ziel-Modell
|
|
POST /api/homelab/ziele/{id}/update „Jetzt updaten“ (App bzw. Host-Pakete)
|
|
POST /api/homelab/ziele/{id}/os-update Pakete im Gast einspielen
|
|
POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern
|
|
POST /api/homelab/ziele/{id}/docker Docker über Arcane (zuerst Probelauf)
|
|
POST /api/homelab/ziele/{id}/rueckweg-probe Rückweg testen: Snapshot, absichtlich rot, zurück, nachprüfen
|
|
POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf)
|
|
GET /api/homelab/laeufe Die letzten Läufe (Updates, Snapshot-Aktionen) mit ihren Schritten
|
|
GET /api/homelab/snapshots Snapshots und Sicherungen je Gerät (Aktionen: siehe unten)
|
|
POST /api/homelab/snapshots/rueckwege-loeschen Alle Rückwege von Updates löschen (nur per Klick)
|
|
POST /api/homelab/sicherungen/probe Probe-Wiederherstellung jetzt (nur lesend, sonst einmal die Woche)
|
|
GET /api/homelab/zertifikate https-Zertifikate (NPMplus, Proxmox-Host, PBS) mit Stufe
|
|
GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz
|
|
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
|
|
GET /api/homelab/ausfuehrer/auftrag ┐
|
|
POST /api/homelab/ausfuehrer/bericht ├ nur für den Ausführer (Kopfzeile X-MC2-Ausfuehrer)
|
|
POST /api/homelab/ausfuehrer/ergebnis/{id} ┘
|
|
|
|
GET /api/stream Live-Strom dieser Instanz (Anstöße, kein Messpunkt)
|
|
|
|
Dünn: die Logik liegt in services/homelab/.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
|
from fastapi.responses import StreamingResponse
|
|
from kern.einstellungen import einstellungen
|
|
from pydantic import BaseModel
|
|
from services.homelab import inventar, kanal, updates
|
|
|
|
router = APIRouter(prefix="/api/homelab", tags=["homelab"])
|
|
strom_router = APIRouter(prefix="/api", tags=["homelab"])
|
|
|
|
|
|
def _nur_ausfuehrer(x_mc2_ausfuehrer: str | None = Header(default=None)) -> None:
|
|
if not kanal.token_gueltig(x_mc2_ausfuehrer):
|
|
raise HTTPException(status_code=403, detail="Nur für den Ausführer auf dem Proxmox-Host.")
|
|
kanal.kontakt()
|
|
|
|
|
|
@router.get("/ziele")
|
|
def ziele() -> dict:
|
|
return inventar.ziele()
|
|
|
|
|
|
def _antwort(ergebnis: dict) -> dict:
|
|
if not ergebnis.get("ok"):
|
|
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Das geht gerade nicht.")
|
|
return ergebnis
|
|
|
|
|
|
@router.post("/ziele/{ziel_id}/update")
|
|
def update(ziel_id: str) -> dict:
|
|
return _antwort(updates.starten(ziel_id, "pakete" if ziel_id == "pve" else "app"))
|
|
|
|
|
|
@router.post("/ziele/{ziel_id}/os-update")
|
|
def os_update(ziel_id: str) -> dict:
|
|
return _antwort(updates.starten(ziel_id, "os"))
|
|
|
|
|
|
@router.post("/ziele/{ziel_id}/docker")
|
|
def docker(ziel_id: str) -> dict:
|
|
"""Docker über Arcanes Updater — zuerst nur als Probelauf (MC_ARCANE_ECHT=1 macht es echt)."""
|
|
return _antwort(updates.starten(ziel_id, "docker"))
|
|
|
|
|
|
@router.post("/ziele/{ziel_id}/rueckweg-probe")
|
|
def rueckweg_probe(ziel_id: str) -> dict:
|
|
"""Rückweg testen (seit 25.09.2026): Snapshot anlegen, absichtlich rot werten, zurückgehen, nachprüfen."""
|
|
return _antwort(updates.starten(ziel_id, "probe"))
|
|
|
|
|
|
@router.post("/ziele/pve/neustart")
|
|
def host_neustart() -> dict:
|
|
return _antwort(updates.starten("pve", "neustart"))
|
|
|
|
|
|
@router.post("/ziele/pve/kernel-aufraeumen")
|
|
def host_kernel_aufraeumen() -> dict:
|
|
"""Alte Kernel vom Proxmox-Host entfernen (seit 25.09.2026, nur per Knopf). Welche, entscheidet der Ausführer."""
|
|
return _antwort(updates.starten("pve", "kernel"))
|
|
|
|
|
|
@router.post("/ziele/{ziel_id}/suchen")
|
|
def suchen(ziel_id: str) -> dict:
|
|
gast = inventar.gast(ziel_id)
|
|
if not gast or not gast.get("erlaubt"):
|
|
raise HTTPException(status_code=404, detail="Dieses Gerät steht nicht (freigegeben) im Bericht.")
|
|
return {"ok": True, "auftrag": kanal.anlegen("suchen", {"vmid": gast["vmid"]})}
|
|
|
|
|
|
@router.get("/hinweise")
|
|
def hinweise() -> dict:
|
|
"""Was der Wächter dieser Instanz gerade sieht (Ausführer schweigt, Gast antwortet nicht …)."""
|
|
from services import waechter
|
|
return waechter.lese_stand()
|
|
|
|
|
|
@router.get("/laeufe")
|
|
def laeufe() -> dict:
|
|
return {"laeufe": updates.laeufe()}
|
|
|
|
|
|
# --- „Alle aktualisieren“, Protokoll, Einstellungen (24.09.2026) ------------------------------------------------
|
|
# GET /api/homelab/alle/plan was „Alle aktualisieren“ täte, in dieser Reihenfolge
|
|
# POST /api/homelab/alle „Alle aktualisieren“ starten
|
|
# GET /api/homelab/sammellauf der laufende oder der letzte Sammellauf
|
|
# GET /api/homelab/protokoll?grenze=200&berichte=0 was wann geschah, neueste zuerst
|
|
# GET /api/homelab/einstellungen Arcane (Schlüssel, echt), Wartezeit, Ausführer
|
|
# POST /api/homelab/einstellungen/arcane-schluessel {"schluessel": …}: erst gegen Arcane prüfen, dann speichern
|
|
# DELETE /api/homelab/einstellungen/arcane-schluessel
|
|
# POST /api/homelab/einstellungen/arcane-echt {"an": bool}: echte Docker-Updates statt Probelauf
|
|
# Was nicht geht, beantworten sie mit {"ok": false, "detail": …} und HTTP 200, wie der Telegram-Test (die Oberfläche
|
|
# liest ok). Die Dienste laden die Endpunkte selbst, wie hinweise().
|
|
|
|
@router.get("/alle/plan")
|
|
def alle_plan() -> dict:
|
|
from services.homelab import sammellauf
|
|
return sammellauf.plan()
|
|
|
|
|
|
@router.post("/alle")
|
|
def alle_starten() -> dict:
|
|
from services.homelab import sammellauf
|
|
return sammellauf.starten()
|
|
|
|
|
|
@router.get("/sammellauf")
|
|
def sammellauf_stand() -> dict:
|
|
from services.homelab import sammellauf
|
|
return {"sammellauf": sammellauf.aktueller()}
|
|
|
|
|
|
@router.post("/sammellauf/{sl_id}/quittieren")
|
|
def sammellauf_quittieren(sl_id: str) -> dict:
|
|
"""Beendetes „Alle aktualisieren“ als gesehen markieren (die Update-Seite blendet es aus)."""
|
|
from services.homelab import sammellauf
|
|
return {"ok": sammellauf.quittieren(sl_id)}
|
|
|
|
|
|
@router.get("/protokoll")
|
|
def protokoll_lesen(grenze: int = 200, berichte: bool = False) -> dict:
|
|
from services.homelab import protokoll
|
|
return {"eintraege": protokoll.eintraege(grenze, berichte)}
|
|
|
|
|
|
@router.get("/speicher")
|
|
def speicher_lage() -> dict:
|
|
"""Speicherpool, NAS und Sicherungen samt Empfehlungen, was Platz bringt (Karte im Homelab-Cockpit)."""
|
|
from services.homelab import speicher
|
|
return speicher.lage()
|
|
|
|
|
|
@router.get("/zertifikate")
|
|
def zertifikate_lage() -> dict:
|
|
"""Die https-Zertifikate mit Ablauf und Stufe (Zertifikats-Wache, seit 25.09.2026)."""
|
|
from services.homelab import zertifikate
|
|
return zertifikate.lage()
|
|
|
|
|
|
@router.post("/sicherungen/probe")
|
|
def sicherungen_probe() -> dict:
|
|
"""Knopf „Jetzt prüfen“: die jüngste Sicherung jedes Geräts probeweise öffnen (seit 25.09.2026)."""
|
|
from services.homelab import speicher
|
|
return _antwort(speicher.probe_starten())
|
|
|
|
|
|
# --- Snapshots und Sicherungen verwalten (Seite Homelab → Snapshots, seit 25.09.2026) ------------------------------
|
|
# GET /api/homelab/snapshots je Gerät Snapshots, Sicherungen, ob gerade etwas läuft
|
|
# POST /api/homelab/snapshots/{id}/anlegen Snapshot jetzt anlegen (freigegeben, Snapshot möglich)
|
|
# POST /api/homelab/snapshots/{id}/{name}/loeschen nur Snapshots des Orchestrators (mc2-…)
|
|
# POST /api/homelab/snapshots/{id}/{name}/zuruecksetzen ebenso; danach Prüfung wie nach einem Update
|
|
# POST /api/homelab/sicherungen/{id}/{datei}/loeschen nur Sicherungen des Orchestrators (Bericht)
|
|
# POST /api/homelab/snapshots/rueckwege-loeschen alle Rückwege von Updates, nacheinander (nur per Klick)
|
|
# Jede Aktion ist ein Lauf wie „Jetzt updaten“; was nicht geht, beantworten sie mit 409 wie die Update-Knöpfe.
|
|
|
|
@router.get("/snapshots")
|
|
def snapshots_liste() -> dict:
|
|
from services.homelab import snapshots
|
|
return snapshots.liste()
|
|
|
|
|
|
@router.post("/snapshots/rueckwege-loeschen")
|
|
def rueckwege_loeschen() -> dict:
|
|
"""Alle Rückwege von Updates löschen (Snapshots mit Herkunft „update“, Sicherungen des Orchestrators) — nur per
|
|
Klick des Users, nacheinander, mit Meldung am Ende."""
|
|
from services.homelab import snapshots
|
|
return _antwort(snapshots.rueckwege_loeschen())
|
|
|
|
|
|
@router.post("/snapshots/{ziel_id}/anlegen")
|
|
def snapshot_anlegen(ziel_id: str) -> dict:
|
|
from services.homelab import snapshots
|
|
return _antwort(snapshots.anlegen(ziel_id))
|
|
|
|
|
|
@router.post("/snapshots/{ziel_id}/{name}/loeschen")
|
|
def snapshot_loeschen(ziel_id: str, name: str) -> dict:
|
|
from services.homelab import snapshots
|
|
return _antwort(snapshots.loeschen(ziel_id, name))
|
|
|
|
|
|
@router.post("/snapshots/{ziel_id}/{name}/zuruecksetzen")
|
|
def snapshot_zuruecksetzen(ziel_id: str, name: str) -> dict:
|
|
from services.homelab import snapshots
|
|
return _antwort(snapshots.zuruecksetzen(ziel_id, name))
|
|
|
|
|
|
@router.post("/sicherungen/{ziel_id}/{datei}/loeschen")
|
|
def sicherung_loeschen(ziel_id: str, datei: str) -> dict:
|
|
from services.homelab import snapshots
|
|
return _antwort(snapshots.sicherung_loeschen(ziel_id, datei))
|
|
|
|
|
|
@router.get("/einstellungen")
|
|
def einstellungen_stand() -> dict:
|
|
from services.homelab import einstellungen as homelab_einstellungen
|
|
return homelab_einstellungen.stand()
|
|
|
|
|
|
@router.post("/einstellungen/arcane-schluessel")
|
|
async def arcane_schluessel_setzen(request: Request) -> dict:
|
|
"""Den Body liest der Dienst selbst: Ein Pydantic-Modell würde bei falscher Form mit 422 antworten und die
|
|
Eingabe, also den Schlüssel, dabei wiederholen."""
|
|
from services.homelab import einstellungen as homelab_einstellungen
|
|
wert = homelab_einstellungen.schluessel_aus_body(await request.body())
|
|
return await asyncio.to_thread(homelab_einstellungen.arcane_schluessel_setzen, wert)
|
|
|
|
|
|
@router.delete("/einstellungen/arcane-schluessel")
|
|
def arcane_schluessel_loeschen() -> dict:
|
|
from services.homelab import einstellungen as homelab_einstellungen
|
|
return homelab_einstellungen.arcane_schluessel_loeschen()
|
|
|
|
|
|
class ArcaneEcht(BaseModel):
|
|
an: bool
|
|
|
|
|
|
@router.post("/einstellungen/arcane-echt")
|
|
def arcane_echt(schalter: ArcaneEcht) -> dict:
|
|
from services.homelab import einstellungen as homelab_einstellungen
|
|
return homelab_einstellungen.arcane_echt_setzen(schalter.an)
|
|
|
|
|
|
@router.get("/ausfuehrer")
|
|
def ausfuehrer() -> dict:
|
|
zuletzt = kanal.zuletzt()
|
|
return {"zuletzt": zuletzt, "verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
|
|
"auftraege": kanal.liste()[:20]}
|
|
|
|
|
|
@router.get("/ausfuehrer/auftrag", dependencies=[Depends(_nur_ausfuehrer)])
|
|
def auftrag_abholen() -> dict:
|
|
return kanal.naechster() or {}
|
|
|
|
|
|
@router.post("/ausfuehrer/bericht", dependencies=[Depends(_nur_ausfuehrer)])
|
|
def bericht(daten: dict) -> dict:
|
|
from services.homelab import speicher
|
|
kanal.bericht_speichern(daten)
|
|
speicher.verlauf_merken(daten) # Verlauf für „voll in etwa N Tagen“ (seit 25.09.2026)
|
|
return {"ok": True}
|
|
|
|
|
|
class Ergebnis(BaseModel):
|
|
code: int
|
|
text: str = ""
|
|
|
|
|
|
@router.post("/ausfuehrer/ergebnis/{auftrag_id}", dependencies=[Depends(_nur_ausfuehrer)])
|
|
def ergebnis(auftrag_id: str, e: Ergebnis) -> dict:
|
|
return {"ok": kanal.ergebnis(auftrag_id, e.code, e.text)}
|
|
|
|
|
|
# --- Live-Strom der Homelab-Instanz ---------------------------------------------------------------
|
|
# Die Oberfläche hört auf /api/stream der Instanz, die sie ausliefert. Im Homelab gibt es keine
|
|
# Messpunkte der KI-Box; der Strom stößt nur neu laden an, wenn sich Bericht, Läufe oder Hinweise ändern,
|
|
# und meldet sich alle 10 s mit einem leeren Anstoß (sonst hielte die Oberfläche die Leitung für tot).
|
|
_DATEIEN = (("homelab", "pve-bericht.json"), ("homelab-laeufe", "homelab-laeufe.json"),
|
|
("homelab-hinweise", "mc2-waechter.json"))
|
|
|
|
|
|
def _abdruecke() -> dict[str, float]:
|
|
ordner = einstellungen().daten_dir
|
|
abdruck = {}
|
|
for schluessel, name in _DATEIEN:
|
|
try:
|
|
abdruck[schluessel] = (ordner / name).stat().st_mtime
|
|
except OSError:
|
|
abdruck[schluessel] = 0.0
|
|
return abdruck
|
|
|
|
|
|
async def _strom(request: Request, takt_s: float = 2.0, lebenszeichen_takte: int = 5):
|
|
alt = _abdruecke()
|
|
yield ": verbunden\n\n"
|
|
takte = 0
|
|
while not await request.is_disconnected():
|
|
await asyncio.sleep(takt_s)
|
|
takte += 1
|
|
neu = _abdruecke()
|
|
schluessel = [k for k, v in neu.items() if v != alt.get(k)]
|
|
alt = neu
|
|
if schluessel or takte % lebenszeichen_takte == 0:
|
|
yield f"event: invalidate\ndata: {json.dumps({'keys': schluessel})}\n\n"
|
|
|
|
|
|
@strom_router.get("/stream")
|
|
async def stream(request: Request) -> StreamingResponse:
|
|
return StreamingResponse(_strom(request), media_type="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
|
|
|
|
|
# --- Messwerte mit Verlauf (Monitoring, seit 24.09.2026) ------------------------------------------------------
|
|
# POST /api/homelab/ausfuehrer/messwerte jede Minute vom Ausführer (Kopfzeile X-MC2-Ausfuehrer)
|
|
# GET /api/homelab/messwerte?zeitraum=1h|24h|7d Proxmox-Host und Gäste: Reihen und letzter Punkt
|
|
# Die Logik liegt in services/homelab/messwerte.py, die Ablage in kern/messreihen.py.
|
|
|
|
@router.post("/ausfuehrer/messwerte", dependencies=[Depends(_nur_ausfuehrer)])
|
|
def messwerte_annehmen(daten: dict) -> dict:
|
|
from services.homelab import messwerte as homelab_messwerte
|
|
return {"ok": True, "geraete": homelab_messwerte.annehmen(daten)}
|
|
|
|
|
|
@router.get("/messwerte")
|
|
def messwerte_lesen(zeitraum: str = "1h") -> dict:
|
|
from services.homelab import messwerte as homelab_messwerte
|
|
if zeitraum not in homelab_messwerte.ZEITRAEUME:
|
|
raise HTTPException(status_code=400, detail="Den Zeitraum gibt es nicht; möglich sind 1h, 24h und 7d.")
|
|
return homelab_messwerte.abfrage(zeitraum)
|