""" Homelab Orchestrator (vormals Mission Control 2.0) — dünner FastAPI-Einstieg. Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev- Server (proxyt /api hierher), daher CORS für localhost offen. Zwei Rollen, derselbe Code (MC_ROLLE, kern/einstellungen.py, seit 24.09.2026): box — die KI-Box: Box-Wart (Updater, Wächter, Modell-Radar) plus die Schnittstellen, die Lucy-Desktop, OpenChamber und Hermes brauchen (Sprache, /v1, MCP-Werkzeuge) homelab — der Proxmox-PC: der Homelab-Teil Beide liefern dieselbe Oberfläche aus und reichen den Bereich der anderen Instanz unter /api/partner/… durch. """ import asyncio import logging import os from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager from typing import Any import httpx from config import FRONTEND_DIST, V1_UPSTREAM, VERSION from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from kern.einstellungen import einstellungen from routers import health, partner from services.herkunft import erlaubt from starlette.requests import Request ROLLE = einstellungen().rolle # Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration # für alle Module (logging.getLogger(__name__)). logging.basicConfig( level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", ) log = logging.getLogger(__name__) def _box_hintergrund() -> list[asyncio.Task[Any]]: """Hintergrund-Tasks der KI-Box. Der Wächter läuft NICHT hier, sondern im mc2-steward (eigener Prozess, steward.py) — ein totes MC2 könnte sich sonst nicht selbst melden.""" from services import reminders, warmer tasks: list[asyncio.Task[Any]] = [] if warmer.ENABLED: tasks.append(asyncio.create_task(warmer.rewarm_loop())) log.info( "Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)", warmer.INTERVAL, ) # Probelauf (neue Fassung neben der laufenden, Box-Wart-Umbau 09/2026): Erinnerungen # gehören dem echten MC2 — zwei Schreiber würden Erinnerungen doppelt feuern. if os.environ.get("MC_PROBELAUF", "") != "1": tasks.append(asyncio.create_task(reminders.reminders_loop())) else: log.info("Probelauf: Erinnerungen bleiben beim echten MC2.") return tasks @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Hintergrund-Tasks an den App-Lebenszyklus binden.""" tasks = _box_hintergrund() if ROLLE == "box" else [] # Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client # pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo). app.state.gw_client = httpx.AsyncClient( timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0), limits=httpx.Limits(max_keepalive_connections=100, max_connections=200), ) try: yield finally: for task in tasks: _ = task.cancel() await app.state.gw_client.aclose() app = FastAPI(title="Homelab Orchestrator", version=VERSION, lifespan=lifespan) # Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf. app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], allow_methods=["*"], allow_headers=["*"], ) # Herkunftsprüfung (24.09.2026, User-Entscheid: keine Anmeldung): Knöpfe fremder Webseiten werden # abgelehnt, Skripte, Desktop-Lucy und /v1 bleiben unberührt. Regeln in services/herkunft.py. @app.middleware("http") async def herkunft(request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]): if not erlaubt(request.method, request.url.path, request.headers.get("origin"), request.headers.get("host")): return JSONResponse({"detail": "Diese Aktion geht nur von der Orchestrator-Seite selbst aus."}, status_code=403) return await call_next(request) @app.middleware("http") async def no_cache( request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]] ) -> httpx.Response: resp = await call_next(request) if request.url.path.startswith("/api"): resp.headers["Cache-Control"] = "no-cache" return resp def _box_router(app: FastAPI) -> None: """Die Schnittstellen der KI-Box (Box-Wart und alles, was Lucy, OpenChamber und Hermes brauchen).""" from routers import ( boxwart, events, gateway_proxy, hermes_ui, maintenance, models, radar, routing, system, voice, zeitmaschine, ) from routers import reminders as reminders_router app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen app.include_router(models.router) app.include_router(routing.router) app.include_router(system.router) app.include_router(voice.router) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat app.include_router(reminders_router.router) # Erinnerungen/Routinen — feuern in den Briefkasten # /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als # eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit # LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten # Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst. if V1_UPSTREAM: from routers import gateway_forward app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway else: app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto) app.include_router(maintenance.router) app.include_router(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached) # Eingebaute Hermes-Web-GUI (hermes dashboard) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all app.include_router(hermes_ui.router) app.include_router(health.router) app.include_router(partner.router) # zweite Instanz: Lebenszeichen + Durchreiche if ROLLE == "box": _box_router(app) # Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html. if FRONTEND_DIST.exists(): app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") _DIST_ROOT = FRONTEND_DIST.resolve() @app.get("/{full_path:path}") def spa(full_path: str): # Unbekannte /api-Pfade sind ein echter 404, keine Startseite: Sonst bekommt die # Oberfläche HTML statt JSON und stürzt ab (erster Probelauf 23.09., /api/radar). if full_path == "api" or full_path.startswith("api/"): raise HTTPException(status_code=404, detail="Diese Schnittstelle gibt es nicht.") # Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber # NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus. try: target = (FRONTEND_DIST / full_path).resolve() if target.is_relative_to(_DIST_ROOT) and target.is_file(): return FileResponse(target) except (ValueError, OSError): pass index = FRONTEND_DIST / "index.html" if index.exists(): # index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle. return FileResponse( index, headers={"Cache-Control": "no-cache, must-revalidate"} ) return {"detail": "frontend not built"}