From e83c0e2c702c7cd982848375ad86f263847eb549 Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:23:16 +0200 Subject: [PATCH] Add reproducible Docker and WireGuard host bootstrap --- .dockerignore | 12 + .env.example | 26 + .gitignore | 2 + README.md | 639 ++---------------- compose.yaml | 399 +++++++++++ config/install.env.example | 53 ++ dev/test_profile_controller.py | 58 ++ docs/ARCHITECTURE.md | 159 ++--- docs/CURRENT_REFERENCE.md | 4 + docs/INSTALLATION.md | 139 ++-- docs/NEW_HOST_ROADMAP.md | 46 ++ docs/RECOVERY_REQUIREMENTS.md | 13 +- docs/SECURITY.md | 110 ++- docs/WIREGUARD_HOME_PEER.md | 55 ++ install.sh | 330 +++++++++ platform/docker/llama-cpp/Dockerfile | 34 + platform/docker/mcp-standard.json | 12 + platform/docker/profile-controller/Dockerfile | 3 + .../profile-controller/profile_controller.py | 164 +++++ platform/docker/router/Dockerfile | 9 + platform/docker/router/entrypoint.sh | 4 + platform/models/manifest.example.yaml | 5 +- platform/web-search/web_search_mcp.py | 40 +- router/ai_profile_router.py | 97 ++- router/router_profiles.json | 6 +- 25 files changed, 1584 insertions(+), 835 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 compose.yaml create mode 100644 config/install.env.example create mode 100644 dev/test_profile_controller.py create mode 100644 docs/NEW_HOST_ROADMAP.md create mode 100644 docs/WIREGUARD_HOME_PEER.md create mode 100755 install.sh create mode 100644 platform/docker/llama-cpp/Dockerfile create mode 100644 platform/docker/mcp-standard.json create mode 100644 platform/docker/profile-controller/Dockerfile create mode 100644 platform/docker/profile-controller/profile_controller.py create mode 100644 platform/docker/router/Dockerfile create mode 100755 platform/docker/router/entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5eb023c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.gitignore +config/install.env +*.local.* +*.log +__pycache__ +*.pyc +docs +dev +deploy +benchmarks +artifacts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9c87776 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Generated as /etc/mike-ai/stack.env by install.sh. Never commit real values. +AI_BIND_ADDRESS=10.77.0.2 +MODEL_DIR=/srv/mike-ai/models +ROUTER_API_KEY=GENERATED_BY_INSTALLER +CONTROLLER_TOKEN=GENERATED_BY_INSTALLER +WEBUI_SECRET_KEY=GENERATED_BY_INSTALLER +OPENWEBUI_IMAGE=ghcr.io/open-webui/open-webui:v0.9.5 +AI_DNS=192.168.1.1 + +FAST_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf +MEDIUM_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf +LONG_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf +EXPERIMENTAL_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf +VISION_PROJECTOR_FILE=qwen/mmproj-BF16.gguf +MTP_MODEL_FILE=qwen/mtp-Qwen3.8-27B-Q4_0.gguf + +FAST_CONTEXT=76800 +MEDIUM_CONTEXT=94208 +LONG_CONTEXT=131072 +EXPERIMENTAL_CONTEXT=76800 +FAST_GPU_DEVICES=0 +MEDIUM_GPU_DEVICES=0 +LONG_GPU_DEVICES=0 +EXPERIMENTAL_GPU_DEVICES=0 +LLAMA_THREADS=6 +LLAMA_THREADS_BATCH=6 diff --git a/.gitignore b/.gitignore index e614be2..7115173 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ logs/ *.bin .venv/ venv/ +config/install.env +platform/web-search/searxng-settings.yml diff --git a/README.md b/README.md index ffffb39..04fd9ba 100644 --- a/README.md +++ b/README.md @@ -1,592 +1,83 @@ # Lokale KI-Plattform -Dieses private Repository dokumentiert und installiert die reproduzierbare -KI-Umgebung rund um einen lokalen `llama.cpp`-Host. Der **AI Profile Router** -bleibt der zentrale Bestandteil, ist aber nicht mehr die einzige Komponente. +Reproduzierbarer Docker-Stack für einen privaten Qwen-/llama.cpp-Host mit +Open WebUI, Profilumschaltung, integrierter Vision, lokaler Websuche und +WireGuard-Isolation. -## Plattform auf einen Blick +## Zielbild -```text -Clients (Open WebUI, Hermes, Apps) - | - v -AI Profile Router :8081 - |-- qwen-fast (76.8K, maximale Geschwindigkeit) - |-- qwen-medium (92K, reines IQ4_XS) - |-- qwen-long (128K, CPU-Offload) - |-- integrierte Qwen-Vision (kein Hotswap) - |-- lokale Bildgenerierung - |-- Whisper STT - `-- XTTS TTS - | - v -llama.cpp :8080 + profilabhängige MCP-Server +- Debian 13 als schlanker GPU-Host +- llama.cpp selbst gebaut und auf einen geprüften Commit festgelegt +- vier getrennte Profilcontainer, davon immer exakt einer aktiv +- `/fast`, `/medium`, `/long` und `/experimental` über den Profile Router +- Open WebUI als einzige normale Oberfläche +- SearXNG/Web-MCP ohne externen API-Schlüssel +- KI-Dienste ausschließlich über die WireGuard-Adresse erreichbar +- KI-Ausgangsverkehr über das Heimnetz, bei Tunnelausfall fail-closed +- keine Secrets, Chats, Logs oder Modelldateien im Repository + +## Schnellstart + +Auf einem frisch installierten Debian 12/13 amd64: + +```bash +cp config/install.env.example config/install.env +chmod 600 config/install.env +editor config/install.env +sudo ./install.sh --config config/install.env ``` -### Enthalten +Installiert werden Docker CE, NVIDIA Container Toolkit, WireGuard, der +gepinnt gebaute llama.cpp-Server, die Modelle und der komplette Compose-Stack. +Bei einer erstmaligen NVIDIA-Treiberinstallation fordert das Skript einen +Neustart an; danach wird derselbe Befehl erneut ausgeführt. -- produktiver Router samt Tests und Deployment -- fest definierte Fast-/Medium-/Long-Profile -- reproduzierbarer `llama.cpp`-Build über einen festgelegten Commit -- systemd-Vorlagen und Profilumschaltung -- Modellmanifest ohne Modelldateien -- sichere MCP-Beispielkonfiguration ohne Zugangsdaten -- Installations-, Betriebs-, Sicherheits- und Migrationsanleitung -- Prüfskript für einen frisch installierten Host +## Dienste -### Bewusst nicht enthalten +| Dienst | Erreichbarkeit | Zweck | +|---|---|---| +| Open WebUI | `:8080` | Chat und Administration | +| Profile Router | `:8081` | OpenAI-kompatible API, Profilwahl | +| llama.cpp | nur Docker-intern | Inferenz, Vision, MCP | +| Profile Controller | nur Docker-intern | eng begrenzter Containerwechsel | +| SearXNG | nur Docker-intern | Websuche | -- GGUF-, Whisper-, FLUX- oder XTTS-Modelldateien -- API-Schlüssel, Tokens, SSH-Schlüssel oder Zertifikate -- Chats, Prompts, Logs, Bilder oder Audiodateien -- alte Benchmarks, experimentelle Builds und ausgemusterte RX-Dienste -- hostgebundene Backups und Cache-Verzeichnisse +Bildgenerierung, TTS/STT sowie Home-Assistant-, ARR- und Unraid-MCPs werden +bewusst nicht automatisch aktiviert. Sie erhalten später eigene Container und +kleinstmögliche Rechte. Die Bildanalyse ist bereits Bestandteil des +multimodalen Qwen-Modells. ## Dokumentation -- [Architektur](docs/ARCHITECTURE.md) -- [Komponentenverzeichnis](docs/COMPONENTS.md) -- [Aktueller produktiver Referenzstand](docs/CURRENT_REFERENCE.md) -- [Noch benötigte Wiederherstellungsartefakte](docs/RECOVERY_REQUIREMENTS.md) -- [Disaster Recovery und Abnahme](docs/DISASTER_RECOVERY.md) -- [Saubere Installation](docs/INSTALLATION.md) +- [Roadmap für den neuen Host](docs/NEW_HOST_ROADMAP.md) +- [Zielarchitektur und Sicherheitsgrenzen](docs/ARCHITECTURE.md) +- [Installation und Abnahme](docs/INSTALLATION.md) +- [WireGuard-Heimseite](docs/WIREGUARD_HOME_PEER.md) - [Betrieb und Profilwechsel](docs/OPERATIONS.md) - [Sicherheitsmodell](docs/SECURITY.md) -- [Router V2: Migration und Kompatibilität](docs/ROUTER_V2_MIGRATION.md) -- [Migration vom bestehenden Host](docs/MIGRATION.md) -- [MCP-Aufteilung](platform/mcp/README.md) -- [llama.cpp-Build und Profile](platform/llama/README.md) +- [Disaster Recovery](docs/DISASTER_RECOVERY.md) +- [Komponenten](docs/COMPONENTS.md) -## AI Profile Router +## Wichtige Dateien -Kleiner OpenAI-kompatibler Proxy (Python, nur Standardbibliothek) vor einem -lokalen llama.cpp-Server. Er leitet normale OpenAI-Requests transparent -weiter (Streaming, Tool Calls, JSON und Bilder), schaltet zwischen drei festen -multimodalen llama.cpp-Profilen um, orchestriert lokale Bildgenerierung mit -FLUX.2 [klein] 4B Base (GPU-Hotswap: Qwen stoppen → FLUX laden → Bild -→ FLUX entladen → Qwen wiederherstellen) und stellt lokale deutsche -Sprachausgabe bereit (XTTS-v2, CPU-only, OpenAI-kompatibel). - -## Zielsystem - -| | | -|---|---| -| Host | frei wählbarer Linux-KI-Host | -| SSH | administrativer Zugang nur für Installation und Wartung | -| llama.cpp | `http://127.0.0.1:8080` (Service `mike-ai-llama-ui.service`) | -| Profil-Skript | `/usr/local/bin/llama-profile {fast\|medium\|long}` | -| Router-Port | **8081** | -| Router-Service | `mike-ai-profile-router.service` | -| TTS-Worker | `http://127.0.0.1:8085` (Service `mike-ai-xtts.service`) | -| STT-Worker | `http://127.0.0.1:8084` (Service `mike-ai-whisper.service`) | - -## Profile / virtuelle Modelle - -| Profil | Modell | Kontext | -|---|---|---| -| `fast` | `qwen-fast` | 76800 | -| `medium` | `qwen-medium` | 94208 | -| `long` | `qwen-long` | 131072 | - -## Endpunkte - -| Endpunkt | Beschreibung | -|---|---| -| `GET /v1/models` | Die drei virtuellen Modelle inkl. `context_length`/`context_window` | -| `GET /health` | öffentliche Liveness-Prüfung des Routerprozesses | -| `GET /ready` | öffentliche Readiness-Prüfung von Router + Textmodell | -| `GET /status` | Aktives Profil, Upstream-Zustand, Modell, Kontext, Uptime | -| `POST /fast` `/medium` `/long` | expliziter Profilwechsel | -| `POST /v1/chat/completions` | Weiterleitung an llama.cpp (Streaming + Tool Calls) | -| `POST /v1/images/generations` | Bildgenerierung (FLUX.2 [klein] 4B Base, OpenAI-kompatibel) | -| `GET /images` | Liste der aufbewahrten Bilder | -| `GET /images/` | PNG-Download (nur `images/`-Verzeichnis, validiert) | -| `POST /v1/audio/speech` | Sprachausgabe (XTTS-v2, OpenAI-kompatibel) | -| `POST /v1/audio/transcriptions` | Deutsche Spracherkennung (whisper.cpp, OpenAI-kompatibel) | -| `GET /v1/audio/models` | Verfügbare Audio-Modelle (STT + TTS) | -| `GET /v1/audio/voices` | Verfügbare TTS-Stimmen | -| alles andere | Transparente Weiterleitung an llama.cpp | - -Bis auf `GET /health` und `GET /ready` benötigen alle Endpunkte einen -Router-API-Key als `Authorization: Bearer …` oder `X-API-Key`. Der Installer -erzeugt ihn einmalig in `/etc/mike-ai/router-api-key` und gibt ihn niemals im -Installationslog aus. Ein fehlender oder zu kurzer Schlüssel verhindert den -Produktivstart (fail closed). - -### Verhalten - -- **Virtuelles Modell** (`qwen-fast`/`qwen-medium`/`qwen-long` in - `chat.completions`): Der Router stellt sicher, dass das passende Profil - aktiv ist (notfalls Wechsel + Warten), ersetzt das Modell durch das echte - llama.cpp-Modell und leitet weiter. -- **Profilwechsel** (`POST /fast` …): Führt `/usr/local/bin/llama-profile - ` aus (ohne Shell, feste Argumente → keine Injection), wartet dann, - bis llama.cpp wieder erreichbar ist, und liefert erst dann `200`. -- **Impliziter Wechsel bei downem llama.cpp**: Ein Chat-Request mit virtuellem - Modell liefert sofort `502`, wenn das Profil bereits aktiv ist, aber - llama.cpp down ist (kein stiller Neustart). Der Neustart wird explizit über - `POST /` angestoßen. -- **Ungültige Profile/Modelle**: `POST /` → `400`; - `qwen-` als Modell → `400`. Nur die drei festen Profile sind - schaltbar. -- **Fehlerformat**: OpenAI-kompatibel (`{"error": {"message", "type", "code"}}`). - -## Bildgenerierung (FLUX.2 [klein] 4B Base) - -Der Router orchestriert lokale Bildgenerierung mit -`black-forest-labs/FLUX.2-klein-base-4B` (Apache 2.0, ~13 GB, bf16 + -CPU-Offload). Da Qwen (llama.cpp) und FLUX denselben GPU/VRAM teilen, macht -der Router einen **GPU-Hotswap**: - -1. Zentrales GPU/Modell-Lock übernehmen (Profilwechsel und Bild teilen sich - dasselbe Lock → kein Race). -2. Aktives Qwen-Profil merken. -3. `mike-ai-llama-ui.service` stoppen, warten bis Port + VRAM frei sind. -4. FLUX-Worker starten (eigener Prozess, `Flux2KleinPipeline`, bf16 + - `enable_model_cpu_offload()`), Bild generieren, PNG speichern. -5. Worker **beenden** (nicht nur entladen), VRAM-Freiheit verifizieren. -6. Vorheriges Qwen-Profil exakt wiederherstellen, Readiness-Check - (Modell geladen + Kontext passt). -7. Erst dann antworten und das GPU-Lock freigeben. - -### Endpunkt `POST /v1/images/generations` - -OpenAI-kompatibel. Unterstützt `prompt`, `size`, `n`, `seed`, `quality`, -`response_format`. - -| Parameter | Werte | Default | -|---|---|---| -| `prompt` | Text (Pflicht) | – | -| `size` | `1024x1024`, `1536x1024`, `1024x1536`, `1920x1088`, `1088x1920` | `1024x1024` | -| `n` | 1–4 | 1 | -| `seed` | int (reproduzierbar) | zufällig | -| `quality` | `standard` (30 Steps), `high` (50 Steps) | `standard` | -| `response_format` | `url` (Default), `b64_json` | `url` | - -Beispiel: - -```bash -curl -s http://AI_HOST:8081/v1/images/generations \ - -H "Authorization: Bearer $ROUTER_KEY" \ - -H 'Content-Type: application/json' \ - -d '{"prompt":"ein roter Würfel auf weißem Grund","size":"1024x1024","quality":"standard"}' +```text +install.sh kompletter Bootstrap +config/install.env.example öffentliche Konfigurationsvorlage +compose.yaml produktiver Stack +platform/docker/llama-cpp/Dockerfile CUDA-llama.cpp-Build +platform/docker/profile-controller/ sichere Profilsteuerung +router/ OpenAI-kompatibler Profile Router ``` -Die Antwort enthält `data[].url` (absolute URL, über den Router abrufbar) -und `data[].b64_json` (optional). Jedes Bild wird unter -`/opt/mike-ai/ai-profile-router/images/` gespeichert (kollisionsfreie Namen, -`img---.png`) und ist über `GET /images/` -abrufbar. - -### Verhalten während eines Bild-Jobs - -- **Chat-Requests warten** (kein 502): Der Router merkt sich, dass Qwen - vorübergehend nicht verfügbar ist (`qwen.available=false`), und Chat-Requests - warten, bis Qwen wieder bereit ist (Timeout `CHAT_WAIT_TIMEOUT`, Default - 300 s). So gibt es keine `502 llama.cpp nicht erreichbar` während des - Hotswaps. -- **Profilwechsel warten**: Ein Profilwechsel während eines Bild-Jobs - blockiert auf dem GPU-Lock, bis der Bild-Job fertig ist (kein Race). -- **`/status`** zeigt den aktuellen Zustand: `image.phase` (`idle`, - `stopping-qwen`, `loading-image`, `generating`, `unloading-image`, - `restoring-qwen`), `image.worker`, `image.model_loaded`, - `image.last_image`, `image.last_seconds`, `image.last_error`, - `qwen.available`, `qwen.active_chats`. - -### Recovery (robust) - -- **`try/finally`**: Qwen wird **immer** wiederhergestellt, egal ob die - Bildgenerierung erfolgreich war, fehlgeschlagen ist (OOM, Python-Fehler, - ungültiger Prompt, Speichern-Fehler, Client-Disconnect, Timeout) oder der - Worker abstürzt. -- **Worker-Beendigung**: Nach jedem Job wird der Worker beendet (SIGTERM → - SIGKILL), nicht nur entladen. So wird der VRAM (inkl. CUDA-Kontext) frei. -- **VRAM-Check**: Nach dem Worker-Beenden wartet der Router, bis der VRAM - unter 1000 MiB fällt (`nvidia-smi`), bevor Qwen neu startet. -- **Qwen-Readiness**: Nach dem Neustart wartet der Router, bis llama.cpp - erreichbar ist, das Modell geladen ist und der Kontext zum Profil passt. -- **`qwen.available`**: Bleibt `false`, wenn die Wiederherstellung fehlschlägt - (Chat-Requests warten weiter, statt 502 zu liefern). Der Fehler wird in - `image.last_error` und im Log protokolliert. - -### Benchmarks (RTX 5080, 16 GB, CPU-Offload, gemessen) - -| Auflösung | Steps | Zeit | Peak-VRAM (torch) | -|---|---|---|---| -| 512×512 | 10 | ~9.3 s | ~8.4 GB | -| 1024×1024 | 30 | ~31.3 s | ~8.4 GB | -| 1024×1024 | 50 | ~45.3 s | ~8.4 GB | -| 1920×1088 | 50 | ~91 s | ~8.9 GB | - -**Entscheidung:** `standard` = 30 Steps (Default, ~31 s bei 1024×1024), -`high` = 50 Steps (maximale Qualität, ~45 s bei 1024×1024). Ab 20–30 Steps -ist der Qualitätsgewinn bei einfachen Motiven gering; 50 Steps lohnt sich -für komplexe Szenen. - -**Hinweis:** FLUX.2 [klein] 4B Base passt **nicht** vollständig GPU-resident -in 16 GB (OOM bei ~15.5 GB). Deshalb wird `enable_model_cpu_offload()` -verwendet (Modelle werden pro Layer zwischen CPU und GPU gewechselt). - -**Hotswap-Gesamtzeit:** Ein vollständiger Bild-Job (Qwen stoppen → FLUX laden -→ Bild → FLUX entladen → Qwen wiederherstellen) dauert ~41–42 s bei -1024×1024 / 30 Steps (davon ~31 s Generierung, ~10 s Qwen-Stop/Start + -VRAM-Check). - -### Erforderliche Python-Pakete (im Venv) - -- `torch` (2.11.0+cu128, CUDA 12.8) -- `diffusers` (0.40.0.dev0, für `Flux2KleinPipeline`) -- `transformers` (5.15.0) -- `accelerate` (1.14.0, für `enable_model_cpu_offload()`) - -Das Venv liegt unter `/opt/mike-ai/ai-profile-router/venv/` und wird von -`install.sh` automatisch angelegt/aktualisiert. - -## Integrierte Vision - -Alle drei Qwen-Profile laden denselben BF16-Multimodalprojektor direkt beim -Start. Der Projektor bleibt mit `--no-mmproj-offload` im System-RAM und -verbraucht dadurch keinen zusätzlichen VRAM. Bilder in -`POST /v1/chat/completions` werden nach Größen- und URL-Prüfung unverändert an -das aktive Qwen-Profil weitergereicht. Es gibt kein separates Q3-Modell, keinen -Vision-Port, keinen Analyse-Cache und keinen Modellwechsel mehr. Folgefragen -bleiben dadurch im normalen multimodalen Chatverlauf. - -Externe Bild-URLs sind standardmäßig gesperrt; Data-URLs dürfen dekodiert -höchstens 20 MiB groß sein. Die FLUX-Bildgenerierung bleibt ein separater -GPU-Hotswap und ist von dieser Änderung nicht betroffen. - -## Sprachausgabe (XTTS-v2, CPU-only) - -Der Router stellt lokale Sprachausgabe bereit. Die Synthese läuft -in einem **separaten, langlebigen Worker** (`mike-ai-xtts.service`), der -das XTTS-v2-Modell einmalig beim Start lädt und dauerhaft im RAM hält -(niedrige Warm-Start-Latenz). Der Worker ist CPU-only und blockiert weder -Qwen/llama.cpp noch FLUX/GPU – er teilt sich nur den Prozessor. - -- **Modell:** Coqui XTTS-v2 (`tts_models/multilingual/multi-dataset/xtts_v2`, - ~1.9 GB, CPML-Lizenz). -- **Stimme:** `claribel` (Claribel Dervla) – natürliche weibliche Stimme, - unterstützt Deutsch und Englisch. -- **Venv:** eigenes Venv unter `/opt/mike-ai/xtts/venv/` mit Python 3.11 - (Coqui TTS unterstützt kein Python 3.13) und CPU-only `torch`. -- **Modelle:** HuggingFace-Cache unter `/opt/mike-ai/xtts/.cache/` (persistent). - -### Endpunkt `POST /v1/audio/speech` - -OpenAI-kompatibel. Unterstützt `input` (oder `text`), `voice`, `speed`, -`response_format`, `model`. - -| Parameter | Werte | Default | -|---|---|---| -| `input` | Text (Pflicht, max. 8000 Zeichen) | – | -| `voice` | `claribel` | `claribel` | -| `speed` | 0.5–2.0 | 1.0 | -| `response_format` | `mp3` (Default), `wav` | `mp3` | -| `model` | `xtts-v2` (optional) | – | - -Die Antwort ist **binäres Audio** (nicht JSON) mit passendem -`Content-Type` (`audio/mpeg`, `audio/wav`). - -Beispiele: - -```bash -# MP3 (Default), Stimme claribel -curl -s http://AI_HOST:8081/v1/audio/speech \ - -H "Authorization: Bearer $ROUTER_KEY" \ - -H 'Content-Type: application/json' \ - -d '{"input":"Hallo, dies ist ein Test.","voice":"claribel"}' -o out.mp3 - -# WAV, 1.5x Tempo -curl -s http://AI_HOST:8081/v1/audio/speech \ - -H "Authorization: Bearer $ROUTER_KEY" \ - -H 'Content-Type: application/json' \ - -d '{"input":"Guten Tag.","voice":"claribel","speed":1.5,"response_format":"wav"}' -o out.wav -``` - -**Lange Texte:** XTTS-v2 verarbeitet den Text intern in Sätze. -Längere Texte werden automatisch in Segmente geteilt; -jedes Segment wird einzeln synthetisiert und die Audios zusammengeführt. - -### Verhalten - -- **Kein GPU-Lock:** TTS läuft CPU-only und greift nicht in den - GPU-Hotswap (Bild) oder Profilwechsel (Qwen) ein. TTS-Requests können - parallel zu Chats laufen. -- **Serialisierte Synthese:** Der Worker synthetisiert nacheinander - (CPU-bound), parallele Requests werden intern gewartet. -- **`/status`** zeigt `tts.reachable`, `tts.ready`, `tts.voices`, - `tts.last_seconds`, `tts.last_voice`, `tts.last_error`. -- **Fehler:** Worker down → `503` (`tts_failed`); ungültige Parameter → - `400`. OpenAI-kompatibles Fehlerformat. - -### Benchmark (CPU-only, gemessen) - -| Textlänge | Audio | Synthese | RTF | -|---|---|---|---| -| ~6 s | 6.2 s | 8.2 s | 1.32× | -| ~15 s | 15.0 s | 19.8 s | 1.32× | - -RTF ~1.32 bedeutet: Synthese ist ~1.3× langsamer als Echtzeit. -RAM-Belegung des Workers: ~4.5 GB (inkl. torch, XTTS-v2-Modell). - -### Erforderliche Python-Pakete (im XTTS-Venv) - -- `torch` (CPU-only, `--index-url https://download.pytorch.org/whl/cpu`) -- `torchaudio` (CPU-only) -- `TTS` (0.22.0, Coqui XTTS) -- `transformers` (4.40.2, für `BeamSearchScorer`) -- `tokenizers` (0.19.1) -- `huggingface-hub` (0.36.2) -- `librosa` (Audio-Verarbeitung) -- `soundfile` (WAV-Export) - -Das Venv liegt unter `/opt/mike-ai/xtts/venv/` und wird von `install.sh` -automatisch angelegt (nur wenn noch nicht vorhanden). - -**Hinweis (Python 3.11):** Coqui TTS 0.22.0 unterstützt offiziell nur -Python `>=3.9.0, <3.12`. Daher wird Python 3.11.16 via `uv` verwendet. -Der Zielsystem-Python (3.13.5) wird nicht verwendet. - -**Hinweis (PyTorch 2.6+):** Coqui TTS nutzt `torch.load()` ohne -`weights_only=False`, was in PyTorch 2.6+ standardmäßig fehlschlägt. -Dies wird durch einen Patch in `TTS/utils/io.py` umgangen. - -## Spracherkennung (whisper.cpp, deutsch, CPU-only) - -Der Router stellt lokale deutsche Spracherkennung bereit. Die Transkription -läuft in einem **separaten, langlebigen Worker** (`mike-ai-whisper.service`), -der `whisper-cli` als Subprozess aufruft. Der Worker ist CPU-only und -blockiert weder Qwen/llama.cpp noch FLUX/GPU – er teilt sich nur den -Prozessor. - -- **Modell:** Whisper large-v3-turbo (ggml, ~1.6 GB, Vollpräzision) -- **Build:** whisper.cpp CPU-only (AVX2+FMA, 8 Threads) -- **Audio-Vorbereitung:** ffmpeg konvertiert WebM/Opus/M4A/AAC → 16 kHz mono WAV -- **Nativ unterstützt:** WAV, MP3, OGG, FLAC -- **Kein GPU-Lock:** STT läuft vollständig auf CPU, parallel zu Qwen (GPU) - und TTS (CPU) - -### Endpunkt `POST /v1/audio/transcriptions` - -OpenAI-kompatibel (Multipart-Form-Data). Unterstützt `file`, `model`, -`language`, `prompt`, `temperature`, `response_format`. - -| Parameter | Werte | Default | -|---|---|---| -| `file` | Audio-Datei (Pflicht: webm, wav, mp3, m4a, ogg, flac) | – | -| `model` | `whisper-1` (oder `whisper`) | `whisper-1` | -| `language` | `de`, `en`, … (optional) | Auto-Detektion | -| `prompt` | Kontext-Hinweis (optional) | – | -| `temperature` | 0.0–1.0 (optional) | 0.0 | -| `response_format` | `json` (Default), `verbose_json` | `json` | - -Die Antwort ist **JSON** mit `text` (und optional `language`, `duration` -bei `verbose_json`). - -Beispiele: - -```bash -# WebM/Opus (z.B. aus Open WebUI-Mikrofon) -curl -s http://AI_HOST:8081/v1/audio/transcriptions \ - -H "Authorization: Bearer $ROUTER_KEY" \ - -F "file=@aufnahme.webm" \ - -F "model=whisper-1" - -# WAV mit expliziter Sprache -curl -s http://AI_HOST:8081/v1/audio/transcriptions \ - -H "Authorization: Bearer $ROUTER_KEY" \ - -F "file=@aufnahme.wav" \ - -F "model=whisper-1" \ - -F "language=de" - -# Verbose-Format -curl -s http://AI_HOST:8081/v1/audio/transcriptions \ - -H "Authorization: Bearer $ROUTER_KEY" \ - -F "file=@aufnahme.wav" \ - -F "model=whisper-1" \ - -F "response_format=verbose_json" -``` - -### Discovery-Endpunkte - -- `GET /v1/audio/models` – listet verfügbare Audio-Modelle - (`whisper-1` für STT, `xtts-v2` für TTS) -- `GET /v1/audio/voices` – listet verfügbare TTS-Stimmen - (`claribel`) - -### Verhalten - -- **Kein GPU-Lock:** STT läuft CPU-only und greift nicht in den - GPU-Hotswap (Bild) oder Profilwechsel (Qwen) ein. STT-Requests können - parallel zu Chats, TTS und Bildgenerierung laufen. -- **Serialisierte Transkription:** Der Worker transkribiert nacheinander - (CPU-bound), parallele Requests werden intern gewartet. -- **`/status`** zeigt `stt.reachable`, `stt.ready`, `stt.model`, - `stt.threads`, `stt.language`, `stt.ffmpeg_exists`. -- **Fehler:** Worker down → `503` (`stt_failed`); ungültige Parameter → - `400`. OpenAI-kompatibles Fehlerformat. - -### Benchmark (CPU-only, gemessen) - -| Audio-Dauer | Transkription | RTF | -|---|---|---| -| 7.3 s | 8.9 s | 1.22× | -| 30 s | 16.8 s | 0.56× | -| 50 s | 18.5 s | 0.37× | - -RTF < 1.0 bedeutet: Transkription ist schneller als Echtzeit. -RAM-Belegung des Workers: ~1.7 GB (inkl. Modell). - -### Erforderliche Komponenten - -- `whisper.cpp` (CPU-only Build, `/opt/mike-ai/whisper.cpp/build-cpu/`) -- `ggml-large-v3-turbo.bin` (`/opt/mike-ai/models/whisper/`) -- `ffmpeg` (für WebM/Opus/M4A/AAC-Konvertierung) -- Python 3.13 (nur Standardbibliothek, kein Venv nötig) - -## Repository-Struktur - -``` -router/ai_profile_router.py # der Router (einzige Laufzeit-Datei) -router/image_worker.py # FLUX-Worker (eigener Prozess, JSON-Protokoll) -router/xtts_worker.py # XTTS-v2-TTS-Worker (eigener Prozess, HTTP-API) -router/stt_worker.py # Whisper-STT-Worker (eigener Prozess, HTTP-API) -deploy/mike-ai-profile-router.service # systemd-Unit (Router) -deploy/mike-ai-xtts.service # systemd-Unit (TTS-Worker) -deploy/mike-ai-whisper.service # systemd-Unit (STT-Worker) -deploy/install.sh # läuft auf dem Zielsystem (per SSH) -deploy/deploy.sh # läuft lokal: SCP + SSH -dev/ # lokale Tests (Mock-llama.cpp, Mock-Worker, Benchmarks) -``` - -Entwicklungsdateien (`dev/`) und Deployment-Dateien (`router/`, `deploy/`) -sind getrennt. Auf dem Zielsystem landet nur `router/` + `deploy/`. - -## Deployment - -Voraussetzung: administrativer SSH-Key für den Zielhost. Ziel und Key werden -explizit über `TARGET` und `SSH_KEY` übergeben. - -```bash -./deploy/deploy.sh -``` - -Das Skript: - -1. Überträgt `ai_profile_router.py`, `image_worker.py`, `tts_worker.py`, - `install.sh` und beide systemd-Units per SCP nach - `/tmp/ai-profile-router/` auf dem Zielsystem. -2. Führt `install.sh` per SSH aus, das: - - den alten Router (`mike-ai-local-llm-router.service` + - `/opt/mike-ai/local-llm-router`) **mit Backup** entfernt, - - den neuen Router + Worker nach `/opt/mike-ai/ai-profile-router/` - installiert, - - ein Python-Venv mit `torch`, `diffusers`, `transformers`, `accelerate` - anlegt (nur wenn noch nicht vorhanden), - - das FLUX-Modell nach `/opt/mike-ai/models/FLUX.2-klein-base-4B` lädt - (nur wenn noch nicht vorhanden, ~15 GB), - - `espeak-ng` installiert (nur wenn noch nicht vorhanden), - - das XTTS-Venv unter `/opt/mike-ai/xtts/venv/` anlegt (nur wenn - noch nicht vorhanden, Python 3.11 via uv + CPU-only torch + Coqui TTS), - - das XTTS-v2-Modell herunterlädt (nur wenn noch nicht vorhanden, ~1.9 GB), - - `mike-ai-profile-router.service` und `mike-ai-xtts.service` - aktivieren (Start beim Boot) und starten, - - `GET /status` verifiziert. - -Die Installation ist idempotent (Update = erneut ausführen). - -## Konfiguration (Umgebungsvariablen in der systemd-Unit) - -| Variable | Default | Bedeutung | -|---|---|---| -| `ROUTER_HOST` | `0.0.0.0` | Bind-Adresse | -| `ROUTER_PORT` | `8081` | Port | -| `ROUTER_AUTH_MODE` | `required` | Authentifizierung; `off` nur für lokale Tests | -| `ROUTER_API_KEY_FILE` | `/etc/mike-ai/router-api-key` | Schlüsseldatei (0600) | -| `ROUTER_STATE_FILE` | `/var/lib/mike-ai-profile-router/state.json` | atomarer Crash-/Recovery-Zustand | -| `ROUTER_PROFILES_FILE` | `/etc/mike-ai/router-profiles.json` | Profile, Kontext und erwarteter Alias | -| `ROUTER_MAX_CONCURRENT_REQUESTS` | `16` | harte Grenze paralleler Requests | -| `UPSTREAM_URL` | `http://127.0.0.1:8080` | llama.cpp | -| `PROFILE_SCRIPT` | `/usr/local/bin/llama-profile` | Profil-Skript | -| `PROFILE_DIR` | `/etc/systemd/system/mike-ai-llama-ui.service.d` | Ort der `override.conf` | -| `SWITCH_TIMEOUT` | `600` | Warten auf llama.cpp nach Wechsel (s) | -| `REQUEST_TIMEOUT` | `600` | Read-Timeout für Upstream-Requests (s) | -| `CONNECT_TIMEOUT` | `10` | Connect-Timeout Upstream (s) | -| `POLL_INTERVAL` | `2` | Polling-Intervall (s) | -| `LOG_LEVEL` | `INFO` | Logging-Level | -| `LLAMA_SERVICE` | `mike-ai-llama-ui.service` | llama.cpp-Service (für Bild-Hotswap) | -| `IMAGE_WORKER` | `/image_worker.py` | FLUX-Worker-Skript | -| `IMAGE_PYTHON` | `sys.executable` | Python für den Worker (venv mit torch) | -| `IMAGE_DIR` | `/opt/mike-ai/ai-profile-router/images` | Bild-Speicherort | -| `IMAGE_WORKER_LOG` | `/opt/mike-ai/ai-profile-router/worker.log` | Worker-Log | -| `IMAGE_GEN_TIMEOUT` | `600` | Timeout pro Bild (s) | -| `IMAGE_VRAM_FREE_TIMEOUT` | `120` | Warten auf VRAM-Freiheit (s) | -| `CHAT_WAIT_TIMEOUT` | `300` | Chat wartet auf Qwen (s) | -| `TTS_WORKER_URL` | `http://127.0.0.1:8085` | TTS-Worker (Router-Seite) | -| `TTS_TIMEOUT` | `300` | Timeout pro Synthese (s) | -| `TTS_CONNECT_TIMEOUT` | `5` | Connect-Timeout TTS-Worker (s) | -| `CHAT_IMAGE_MAX_BYTES` | `20971520` | maximales dekodiertes Chatbild (20 MiB) | -| `CHAT_IMAGE_ALLOW_REMOTE_URLS` | `false` | externe Bild-URLs; standardmäßig SSRF-sicher aus | - -TTS-Worker (`mike-ai-xtts.service`): - -| Variable | Default | Bedeutung | -|---|---|---| -| `XTTS_HOST` | `127.0.0.1` | Bind-Adresse (nur lokal, Router proxyt) | -| `XTTS_PORT` | `8085` | Port | -| `XTTS_MODEL_ID` | `tts_models/multilingual/multi-dataset/xtts_v2` | Modell-ID | -| `XTTS_DEFAULT_VOICE` | `claribel` | Default-Stimme | -| `LOG_LEVEL` | `INFO` | Logging-Level | - -## Lokale Tests - -```bash -./dev/test_local.sh -``` - -Startet einen Mock-llama.cpp, einen Mock-Bild-Worker, einen Mock-TTS-Worker -und den Router mit einem Fake-Profil-Skript und prüft: `/v1/models`, -`/status`, Forwarding, Streaming, Tool Calls, Profilwechsel -(fast→medium→fast), virtuelles Modell triggert Wechsel, ungültige Profile, -Upstream down → 502, Recovery, **Bildgenerierung** (`standard`→30 Steps, -`high`→50 Steps, Validierung, Image-Fehler→Qwen wiederhergestellt, -Fast/Medium/Long→Image→gleiches Profil, `/status` während Bild-Job, -paralleler Chat während Bild-Job wartet statt 502) und **Sprachausgabe** -(`/status` mit tts-Section, `POST /v1/audio/speech` wav/mp3, Validierung, -Worker-Fehler→503, Worker down→503, Worker-Neustart→Recovery). - -Aktuell: **63 Integrationsassertions** plus Chunked-, Multipart- und -Security/Recovery-Unit-Tests. - -## Betrieb - -```bash -systemctl status mike-ai-profile-router -systemctl status mike-ai-xtts -journalctl -u mike-ai-profile-router -f -journalctl -u mike-ai-xtts -f -ROUTER_KEY='aus lokalem Secret-Store' -curl -s http://AI_HOST:8081/status -H "Authorization: Bearer $ROUTER_KEY" | python3 -m json.tool -curl -s -X POST http://AI_HOST:8081/medium -H "Authorization: Bearer $ROUTER_KEY" -# TTS-Test -curl -s http://AI_HOST:8081/v1/audio/speech \ - -H "Authorization: Bearer $ROUTER_KEY" \ - -H 'Content-Type: application/json' \ - -d '{"input":"Hallo","voice":"claribel"}' -o test.mp3 -``` - -## Sicherheit - -- Keine Shell-Aufrufe: Profil-Skript wird mit `subprocess.run([script, profil])` - aufgerufen, `profil` ist Whitelist-geprüft (`fast|medium|long`). -- Keine Secrets/Tokens im Code oder in der Unit. -- API-Key-Pflicht, konstante Schlüsselprüfung und keine Weitergabe des - Router-Schlüssels an llama.cpp. -- Remote-Bilder standardmäßig gesperrt; lokale Data-URLs sind typ- und - größenvalidiert. -- systemd-Hardening: `NoNewPrivileges`, `PrivateTmp`, `ProtectHome`, - Kernel-/Control-Group-Schutz und restriktive Dateirechte. -- Die bestehende llama.cpp-/Profil-Konfiguration wird nicht verändert; der - Router nutzt nur das vorhandene `llama-profile`-Skript und liest die - `override.conf`. +## Sicherheitsregeln + +- `config/install.env` ist lokal, Modus 0600, und wird ignoriert. +- API-, Controller-, WebUI- und WireGuard-Schlüssel entstehen erst am Host. +- Nur der kleine Profile Controller sieht den Docker-Socket. +- llama.cpp veröffentlicht weder Port noch WebUI. +- Ein Blackhole-Fallback verhindert Traffic-Leaks bei WireGuard-Ausfall. +- Das Uni-Netz und das Heimnetz dürfen diesen Host nicht als Transit benutzen. + +Die Profilwerte sind Ausgangswerte. Nach dem Neuaufbau werden RTX 5080 und +RTX 3060 mit der bestehenden Standard-Testserie neu vermessen, bevor die zweite +GPU in ein Produktionsprofil einfließt. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..88d6e90 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,399 @@ +name: mike-ai + +x-llama-common: &llama-common + image: ${LLAMA_IMAGE:-mike-ai/llama.cpp:local} + restart: "no" + profiles: [inference] + gpus: all + ipc: host + read_only: true + tmpfs: + - /tmp:size=1g,mode=1777 + volumes: + - "${MODEL_DIR:-/srv/mike-ai/models}:/models:ro" + - ./platform/docker/mcp-standard.json:/etc/mike-ai/mcp-standard.json:ro + environment: + SEARXNG_URL: http://searxng:8080 + NVIDIA_DRIVER_CAPABILITIES: compute,utility + dns: ["${AI_DNS:-1.1.1.1}"] + networks: + inference: + aliases: [llama-upstream] + search: {} + security_opt: ["no-new-privileges:true"] + cap_drop: [ALL] + healthcheck: + test: [CMD, curl, -fsS, "http://127.0.0.1:8080/health"] + interval: 10s + timeout: 5s + retries: 60 + start_period: 30s + +services: + llama-fast: + <<: *llama-common + container_name: mike-ai-llama-fast + labels: + com.mike-ai.llama-profile: fast + environment: + SEARXNG_URL: http://searxng:8080 + NVIDIA_VISIBLE_DEVICES: ${FAST_GPU_DEVICES:-0} + NVIDIA_DRIVER_CAPABILITIES: compute,utility + command: + - --model + - "/models/${FAST_MODEL_FILE:?FAST_MODEL_FILE is required}" + - --mmproj + - "/models/${VISION_PROJECTOR_FILE:?VISION_PROJECTOR_FILE is required}" + - --no-mmproj-offload + - --alias + - qwen-fast + - --ctx-size + - "${FAST_CONTEXT:-76800}" + - --flash-attn + - "on" + - --cache-type-k + - q4_0 + - --cache-type-v + - q4_0 + - --threads + - "${LLAMA_THREADS:-6}" + - --threads-batch + - "${LLAMA_THREADS_BATCH:-6}" + - --batch-size + - "64" + - --ubatch-size + - "32" + - --parallel + - "1" + - --jinja + - --reasoning + - auto + - --host + - 0.0.0.0 + - --port + - "8080" + - --metrics + - --fit + - "off" + - --n-gpu-layers + - all + - --no-mmap + - --no-ui + - --temperature + - "0.2" + - --top-p + - "0.8" + - --top-k + - "20" + - --mcp-servers-config + - /etc/mike-ai/mcp-standard.json + - --device + - CUDA0 + - --split-mode + - none + - --spec-type + - draft-mtp + - --model-draft + - "/models/${MTP_MODEL_FILE:?MTP_MODEL_FILE is required}" + - --spec-draft-n-max + - "2" + - --spec-draft-type-k + - f16 + - --spec-draft-type-v + - f16 + + llama-medium: + <<: *llama-common + container_name: mike-ai-llama-medium + labels: + com.mike-ai.llama-profile: medium + environment: + SEARXNG_URL: http://searxng:8080 + NVIDIA_VISIBLE_DEVICES: ${MEDIUM_GPU_DEVICES:-0} + NVIDIA_DRIVER_CAPABILITIES: compute,utility + command: + - --model + - "/models/${MEDIUM_MODEL_FILE:?MEDIUM_MODEL_FILE is required}" + - --mmproj + - "/models/${VISION_PROJECTOR_FILE:?VISION_PROJECTOR_FILE is required}" + - --no-mmproj-offload + - --alias + - qwen-medium + - --ctx-size + - "${MEDIUM_CONTEXT:-94208}" + - --flash-attn + - "on" + - --cache-type-k + - q4_0 + - --cache-type-v + - q4_0 + - --threads + - "${LLAMA_THREADS:-6}" + - --threads-batch + - "${LLAMA_THREADS_BATCH:-6}" + - --batch-size + - "64" + - --ubatch-size + - "32" + - --parallel + - "1" + - --jinja + - --reasoning + - auto + - --host + - 0.0.0.0 + - --port + - "8080" + - --metrics + - --fit + - "off" + - --n-gpu-layers + - all + - --no-mmap + - --no-ui + - --temperature + - "0.2" + - --top-p + - "0.8" + - --top-k + - "20" + - --mcp-servers-config + - /etc/mike-ai/mcp-standard.json + - --device + - CUDA0 + - --split-mode + - none + + llama-long: + <<: *llama-common + container_name: mike-ai-llama-long + labels: + com.mike-ai.llama-profile: long + environment: + SEARXNG_URL: http://searxng:8080 + NVIDIA_VISIBLE_DEVICES: ${LONG_GPU_DEVICES:-0} + NVIDIA_DRIVER_CAPABILITIES: compute,utility + command: + - --model + - "/models/${LONG_MODEL_FILE:?LONG_MODEL_FILE is required}" + - --mmproj + - "/models/${VISION_PROJECTOR_FILE:?VISION_PROJECTOR_FILE is required}" + - --no-mmproj-offload + - --alias + - qwen-long + - --ctx-size + - "${LONG_CONTEXT:-131072}" + - --flash-attn + - "on" + - --cache-type-k + - q4_0 + - --cache-type-v + - q4_0 + - --threads + - "${LLAMA_THREADS:-6}" + - --threads-batch + - "${LLAMA_THREADS_BATCH:-6}" + - --batch-size + - "64" + - --ubatch-size + - "32" + - --parallel + - "1" + - --jinja + - --reasoning + - auto + - --host + - 0.0.0.0 + - --port + - "8080" + - --metrics + - --fit + - "off" + - --n-gpu-layers + - all + - --override-tensor + - blk.([0-9]|1[0-1]).ffn_.*=CPU + - --no-mmap + - --no-ui + - --temperature + - "0.2" + - --top-p + - "0.8" + - --top-k + - "20" + - --mcp-servers-config + - /etc/mike-ai/mcp-standard.json + - --device + - CUDA0 + - --split-mode + - none + - --spec-type + - draft-mtp + - --model-draft + - "/models/${MTP_MODEL_FILE:?MTP_MODEL_FILE is required}" + - --spec-draft-n-max + - "2" + - --spec-draft-type-k + - q4_0 + - --spec-draft-type-v + - q4_0 + + llama-experimental: + <<: *llama-common + container_name: mike-ai-llama-experimental + labels: + com.mike-ai.llama-profile: experimental + environment: + SEARXNG_URL: http://searxng:8080 + NVIDIA_VISIBLE_DEVICES: ${EXPERIMENTAL_GPU_DEVICES:-0} + NVIDIA_DRIVER_CAPABILITIES: compute,utility + command: + - --model + - "/models/${EXPERIMENTAL_MODEL_FILE:?EXPERIMENTAL_MODEL_FILE is required}" + - --alias + - qwen-experimental + - --ctx-size + - "${EXPERIMENTAL_CONTEXT:-76800}" + - --flash-attn + - "on" + - --cache-type-k + - q4_0 + - --cache-type-v + - q4_0 + - --parallel + - "1" + - --jinja + - --reasoning + - auto + - --host + - 0.0.0.0 + - --port + - "8080" + - --metrics + - --fit + - "off" + - --n-gpu-layers + - all + - --no-mmap + - --no-ui + - --mcp-servers-config + - /etc/mike-ai/mcp-standard.json + - --device + - CUDA0 + - --split-mode + - none + + profile-controller: + build: ./platform/docker/profile-controller + image: mike-ai/profile-controller:local + container_name: mike-ai-profile-controller + restart: unless-stopped + read_only: true + tmpfs: ["/tmp:size=16m"] + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + CONTROLLER_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}" + ALLOWED_PROFILES: fast,medium,long,experimental + networks: [control] + security_opt: ["no-new-privileges:true"] + healthcheck: + test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8090/health', timeout=2)"] + interval: 10s + timeout: 3s + retries: 10 + + router: + build: + context: . + dockerfile: platform/docker/router/Dockerfile + image: mike-ai/profile-router:local + container_name: mike-ai-router + restart: unless-stopped + read_only: true + tmpfs: ["/tmp:size=256m"] + volumes: + - ./router/router_profiles.json:/etc/mike-ai/router-profiles.json:ro + - router-state:/var/lib/mike-ai-profile-router + - router-images:/data/images + environment: + ROUTER_HOST: 0.0.0.0 + ROUTER_PORT: "8081" + ROUTER_AUTH_MODE: required + ROUTER_API_KEY: "${ROUTER_API_KEY:?ROUTER_API_KEY is required}" + ROUTER_PROFILES_FILE: /etc/mike-ai/router-profiles.json + ROUTER_STATE_FILE: /var/lib/mike-ai-profile-router/state.json + ROUTER_MAX_CONCURRENT_REQUESTS: "16" + UPSTREAM_URL: http://llama-upstream:8080 + PROFILE_CONTROL_URL: http://profile-controller:8090 + PROFILE_CONTROL_TOKEN: "${CONTROLLER_TOKEN:?CONTROLLER_TOKEN is required}" + SWITCH_TIMEOUT: "600" + REQUEST_TIMEOUT: "600" + IMAGE_DIR: /data/images + CHAT_IMAGE_ALLOW_REMOTE_URLS: "false" + ENABLE_IMAGE_GENERATION: "false" + ENABLE_TTS: "false" + ENABLE_STT: "false" + ports: + - "${AI_BIND_ADDRESS:-127.0.0.1}:8081:8081" + networks: [frontend, control, inference] + security_opt: ["no-new-privileges:true"] + cap_drop: [ALL] + depends_on: + profile-controller: + condition: service_healthy + + open-webui: + image: ${OPENWEBUI_IMAGE:-ghcr.io/open-webui/open-webui:v0.9.5} + container_name: mike-ai-open-webui + restart: unless-stopped + volumes: + - open-webui-data:/app/backend/data + environment: + WEBUI_SECRET_KEY: "${WEBUI_SECRET_KEY:?WEBUI_SECRET_KEY is required}" + OLLAMA_BASE_URL: "" + OPENAI_API_BASE_URLS: http://router:8081/v1 + OPENAI_API_KEYS: "${ROUTER_API_KEY:?ROUTER_API_KEY is required}" + ENABLE_SIGNUP: ${OPENWEBUI_ENABLE_SIGNUP:-false} + DO_NOT_TRACK: "true" + SCARF_NO_ANALYTICS: "true" + ports: + - "${AI_BIND_ADDRESS:-127.0.0.1}:8080:8080" + dns: ["${AI_DNS:-1.1.1.1}"] + networks: [frontend] + depends_on: [router] + security_opt: ["no-new-privileges:true"] + + searxng: + image: searxng/searxng@sha256:e45d5894bfaa0bf8773b9f283795ae57f1c15ddb29c8cecb70b3665b0ce9ec60 + container_name: mike-ai-searxng + restart: unless-stopped + volumes: + - ./platform/web-search/searxng-settings.yml:/etc/searxng/settings.yml:ro + networks: [search] + dns: ["${AI_DNS:-1.1.1.1}"] + security_opt: ["no-new-privileges:true"] + cap_drop: [ALL] + +networks: + frontend: + internal: false + ipam: + config: [{subnet: 172.30.10.0/24}] + control: + internal: true + ipam: + config: [{subnet: 172.30.20.0/24}] + inference: + internal: true + ipam: + config: [{subnet: 172.30.30.0/24}] + search: + internal: false + ipam: + config: [{subnet: 172.30.40.0/24}] + +volumes: + open-webui-data: + router-state: + router-images: diff --git a/config/install.env.example b/config/install.env.example new file mode 100644 index 0000000..f5b7bfb --- /dev/null +++ b/config/install.env.example @@ -0,0 +1,53 @@ +# Copy to /root/mike-ai-install.env, edit, chmod 600, then run: +# sudo ./install.sh --config /root/mike-ai-install.env + +AI_HOSTNAME=ki-host +ADMIN_USER=mike +AI_BIND_ADDRESS=10.77.0.2 +MODEL_DIR=/srv/mike-ai/models + +# Installing a new NVIDIA driver can require one reboot. In that case this +# installer exits with code 20; rerun the same command after reboot. +INSTALL_NVIDIA_DRIVER=true +TEXT_GPU_DEVICES=0 +SECONDARY_GPU_DEVICES=1 + +# WireGuard client. The home peer must route 10.77.0.2/32 back to this host. +WIREGUARD_ENABLE=true +WG_INTERFACE=wg0 +WG_ADDRESS=10.77.0.2/32 +WG_HOME_SUBNET=192.168.1.0/24 +WG_PEER_PUBLIC_KEY=REPLACE_WITH_HOME_WIREGUARD_PUBLIC_KEY +WG_PEER_ENDPOINT=home.example.net:51820 +WG_PEER_PRESHARED_KEY_FILE= +WG_DNS=192.168.1.1 +WG_ROUTE_AI_INTERNET=true + +# Exact model artifacts. Never put access tokens in these URLs. +FAST_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf +FAST_MODEL_URL=https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/Qwen3.8-27B-UD-IQ4_XS.gguf +FAST_MODEL_SHA256=40fac4050e940397dbf13087afd50f4734a11805bf9d65ef8ddd7483470e6199 +MEDIUM_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf +MEDIUM_MODEL_URL=https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/Qwen3.8-27B-UD-IQ4_XS.gguf +MEDIUM_MODEL_SHA256=40fac4050e940397dbf13087afd50f4734a11805bf9d65ef8ddd7483470e6199 +LONG_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf +LONG_MODEL_URL=https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/Qwen3.8-27B-UD-IQ4_XS.gguf +LONG_MODEL_SHA256=40fac4050e940397dbf13087afd50f4734a11805bf9d65ef8ddd7483470e6199 +EXPERIMENTAL_MODEL_FILE=qwen/Qwen3.8-27B-UD-IQ4_XS.gguf +EXPERIMENTAL_MODEL_URL=https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/Qwen3.8-27B-UD-IQ4_XS.gguf +EXPERIMENTAL_MODEL_SHA256=40fac4050e940397dbf13087afd50f4734a11805bf9d65ef8ddd7483470e6199 +VISION_PROJECTOR_FILE=qwen/mmproj-BF16.gguf +VISION_PROJECTOR_URL=https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/mmproj-BF16.gguf +VISION_PROJECTOR_SHA256=83ee4f4f205fa514161778c41df1ea14144faa0f713510893b63c2395f5c2d53 +MTP_MODEL_FILE=qwen/mtp-Qwen3.8-27B-Q4_0.gguf +MTP_MODEL_URL=https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/MTP/mtp-Qwen3.8-27B-Q4_0.gguf +MTP_MODEL_SHA256=50d9ce5a6da381bbcfb31061cf73df94a90e6faf8efeddee379a9cb8f1501c6e + +FAST_CONTEXT=76800 +MEDIUM_CONTEXT=94208 +LONG_CONTEXT=131072 +EXPERIMENTAL_CONTEXT=76800 +LLAMA_THREADS=6 +LLAMA_THREADS_BATCH=6 +OPENWEBUI_IMAGE=ghcr.io/open-webui/open-webui:v0.9.5 +OPENWEBUI_ENABLE_SIGNUP=false diff --git a/dev/test_profile_controller.py b/dev/test_profile_controller.py new file mode 100644 index 0000000..4915ba0 --- /dev/null +++ b/dev/test_profile_controller.py @@ -0,0 +1,58 @@ +import importlib.util +import os +import pathlib +import unittest +from unittest.mock import patch + + +os.environ.setdefault("CONTROLLER_TOKEN", "x" * 48) +PATH = pathlib.Path(__file__).parents[1] / "platform/docker/profile-controller/profile_controller.py" +SPEC = importlib.util.spec_from_file_location("profile_controller", PATH) +controller = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +SPEC.loader.exec_module(controller) + + +def item(profile, state="exited"): + return { + "Id": f"id-{profile}", + "State": state, + "Labels": {controller.LABEL_KEY: profile}, + } + + +class ProfileControllerTests(unittest.TestCase): + def test_rejects_unknown_profile_before_docker_call(self): + with patch.object(controller, "docker_request") as request: + with self.assertRaises(ValueError): + controller.activate("shell") + request.assert_not_called() + + def test_switch_stops_running_profile_then_starts_target(self): + profiles = {name: item(name) for name in controller.ALLOWED} + profiles["fast"] = item("fast", "running") + calls = [] + + def request(method, path): + calls.append((method, path)) + return 204, b"" + + with patch.object(controller, "containers", return_value=profiles), \ + patch.object(controller, "docker_request", side_effect=request): + result = controller.activate("medium") + + self.assertEqual(result, {"active_profile": "medium", "changed": True}) + self.assertEqual(calls, [ + ("POST", "/containers/id-fast/stop?t=120"), + ("POST", "/containers/id-medium/start"), + ]) + + def test_fails_if_profile_container_is_missing(self): + profiles = {name: item(name) for name in controller.ALLOWED[:-1]} + with patch.object(controller, "containers", return_value=profiles): + with self.assertRaisesRegex(RuntimeError, "missing"): + controller.activate("fast") + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8dc5307..6054030 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,107 +1,82 @@ -# Architektur +# Zielarchitektur des neuen KI-Hosts -## Ziel +## Grundsatz -Die Plattform stellt eine private, lokal betriebene OpenAI-kompatible API -bereit. Jede Komponente hat genau eine Aufgabe und kann unabhängig ersetzt -werden. Ein Neuaufbau darf keine Dateien vom alten Host voraussetzen, die nicht -in diesem Repository oder im Modellmanifest beschrieben sind. +Der Host läuft auf Debian 13. Das Betriebssystem darf im Universitätsnetz +administrierbar bleiben; die KI-Plattform wird ausschließlich an die +WireGuard-Adresse gebunden. KI-Container erreichen Heimnetz und Internet über +den Heim-WireGuard-Peer. Bei Tunnelausfall verhindert eine Blackhole-Route den +unbeabsichtigten Rückfall auf das Universitätsgateway. -## Komponenten +```text +Heimnetz / VPN-Clients + | + WireGuard + | + 10.77.0.2:8080 Open WebUI + 10.77.0.2:8081 Profile Router API + | + Docker-intern + +-- Profile Controller -- Docker Socket (feste Allowlist) + +-- llama-fast --\ + +-- llama-medium > exakt einer aktiv + +-- llama-long --/ + +-- llama-experimental + +-- SearXNG + Web-MCP +``` -| Komponente | Port | Ausführung | Aufgabe | -|---|---:|---|---| -| AI Profile Router | 8081 | systemd, gehärtet | zentrale Client-API und Orchestrierung | -| llama.cpp | 8080 | systemd | Textmodell, Tool Calling und MCP | -| Whisper | 8084, nur localhost | systemd | Speech-to-Text | -| XTTS | 8085, nur localhost | systemd | Text-to-Speech | -| TinySearch | 8000, nur localhost | Docker | kompakte Websuche | -| SearXNG | intern | Docker | Suchmaschinen-Metasuche | -| LLama-GUI | 5240, optional | systemd | manuelle Administration | -| Glances | lokal, optional | systemd | Systemmetriken | +## Container und Vertrauensgrenzen -## Request-Fluss +| Komponente | Außen erreichbar | Aufgabe | +|---|---|---| +| Open WebUI | nur WireGuard, Port 8080 | Chat-Oberfläche | +| Profile Router | nur WireGuard, Port 8081 | OpenAI-API und Profilwahl | +| Profile Controller | nein | startet ausschließlich vier bekannte Profile | +| llama.cpp Profile | nein | Inferenz, Tool Calling, integrierte Vision | +| SearXNG | nein | Websuche für den lokalen Web-MCP | -1. Ein Client verwendet ausschließlich Port 8081. -2. Der Router veröffentlicht `qwen-fast`, `qwen-medium` und `qwen-long`. -3. Passt das aktive Profil nicht zum virtuellen Modell, wird llama.cpp kontrolliert - mit dem passenden Profil neu gestartet. -4. Der Router wartet auf Modellname und erwartete Kontextgröße. -5. Erst dann wird die Anfrage an Port 8080 weitergeleitet. - -Profilwahl und Weiterleitung bilden dabei eine atomare Modell-Lease. Ein -zweiter Request kann das Profil nicht mehr zwischen Auswahl und Inferenz -wechseln. Laufende Requests werden vor einem GPU-Hotswap vollständig beendet; -bei Überschreiten des Drain-Timeouts wird der Wechsel abgebrochen, nicht der -Chat. +Nur der Profile Controller erhält den Docker-Socket. Der Router erhält weder +Socket noch Shell-Zugriff und kann dem Controller nur `fast`, `medium`, `long` +oder `experimental` übergeben. Die llama-Container laufen ohne UI, +Capabilities und Schreibzugriff auf die Modelldateien. ## Profilprinzip -Die Profile sind vollständige systemd-Overrides. Ein Profilwechsel kopiert die -gewählte Datei atomar auf `override.conf`, lädt systemd neu und startet genau -einen llama.cpp-Dienst neu. Es gibt niemals mehrere Textmodelle gleichzeitig. -Das unabhängige Register `/etc/mike-ai/router-profiles.json` definiert Kontext -und erwarteten Modellalias. Readiness gilt nur, wenn beides exakt passt; eine -abweichende oder fehlende Registry verhindert den Start. +Alle Profile verwenden dasselbe selbst gebaute llama.cpp-Image. Separate, +normalerweise gestoppte Containerdefinitionen halten Parameter wie Kontext, +MTP und CPU-Offload reproduzierbar. Ein Wechsel stoppt das alte Profil und +startet genau einen bereits angelegten Container. Dadurch lassen sich Profile +einzeln verändern oder duplizieren, ohne vier Modelle parallel im VRAM zu +halten. -## GPU-Hotswap +| Profil | Ausgangswert | Zweck | +|---|---:|---| +| fast | 76.800 Kontext, MTP | mindestens ungefähr 80 Token/s anstreben | +| medium | 94.208 Kontext | mehr Kontext ohne CPU-FFN-Offload | +| long | 131.072 Kontext | maximale Nutzbarkeit, CPU-Offload erlaubt | +| experimental | 76.800 Kontext | isolierte Tests ohne Produktion zu ändern | -Vision und Bildgenerierung teilen sich die RTX mit dem Textmodell. Der Router: +Diese Werte sind reproduzierbare Startwerte, keine Garantie. Nach Einbau der +RTX 3060 werden sie auf dem Zielhost erneut gemessen. Die zweite Karte wird +nicht automatisch in die Produktionsprofile aufgenommen. -1. sperrt die GPU-Orchestrierung, -2. merkt sich das aktive Textprofil, -3. stoppt llama.cpp, -4. startet vorübergehend Vision oder FLUX, -5. beendet den Hilfsprozess vollständig, -6. stellt das ursprüngliche Textprofil wieder her, -7. prüft Modell und Kontext vor der Freigabe. +## Netzwerk -Der zuletzt stabile Zustand und temporäre Worker-PIDs werden atomar unter -`/var/lib/mike-ai-profile-router/state.json` festgehalten. Beim Routerstart -werden ausschließlich dort erfasste Prozesse nach zusätzlicher -Kommandozeilenprüfung beendet und das letzte Profil wiederhergestellt. +- Docker-Netze liegen ausschließlich unter `172.30.0.0/16`. +- Open WebUI und Router binden an `AI_BIND_ADDRESS`, die WireGuard-IP. +- Quellrouting schickt KI-Container in Tabelle 51820 über WireGuard. +- Eine Blackhole-Default-Route bleibt als Fail-Closed-Fallback bestehen. +- Firewallregeln gestatten aus dem VPN nur die beiden veröffentlichten Ports. +- Der Host routet weder Universitätsverkehr ins Heimnetz noch Heimverkehr ins + Universitätsnetz. +- Das Heimnetz muss die Rückroute zur WireGuard-Adresse kennen. Soll auch der + Internetzugang der KI über zuhause laufen, braucht der Heim-Peer zusätzlich + IP-Forwarding und NAT ins Heim-WAN. -## Vertrauensgrenzen +## Nicht automatisch installiert -- Port 8081 verlangt einen eigenen API-Key; nur `/health` und `/ready` sind - absichtlich anonym und enthalten keine privaten Daten. -- Der Client-Key wird vor dem lokalen Upstream entfernt. -- Bild-Uploads sind begrenzt. Remote-Bild-URLs sind standardmäßig aus, damit - der Vision-Pfad nicht als Zugriff auf Intranet oder Metadatenendpunkte dient. -- Maximal 16 Requests werden gleichzeitig bearbeitet; weitere erhalten 429. -- Der Dienst benötigt derzeit wegen systemd-Profilwechsel und GPU-Hotswap noch - Root-Rechte. Die systemd-Sandbox begrenzt diese, ersetzt aber keine künftige - Aufteilung in unprivilegierten Proxy und eng begrenzten Root-Helper. - -## Verzeichnislayout auf dem Zielhost - -```text -/opt/mike-ai/ - ai-profile-router/ Routercode und eigenes Venv - llama.cpp/ exakt ein produktiver Build - models/ Modelle nach Manifest - whisper.cpp/ Speech-to-Text Runtime - xtts/ XTTS Runtime und Cache - web-search/ Docker Compose für TinySearch/SearXNG - -/etc/mike-ai/ - mcp-servers.json lokale, geheime produktive Konfiguration - *.env Credentials, niemals im Git - -/etc/systemd/system/ - mike-ai-*.service - mike-ai-llama-ui.service.d/ - override.conf - profile-fast.conf.disabled - profile-medium.conf.disabled - profile-long.conf.disabled -``` - -## Nicht Teil der Zielarchitektur - -- parallele llama.cpp-Builds -- allgemeiner Shell-MCP im Standardprofil -- doppelte Unraid-MCPs -- RX-spezifische Dienste ohne eingebaute RX -- automatisch startende Benchmark-Dienste -- Modellkopien außerhalb des dokumentierten Modellverzeichnisses +Bildgenerierung, Whisper, TTS sowie Home-Assistant-, ARR- und Unraid-MCPs sind +Erweiterungen. Sie benötigen eigene Modelle, Rechte oder Secrets und bleiben +im sauberen Basissystem deaktiviert. Multimodale Bildanalyse erfolgt direkt +über Qwen plus Projektor. Nicht installierte Worker-Endpunkte antworten klar + mit `feature_disabled`, statt alte systemd-Pfade aufzurufen. diff --git a/docs/CURRENT_REFERENCE.md b/docs/CURRENT_REFERENCE.md index 0a5ac4c..70f3245 100644 --- a/docs/CURRENT_REFERENCE.md +++ b/docs/CURRENT_REFERENCE.md @@ -36,6 +36,10 @@ Zielplattform. ### Aktives Fast-Profil - Qwen3.8-27B IQ4-MIX +- Dateigröße: 14.111.614.400 Bytes +- SHA256: `54879ae8738d5938f46cb3b8cbf16bf42b8c85b7d68d7c73f062b612ec183e36` +- Öffentliche Herkunft ist noch nicht ausreichend dokumentiert; für eine + bitgenaue Migration muss die geprüfte Datei vom Referenzhost gesichert werden. - Kontext 76.800 - vollständig auf CUDA0 - Flash Attention diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index fddca7e..1fbafa6 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -1,97 +1,84 @@ -# Saubere Installation +# Installation auf einem frischen Debian-Host -Diese Anleitung beschreibt den Neuaufbau. Sie löscht oder migriert keine Daten -automatisch. +Der automatisierte Weg ist `install.sh`. Das Skript ist für **Debian 12/13 +amd64** gedacht, installiert Docker CE, NVIDIA-Treiber/Container-Toolkit, +WireGuard, baut llama.cpp reproduzierbar, lädt Modelle mit SHA256-Prüfung und +startet den Stack. -## 1. Voraussetzungen +## Vorher klären -- Debian 13 oder kompatibles Linux -- NVIDIA-Treiber und funktionierendes `nvidia-smi` -- Build-Werkzeuge, CMake, Git und CUDA Toolkit -- Python 3.13 für Router/FLUX und Python 3.11 für XTTS -- Docker plus Compose für Websuche -- ausreichend freier Speicher; mindestens 15 Prozent auf `/` +1. Die Universität muss den ausgehenden WireGuard-Tunnel erlauben. +2. Heimnetz, Universitätsnetz und Docker-Netz dürfen sich nicht überschneiden. +3. Der WireGuard-Heim-Peer braucht eine feste öffentliche Adresse oder DNS. +4. Für KI-Internetzugang über zuhause: Forwarding und NAT am Heim-Peer. +5. Das private Repository muss auf dem neuen Host lesbar sein. -## 2. Benutzer und Verzeichnisse +## Debian installieren -Für produktive Dienste sollen eigene Systembenutzer verwendet werden. Der -Router benötigt kontrollierte Berechtigung zum Neustart des llama.cpp-Dienstes; -er sollte nicht dauerhaft als root laufen. +- Debian 13 minimal, amd64, OpenSSH-Server, kein Desktop erforderlich. +- Einen normalen Administrationsbenutzer mit sudo anlegen. +- Optional bei physischem Fremdzugriff: LUKS-Verschlüsselung. +- BIOS: Above 4G Decoding aktiv; beide GPUs sichtbar machen. -```text -/opt/mike-ai/models -/opt/mike-ai/ai-profile-router -/etc/mike-ai +## Konfiguration + +```bash +git clone AI-Profile-Router +cd AI-Profile-Router +cp config/install.env.example config/install.env +chmod 600 config/install.env +editor config/install.env ``` -Geheimnisse werden mit Modus `0600` unter `/etc/mike-ai` abgelegt. +Mindestens `ADMIN_USER`, `AI_BIND_ADDRESS`, `WG_ADDRESS`, +`WG_PEER_PUBLIC_KEY`, `WG_ENDPOINT` und `WG_HOME_SUBNET` anpassen. Private +WireGuard-, Router- und WebUI-Schlüssel werden lokal erzeugt und nur unter +`/etc/mike-ai` gespeichert. -## 3. llama.cpp bauen +## Installation starten -`platform/llama/build-llama-cpp.sh` checkt exakt den in -`platform/llama/LLAMA_CPP_COMMIT` hinterlegten Commit aus. Vor einem Upgrade: - -1. neuen Commit in einem separaten Build testen, -2. Standardbenchmark ausführen, -3. MCP-Grammatik und Tool Calls prüfen, -4. Commitdatei erst danach aktualisieren. - -## 4. Modelle bereitstellen - -Modelldateien werden nicht in Git gespeichert. Die erwarteten Rollen und -Zielpfade stehen in `platform/models/manifest.example.yaml`. Für die lokale -Installation wird daraus eine nicht eingecheckte `manifest.local.yaml` mit -SHA256-Prüfsummen erstellt. - -## 5. llama.cpp-Dienst und Profile - -Die Dateien aus `platform/systemd` und `platform/profiles` installieren. Danach: - -```text -llama-profile fast +```bash +sudo ./install.sh --config config/install.env ``` -Der Befehl muss Port 8080 erst freigeben, wenn Modell und Kontext korrekt sind. +Wenn erstmals ein NVIDIA-Treiber installiert wurde, endet das Skript bewusst +mit Code 20. Dann neu starten und denselben Befehl erneut ausführen. Das Skript +ist auf Wiederholung ausgelegt und löscht keine vorhandenen Modelldateien. -## 6. MCP-Konfiguration +Der Installer zeigt nur den öffentlichen WireGuard-Schlüssel. Diesen am +Heim-Peer eintragen: -`platform/mcp/mcp-servers.example.json` nach `/etc/mike-ai/mcp-servers.json` -kopieren und nur benötigte Server aktivieren. Zugangsdaten werden ausschließlich -über lokale Environment-Dateien oder einen Secret Broker referenziert. +```ini +[Peer] +PublicKey = +AllowedIPs = 10.77.0.2/32 +``` -## 7. Router installieren +Erst wenn der Tunnel steht, kann der Bootstrap fortfahren. API-Schlüssel +werden nicht ausgegeben. Sie liegen root-only unter `/etc/mike-ai`. -Das bestehende `deploy/install.sh` installiert Router, Vision/Bild-Worker, -Whisper und XTTS. Vor produktiver Verwendung müssen Modellpfade in der -systemd-Datei gegen das lokale Manifest geprüft werden. +## Ergebnis und Abnahme -Der Installer erzeugt `/etc/mike-ai/router-api-key` (0600), installiert das -Profilregister und legt die atomare Zustandsablage an. Danach wird der Key -einmal manuell in die Secret-Stores der erlaubten Clients übernommen. Er darf -nicht im Terminal-Log, in Screenshots oder in Git dokumentiert werden. +- Open WebUI: `http://:8080` +- Router: `http://:8081` +- llama.cpp-WebUI: absichtlich deaktiviert und nicht veröffentlicht -Der Router läuft aktuell als gehärteter Root-Dienst, weil er den -llama.cpp-Systemdienst und temporäre GPU-Worker kontrolliert. Das ist eine -bewusste Restabweichung. Ein späterer V3-Schritt soll den HTTP-Proxy als eigenen -Benutzer ausführen und nur Profil-/Hotswap-Befehle an einen fest -parametrisierten Root-Helper delegieren. +```bash +sudo systemctl status wg-quick@wg0 mike-ai-network-guard +sudo docker compose --env-file /etc/mike-ai/stack.env \ + -f /opt/mike-ai/stack/compose.yaml ps +curl http://:8081/health +``` -## 8. Websuche +Zusätzlich prüfen: Uni-LAN sieht keine KI-Ports; Heimnetz erreicht beide; +gestopptes WireGuard lässt KI-Container nicht ins Internet; jeder Profilwechsel +startet exakt einen llama-Container; Text, Tool Call und Bild funktionieren. -TinySearch und SearXNG bleiben als einziges Docker-Teilsystem isoliert. Die -Suchdienste sollen nur an localhost gebunden werden; nur der Web-MCP greift -darauf zu. +Die öffentliche Standardkonfiguration nutzt `UD-IQ4_XS`. Das bislang schnellste +Referenzprofil nutzt dagegen die lokal vorhandene `IQ4-MIX`-Datei. Für eine +bitgenaue Migration diese Datei anhand der in `CURRENT_REFERENCE.md` +dokumentierten SHA256 in das Modellverzeichnis kopieren und die drei +`*_MODEL_FILE`-Werte anpassen. Der Installer löscht vorhandene Modelle nicht. -## 9. Verifikation - -`platform/checks/verify-platform.sh` kontrolliert: - -- freien Plattenplatz, -- GPU und VRAM, -- aktive Dienste, -- Ports, -- Routermodelle und aktives Profil, -- llama.cpp-Health, -- unerwartete RX- und Benchmark-Dienste. - -Erst nach erfolgreicher Prüfung werden Clients auf Port 8081 umgestellt. +Open-WebUI-Daten liegen in einem Docker-Volume und müssen separat gesichert +werden. Geheimnisse und Chatdaten gehören nie in Git. diff --git a/docs/NEW_HOST_ROADMAP.md b/docs/NEW_HOST_ROADMAP.md new file mode 100644 index 0000000..c0632ae --- /dev/null +++ b/docs/NEW_HOST_ROADMAP.md @@ -0,0 +1,46 @@ +# Roadmap: sauberer KI-Host + +## Phase 0 – Entscheidungen und Freigaben + +- VPN-Nutzung mit der Universität abstimmen. +- Eindeutige Netze und Heim-WireGuard-Peer festlegen. +- Festplattenverschlüsselung und Remote-Unlock entscheiden. +- Repository- und Secret-Backup prüfen. + +## Phase 1 – Grundsystem + +- Debian 13 minimal und OpenSSH installieren. +- Firmware/BIOS und beide NVIDIA-Karten prüfen. +- Updates, Zeitsynchronisation und administrativen Zugang testen. + +## Phase 2 – automatischer Bootstrap + +- `config/install.env` ausfüllen. +- `install.sh` ausführen, bei Treiberinstallation neu starten und wiederholen. +- WireGuard-Peer zuhause ergänzen. +- Docker-, GPU- und Fail-Closed-Netztest bestehen. + +## Phase 3 – Inferenz abnehmen + +- Fast/Medium/Long mit derselben Testserie messen. +- Kontext, Prompt-Speed, Ausgabe-Speed und VRAM dokumentieren. +- RTX 3060 zuerst nur im Experimentalprofil testen. +- Erst nach Qualitäts- und Geschwindigkeitsvergleich Produktionswerte ändern. + +## Phase 4 – optionale Fähigkeiten + +- Home-Assistant-MCP mit kleinsten Rechten. +- ARR-MCP zunächst read-only, Schreibaktionen mit Preview/Approval. +- Unraid-/Docker-Zugriff über begrenzte Broker statt Shell-MCP. +- Whisper, TTS oder Bildgenerierung jeweils als eigener Container. + +## Phase 5 – Betrieb + +- Open-WebUI-Volume, Konfigurationen und Secrets verschlüsselt sichern. +- Image- und llama.cpp-Upgrades im Experimentalprofil testen. +- Logs ohne Prompts/Secrets, Metriken für GPU, RAM und Tokenraten. +- Recovery auf leerem Testsystem regelmäßig proben. + +Fertig ist der Host erst, wenn er sich aus Repository und Secret-Backup neu +erzeugen lässt, das Uni-Netz keine KI-Ports sieht, ein Tunnelverlust +fail-closed ist und alle drei Profile den Standardbenchmark bestehen. diff --git a/docs/RECOVERY_REQUIREMENTS.md b/docs/RECOVERY_REQUIREMENTS.md index 3513597..df4a1b7 100644 --- a/docs/RECOVERY_REQUIREMENTS.md +++ b/docs/RECOVERY_REQUIREMENTS.md @@ -55,16 +55,15 @@ Jede Komponente bekommt zusätzlich: - benötigte Environment-Namen ohne Werte - Liste read-only und schreibender Werkzeuge -## 3. Basissystem-Bootstrap – offen +## 3. Basissystem-Bootstrap – umgesetzt, Praxistest offen -Ein idempotentes Bootstrap-Skript muss noch erstellen: +`install.sh` erstellt inzwischen: - Paketquellen und benötigte Debian-Pakete - NVIDIA-Treiber und exakte Version - CUDA Toolkit und Buildabhängigkeiten - Docker und Compose -- Python 3.13 und Python 3.11/uv -- Dienstbenutzer und Gruppen +- die benötigten Container-Runtimes und Dienstbenutzer in Images - Verzeichnisse, Eigentümer und Dateirechte - Firewallregeln - Journalgrößenlimit @@ -91,7 +90,7 @@ Noch festzulegen: - Restore ohne Ausgabe der Werte in Terminal- oder Modellkontext - Funktionstest mit ausschließlich Statuscode, niemals Tokenanzeige -## 5. Netzwerk und DNS – offen +## 5. Netzwerk und DNS – Vorlage umgesetzt, Standortwerte offen Dokumentiert werden müssen: @@ -157,9 +156,9 @@ Festlegen, welche Daten persistent sein sollen: - Benchmarkresultate: eigenes Repository - Logs: ohne Prompt- und Tool-Antwortinhalte -## 9. Ende-zu-Ende-Installer – offen +## 9. Ende-zu-Ende-Installer – implementiert, Hardware-Abnahme offen -Der gewünschte Endzustand ist: +Der Ablauf ist jetzt in `install.sh` zusammengeführt: ```text bootstrap-host diff --git a/docs/SECURITY.md b/docs/SECURITY.md index f32b077..80090c7 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,73 +1,63 @@ # Sicherheitsmodell -## Grundsatz +## Netzgrenze -Das lokale Modell erhält nur die Werkzeuge, die es für den aktuellen Modus -benötigt. Lokalität allein ersetzt keine Zugriffskontrolle. +- KI-Ports binden ausschließlich an die WireGuard-IP. +- Docker-Netze `172.30.0.0/16` verwenden eine eigene Routingtabelle. +- Heimnetz- und optionaler Internetverkehr laufen über WireGuard. +- Eine Blackhole-Default-Route verhindert Fail-open bei Tunnelverlust. +- `DOCKER-USER` erlaubt nur etablierte Verbindungen, KI→WireGuard und + WireGuard→Open-WebUI/Router. +- Der Host ist kein Router zwischen Universitäts- und Heimnetz. -## MCP-Profile +Docker-publizierte Ports können gewöhnliche Host-Firewallregeln umgehen. +Darum setzt der Installer seine Regeln ausdrücklich in `DOCKER-USER` und +verlässt sich nicht allein auf UFW. -Empfohlene Trennung: +## Containergrenzen -| Modus | Werkzeuge | +- llama.cpp: read-only, keine Capabilities, Modelle read-only, keine Ports. +- Router: unprivilegierter Benutzer, kein Docker-Socket, feste API-Oberfläche. +- Profile Controller: einzige Socket-Ausnahme; feste Profile und nur + List/Start/Stop, keine frei wählbaren Images, Befehle oder Mounts. +- Open WebUI: einziges persistentes Chat-Volume. +- SearXNG: intern, Suchanfragen ohne Chatverlauf. + +Ein Docker-Socket bleibt grundsätzlich privilegiert. Der Controller reduziert +die erreichbare Funktion stark, ersetzt aber keine zusätzliche Socket-Proxy- +Sandbox. Er ist klein, testbar und nicht von Clients direkt erreichbar. + +## Secrets und private Daten + +- Keine Secrets in Git, Prompts, MCP-Schemas, Logs oder Screenshots. +- Installer-Konfiguration und `/etc/mike-ai/*` haben restriktive Rechte. +- Router-, Controller- und WebUI-Schlüssel sind getrennt und zufällig. +- Das Modell bekommt keine Schlüsselwerte zurück; spätere Integrationen nutzen + lokale Broker/Environment-Dateien. +- Open-WebUI-Volume kann Chats enthalten und wird nur verschlüsselt gesichert. + +## Werkzeugprofile + +| Modus | Erlaubte Werkzeuge | |---|---| -| Standard | Websuche, harmlose lokale Hilfsfunktionen | -| Home Assistant | HA-Administration plus Websuche | -| ARR | Sonarr/Radarr plus Websuche | -| Unraid Read-only | Diagnose, Logs, Status | -| Unraid Write | nur bewusst aktiviert, mit Vorschau und Approval Ticket | +| Standard | lokale Websuche, harmlose Hilfsfunktionen | +| Home Assistant | eigener begrenzter HA-MCP | +| ARR | Sonarr/Radarr, zuerst read-only | +| Unraid Diagnose | Status und eng begrenzte Logs | +| Administration | Vorschau, Approval-Ticket, Verifikation | -## Nicht im Standardprofil - -- allgemeine Shell -- `python3`, `ssh`, `scp` oder beliebiges `curl` -- Container erstellen, verändern oder löschen -- Registry-/Storage-Direktzugriff -- uneingeschränkte Dateisuche - -## Secrets - -- Keine Secrets in Git, Prompts, MCP-Schemas oder Logs. -- Konfiguration referenziert nur Namen lokaler Environment-Dateien. -- Dateien mit Secrets: Eigentümer root oder Dienstbenutzer, Modus `0600`. -- Tokens werden pro Dienst getrennt und minimal berechtigt. -- Ein Secret Broker oder Wrapper stellt Verbindungen her, ohne Tokens an das - Modell zurückzugeben. - -## Netzwerk - -- Port 8080 nur localhost oder administratives VLAN. -- Clients verwenden Port 8081. -- Whisper, XTTS, TinySearch und SearXNG nur localhost. -- Firewall erlaubt nur bekannte Quellnetze. -- Externe Suche erhält nur die tatsächliche Suchanfrage, keine Chat-Historie. - -## Router-Grenze - -- Alle fachlichen Endpunkte verlangen einen mindestens 32 Zeichen langen, - zufälligen Router-Key. Der Dienst startet ohne gültigen Key nicht. -- `/health` und `/ready` sind die einzigen anonymen Endpunkte und geben nur - groben Betriebszustand aus. -- Authentifizierungsheader werden niemals an llama.cpp weitergereicht. -- Remote-Bild-URLs sind standardmäßig gesperrt. Data-URLs werden auf MIME-Typ, - Base64-Gültigkeit und 20 MiB Maximalgröße geprüft. -- Die Zahl gleichzeitiger Requests ist begrenzt; große Uploads sind global - begrenzt und generierte Bilder werden nach Alter, Anzahl und Größe bereinigt. -- Crash-Recovery beendet keine PID nur aufgrund einer Zahl, sondern verlangt - zusätzlich einen erwarteten Prozessmarker in `/proc//cmdline`. +Allgemeine Shell, beliebiges SSH/SCP, freies `curl`, Docker-Administration und +Dateisystemsuche gehören nicht ins Standardprofil. ## Schreibaktionen -Jede destruktive oder persistente Aktion verwendet: +Persistente oder destruktive Änderungen folgen immer: Bestandsaufnahme, +exakte Vorschau, an die Vorschau gebundene Freigabe, unveränderte Ausführung, +anschließende Verifikation. -1. read-only Bestandsaufnahme, -2. exakte Vorschau, -3. an diese Vorschau gebundenes Approval Ticket, -4. unveränderte Ausführung, -5. anschließende Verifikation. +## Vor jedem Push -## Repository-Prüfung vor jedem Push - -- Suche nach Token-, Passwort- und Private-Key-Mustern. -- Keine `.env`, Zertifikate, Logs, Bilder, Audio oder Modellartefakte. -- Keine echten internen API-Schlüssel in Beispielen. +- Private-Key-, Token-, Passwort- und API-Key-Muster suchen. +- Keine `.env`, Zertifikate, Logs, Bilder, Audio oder Modelle einchecken. +- Beispiele enthalten nur Platzhalter; interne Hostnamen nur wenn bewusst. +- Änderungen am Controller und Netzwerkguard mit Tests und Review versehen. diff --git a/docs/WIREGUARD_HOME_PEER.md b/docs/WIREGUARD_HOME_PEER.md new file mode 100644 index 0000000..888ac29 --- /dev/null +++ b/docs/WIREGUARD_HOME_PEER.md @@ -0,0 +1,55 @@ +# WireGuard-Heimseite + +Der Installer kann nur den KI-Host konfigurieren. Einmalig muss der vorhandene +WireGuard-Router im Heimnetz den neuen Peer kennen. Ohne diesen externen Schritt +kann kein automatisches Skript auf dem Uni-Host den Tunnel fertigstellen. + +## Peer ergänzen + +Beispiel mit KI-WireGuard-Adresse `10.77.0.2/32`: + +```ini +[Peer] +PublicKey = +AllowedIPs = 10.77.0.2/32 +``` + +Das Heimgerät muss das LAN `192.168.1.0/24` zum Tunnel routen können. Geräte im +Heimnetz benötigen entweder eine Route für `10.77.0.2/32` über den +WireGuard-Router oder der Router maskiert den VPN-Verkehr passend. + +## KI-Internetzugang über zuhause + +Wenn `WG_ROUTE_AI_INTERNET=true` gesetzt ist, muss der Heim-Peer IPv4-Forwarding +und NAT ins WAN erlauben. Das wird auf dem Heimrouter eingerichtet, nicht auf +dem Universitätsnetz. Beispielprinzip für nftables: + +```nft +table inet mike_ai { + chain forward { + type filter hook forward priority 0; policy accept; + iifname "wg0" ip saddr 10.77.0.2 accept + oifname "wg0" ip daddr 10.77.0.2 ct state established,related accept + } +} + +table ip mike_ai_nat { + chain postrouting { + type nat hook postrouting priority 100; policy accept; + ip saddr 10.77.0.2 oifname "" masquerade + } +} +``` + +Die tatsächlichen Interface-Namen und die bestehende Firewall des Heimrouters +gehen vor. Regeln nicht blind neben eine bereits verwaltete Firewall kopieren. + +## Sicherheitsprüfung + +1. Vom Heimnetz `10.77.0.2` erreichen. +2. Open WebUI auf `10.77.0.2:8080` erreichen. +3. Aus dem Universitäts-LAN Port 8080/8081 nicht erreichen. +4. `wg0` am KI-Host stoppen: KI-Container dürfen nun weder Heimnetz noch + Internet erreichen. +5. Der Debian-Host selbst darf weiterhin nur die ausdrücklich gewünschte + Administration über das Uni-LAN anbieten. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..15ceb8e --- /dev/null +++ b/install.sh @@ -0,0 +1,330 @@ +#!/usr/bin/env bash +# Reproducible bootstrap for a fresh Debian 12/13 AI host. +set -Eeuo pipefail +umask 077 + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG="" +STACK_DIR=/opt/mike-ai/stack +SECRETS_DIR=/etc/mike-ai +STATE_DIR=/srv/mike-ai + +log() { printf '\n==> %s\n' "$*"; } +die() { printf 'FEHLER: %s\n' "$*" >&2; exit 1; } + +usage() { + cat <<'EOF' +Aufruf: sudo ./install.sh --config /root/mike-ai-install.env + +Das Skript ist idempotent und darf nach einem Treiber-Reboot erneut ausgeführt +werden. Es formatiert keine Datenträger und löscht keine Modelle oder Volumes. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --config) CONFIG="${2:-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "Unbekanntes Argument: $1" ;; + esac +done + +[[ $EUID -eq 0 ]] || die "Bitte als root ausführen." +[[ -n "$CONFIG" && -f "$CONFIG" ]] || die "Konfigurationsdatei fehlt (--config)." +config_mode=$(stat -c '%a' "$CONFIG") +if (( (8#$config_mode & 077) != 0 )); then + die "Konfigurationsdatei darf nicht für Gruppe/Andere schreib- oder lesbar sein (chmod 600)." +fi +# shellcheck disable=SC1090 +source "$CONFIG" + +required=(AI_HOSTNAME ADMIN_USER AI_BIND_ADDRESS MODEL_DIR FAST_MODEL_FILE + FAST_MODEL_URL FAST_MODEL_SHA256 MEDIUM_MODEL_FILE MEDIUM_MODEL_URL + MEDIUM_MODEL_SHA256 LONG_MODEL_FILE LONG_MODEL_URL LONG_MODEL_SHA256 + EXPERIMENTAL_MODEL_FILE EXPERIMENTAL_MODEL_URL EXPERIMENTAL_MODEL_SHA256 + VISION_PROJECTOR_FILE VISION_PROJECTOR_URL VISION_PROJECTOR_SHA256 + MTP_MODEL_FILE MTP_MODEL_URL MTP_MODEL_SHA256) +for name in "${required[@]}"; do + [[ -n "${!name:-}" ]] || die "Pflichtwert $name fehlt." +done + +source /etc/os-release +[[ ${ID:-} == debian ]] || die "Unterstützt wird Debian, gefunden: ${ID:-unbekannt}." +[[ ${VERSION_ID%%.*} == 12 || ${VERSION_ID%%.*} == 13 ]] || \ + die "Unterstützt werden Debian 12 und 13." +[[ $(dpkg --print-architecture) == amd64 ]] || die "Dieser Stack erwartet amd64." +id "$ADMIN_USER" >/dev/null 2>&1 || die "ADMIN_USER $ADMIN_USER existiert nicht." + +install_base_packages() { + log "Basispakete installieren" + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates curl git gnupg jq openssl wireguard-tools iptables \ + iproute2 pciutils rsync unattended-upgrades +} + +install_docker() { + log "Docker CE aus dem offiziellen Repository installieren" + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/debian/gpg \ + -o /etc/apt/keyrings/docker.asc + chmod a+r /etc/apt/keyrings/docker.asc + cat >/etc/apt/sources.list.d/docker.sources </dev/null 2>&1; then + [[ ${INSTALL_NVIDIA_DRIVER:-false} == true ]] || \ + die "NVIDIA-Treiber fehlt. Installiere ihn oder setze INSTALL_NVIDIA_DRIVER=true." + log "NVIDIA-Treiber aus Debian installieren" + if ! apt-cache show nvidia-driver >/dev/null 2>&1; then + die "Paket nvidia-driver fehlt. Debian-Quellen müssen contrib, non-free und non-free-firmware enthalten." + fi + DEBIAN_FRONTEND=noninteractive apt-get install -y nvidia-driver firmware-misc-nonfree + printf '\nNVIDIA-Treiber installiert. Jetzt neu starten und danach denselben Installer erneut ausführen.\n' + exit 20 + fi + nvidia-smi >/dev/null || die "nvidia-smi ist vorhanden, aber der Treiber funktioniert nicht." + + log "NVIDIA Container Toolkit installieren" + curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \ + gpg --dearmor --yes -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg + curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \ + sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \ + >/etc/apt/sources.list.d/nvidia-container-toolkit.list + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y nvidia-container-toolkit + nvidia-ctk runtime configure --runtime=docker + systemctl restart docker + docker run --rm --gpus all nvidia/cuda:12.8.1-base-ubuntu24.04 nvidia-smi >/dev/null +} + +setup_wireguard() { + [[ ${WIREGUARD_ENABLE:-false} == true ]] || return 0 + for name in WG_INTERFACE WG_ADDRESS WG_HOME_SUBNET WG_PEER_PUBLIC_KEY WG_PEER_ENDPOINT; do + [[ -n "${!name:-}" && ${!name} != REPLACE_* ]] || die "WireGuard-Wert $name fehlt." + done + log "WireGuard als ausgehenden Heimnetz-Tunnel konfigurieren" + install -d -m 0700 /etc/wireguard + local key_file="/etc/wireguard/${WG_INTERFACE}.key" + [[ -s $key_file ]] || wg genkey >"$key_file" + chmod 0600 "$key_file" + local private_key + private_key=$(<"$key_file") + local allowed_ips="$WG_HOME_SUBNET" + [[ ${WG_ROUTE_AI_INTERNET:-true} == true ]] && allowed_ips="0.0.0.0/0, ::/0" + { + printf '[Interface]\nAddress = %s\nPrivateKey = %s\nTable = off\n' "$WG_ADDRESS" "$private_key" + printf '\n[Peer]\nPublicKey = %s\nEndpoint = %s\nAllowedIPs = %s\nPersistentKeepalive = 25\n' \ + "$WG_PEER_PUBLIC_KEY" "$WG_PEER_ENDPOINT" "$allowed_ips" + if [[ -n ${WG_PEER_PRESHARED_KEY_FILE:-} ]]; then + [[ -s $WG_PEER_PRESHARED_KEY_FILE ]] || die "WireGuard-Preshared-Key-Datei fehlt." + printf 'PresharedKey = %s\n' "$(<"$WG_PEER_PRESHARED_KEY_FILE")" + fi + } >"/etc/wireguard/${WG_INTERFACE}.conf" + chmod 0600 "/etc/wireguard/${WG_INTERFACE}.conf" + systemctl enable --now "wg-quick@${WG_INTERFACE}" + ip address show "$WG_INTERFACE" >/dev/null + local wg_host=${WG_ADDRESS%/*} + [[ $AI_BIND_ADDRESS == "$wg_host" ]] || \ + die "AI_BIND_ADDRESS muss der WireGuard-Adresse ohne Präfix entsprechen ($wg_host)." + printf 'WireGuard-Public-Key des KI-Hosts: %s\n' "$(wg pubkey <"$key_file")" +} + +install_stack_files() { + log "Stackdateien installieren" + install -d -m 0755 "$STACK_DIR" "$MODEL_DIR" "$STATE_DIR/backups" + rsync -a --delete --exclude .git --exclude '*.local.*' \ + --exclude config/install.env "$ROOT_DIR/" "$STACK_DIR/" + install -d -m 0700 "$SECRETS_DIR" + [[ -s $SECRETS_DIR/router-api-key ]] || openssl rand -base64 48 >$SECRETS_DIR/router-api-key + [[ -s $SECRETS_DIR/controller-token ]] || openssl rand -base64 48 >$SECRETS_DIR/controller-token + [[ -s $SECRETS_DIR/webui-secret ]] || openssl rand -base64 48 >$SECRETS_DIR/webui-secret + chmod 0600 "$SECRETS_DIR"/* + + local searx="$STACK_DIR/platform/web-search/searxng-settings.yml" + if [[ ! -s $searx ]]; then + cp "$STACK_DIR/platform/web-search/searxng-settings.example.yml" "$searx" + sed -i "s/CHANGE_ME_GENERATE_RANDOM_SECRET/$(openssl rand -hex 32)/" "$searx" + fi + # The official image reads this as its unprivileged uid (977). + chown root:977 "$searx" + chmod 0640 "$searx" + + cat >$SECRETS_DIR/stack.env </dev/null 2>&1; then + printf 'Vorhanden und geprüft: %s\n' "$relative" + return + fi + log "Modell laden: $relative" + curl --fail --location --continue-at - --retry 5 --retry-all-errors \ + --output "$target.partial" "$url" + printf '%s %s\n' "$expected" "$target.partial" | sha256sum -c - + mv "$target.partial" "$target" +} + +download_models() { + [[ ${SKIP_MODEL_DOWNLOADS:-false} == true ]] && return 0 + declare -A seen=() + local row relative url hash + while IFS='|' read -r relative url hash; do + [[ -n ${seen[$relative]:-} ]] && continue + seen[$relative]=1 + download_one "$relative" "$url" "$hash" + done </usr/local/sbin/mike-ai-network-guard </dev/null || true +done +ip route replace \"\$HOME_NET\" dev \"\$WG\" +ip route replace \"\$HOME_NET\" dev \"\$WG\" table \"\$TABLE\" +ip route replace blackhole default metric 32767 table \"\$TABLE\" +EOF + if [[ ${WG_ROUTE_AI_INTERNET:-true} == true ]]; then + printf 'ip route replace default dev "$WG" metric 10 table "$TABLE"\n' >>/usr/local/sbin/mike-ai-network-guard + fi + cat >>/usr/local/sbin/mike-ai-network-guard <<'EOF' +# Explicit forwarding policy: AI containers may use wg0; WireGuard clients may +# reach the two published UI/API ports. Neither side may use this host as a +# general university<->home router. The blackhole route above prevents a +# fail-open to the university gateway when wg0 loses its peer/default route. +add_rule() { iptables -C DOCKER-USER "$@" 2>/dev/null || iptables -I DOCKER-USER 1 "$@"; } +add_rule -o "$WG" -j DROP +add_rule -i "$WG" -j DROP +add_rule -s 172.30.0.0/16 -o "$WG" -j ACCEPT +add_rule -i "$WG" -d 172.30.10.0/24 -p tcp -m multiport --dports 8080,8081 -j ACCEPT +add_rule -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT +EOF + chmod 0755 /usr/local/sbin/mike-ai-network-guard + cat >/etc/systemd/system/mike-ai-network-guard.service < None: + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(SOCKET_PATH) + + +def docker_request(method: str, path: str) -> tuple[int, bytes]: + conn = UnixConnection("localhost", timeout=30) + try: + conn.request(method, path, headers={"Content-Type": "application/json"}) + response = conn.getresponse() + return response.status, response.read() + finally: + conn.close() + + +def containers() -> dict[str, dict]: + filters = urllib.parse.quote(json.dumps({"label": [LABEL_KEY]})) + status, body = docker_request("GET", f"/containers/json?all=1&filters={filters}") + if status != 200: + raise RuntimeError(f"Docker list failed with HTTP {status}") + result: dict[str, dict] = {} + for item in json.loads(body): + profile = item.get("Labels", {}).get(LABEL_KEY) + if profile in ALLOWED: + if profile in result: + raise RuntimeError(f"duplicate container for profile {profile}") + result[profile] = item + return result + + +def active_profile(items: dict[str, dict] | None = None) -> str | None: + items = items or containers() + active = [name for name, item in items.items() if item.get("State") == "running"] + if len(active) > 1: + raise RuntimeError(f"multiple llama profiles active: {', '.join(active)}") + return active[0] if active else None + + +def activate(profile: str) -> dict: + if profile not in ALLOWED: + raise ValueError("profile is not allowlisted") + with LOCK: + items = containers() + missing = [name for name in ALLOWED if name not in items] + if missing: + raise RuntimeError("profile containers missing: " + ", ".join(missing)) + current = active_profile(items) + if current == profile: + return {"active_profile": current, "changed": False} + for name, item in items.items(): + if name == profile or item.get("State") != "running": + continue + status, _ = docker_request("POST", f"/containers/{item['Id']}/stop?t=120") + if status not in (204, 304): + raise RuntimeError(f"failed to stop profile {name}: HTTP {status}") + target = items[profile] + status, _ = docker_request("POST", f"/containers/{target['Id']}/start") + if status not in (204, 304): + raise RuntimeError(f"failed to start profile {profile}: HTTP {status}") + log.info("activated profile %s (previous=%s)", profile, current) + return {"active_profile": profile, "changed": True} + + +def load_token() -> str: + token = os.environ.get("CONTROLLER_TOKEN", "").strip() + if not token: + with open(TOKEN_FILE, encoding="utf-8") as handle: + token = handle.read().strip() + if len(token) < 32: + raise RuntimeError("controller token is missing or too short") + return token + + +TOKEN = load_token() + + +class Handler(BaseHTTPRequestHandler): + server_version = "mike-ai-profile-controller/1" + + def log_message(self, fmt: str, *args: object) -> None: + log.info("%s - %s", self.client_address[0], fmt % args) + + def reply(self, status: int, payload: dict) -> None: + body = json.dumps(payload, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def authenticated(self) -> bool: + return self.headers.get("Authorization", "") == f"Bearer {TOKEN}" + + def do_GET(self) -> None: # noqa: N802 + if self.path == "/health": + self.reply(200, {"status": "ok"}) + return + if self.path != "/status": + self.reply(404, {"error": "not found"}) + return + if not self.authenticated(): + self.reply(401, {"error": "unauthorized"}) + return + try: + items = containers() + self.reply(200, {"active_profile": active_profile(items), + "profiles": {name: items.get(name, {}).get( + "State", "missing") for name in ALLOWED}}) + except Exception as exc: + log.exception("status failed") + self.reply(503, {"error": str(exc)}) + + def do_POST(self) -> None: # noqa: N802 + if not self.authenticated(): + self.reply(401, {"error": "unauthorized"}) + return + prefix, suffix = "/profiles/", "/activate" + if not self.path.startswith(prefix) or not self.path.endswith(suffix): + self.reply(404, {"error": "not found"}) + return + profile = self.path[len(prefix):-len(suffix)] + if profile not in ALLOWED: + self.reply(400, {"error": "profile is not allowlisted"}) + return + try: + self.reply(200, activate(profile)) + except Exception as exc: + log.exception("activation failed for %s", profile) + self.reply(503, {"error": str(exc)}) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + ThreadingHTTPServer((HOST, PORT), Handler).serve_forever() diff --git a/platform/docker/router/Dockerfile b/platform/docker/router/Dockerfile new file mode 100644 index 0000000..4ad989a --- /dev/null +++ b/platform/docker/router/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.13.7-slim +RUN apt-get update && apt-get install -y --no-install-recommends gosu && \ + rm -rf /var/lib/apt/lists/* && \ + useradd --system --uid 10002 --home /nonexistent --shell /usr/sbin/nologin router +WORKDIR /app +COPY router/ai_profile_router.py router/router_support.py /app/ +COPY platform/docker/router/entrypoint.sh /usr/local/bin/router-entrypoint +RUN chmod 0755 /usr/local/bin/router-entrypoint +ENTRYPOINT ["router-entrypoint"] diff --git a/platform/docker/router/entrypoint.sh b/platform/docker/router/entrypoint.sh new file mode 100755 index 0000000..326c708 --- /dev/null +++ b/platform/docker/router/entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu +chown 10002:10002 /var/lib/mike-ai-profile-router /data/images +exec gosu 10002:10002 python /app/ai_profile_router.py diff --git a/platform/models/manifest.example.yaml b/platform/models/manifest.example.yaml index 5650e75..d3396d1 100644 --- a/platform/models/manifest.example.yaml +++ b/platform/models/manifest.example.yaml @@ -2,10 +2,11 @@ schema: 1 models: qwen_fast_long: role: primary-text-fast-and-long - source: "REPLACE_WITH_MODEL_REPOSITORY" + source: "local migration from the reference host; public origin still to document" file: Qwen3.8-27B-IQ4-MIX.gguf target: /opt/mike-ai/models/qwen3.8-27b-iq4-mix/Qwen3.8-27B-IQ4-MIX.gguf - sha256: "REPLACE_AFTER_VERIFICATION" + sha256: "54879ae8738d5938f46cb3b8cbf16bf42b8c85b7d68d7c73f062b612ec183e36" + size_bytes: 14111614400 qwen_medium: role: primary-text-medium source: "REPLACE_WITH_MODEL_REPOSITORY" diff --git a/platform/web-search/web_search_mcp.py b/platform/web-search/web_search_mcp.py index c016778..716ea35 100644 --- a/platform/web-search/web_search_mcp.py +++ b/platform/web-search/web_search_mcp.py @@ -31,6 +31,7 @@ SERVER_VERSION = "2.1.0" TINYSEARCH_CONTAINER = os.environ.get( "TINYSEARCH_CONTAINER", "mike-ai-web-search-tinysearch-1" ) +SEARXNG_URL = os.environ.get("SEARXNG_URL", "").rstrip("/") CHILD_TIMEOUT_SECONDS = float(os.environ.get("TINYSEARCH_CHILD_TIMEOUT", "110")) HTTP_TIMEOUT_SECONDS = float(os.environ.get("WEB_API_TIMEOUT", "18")) GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "").strip() @@ -349,6 +350,31 @@ def api_json(url: str, service: str) -> Any: return decoded +def searxng_json(query: str) -> Any: + """Query only the administrator-configured internal SearXNG endpoint. + + Public API fetches intentionally reject private addresses. SearXNG is the + one explicit internal exception; callers cannot influence its scheme, + authority or path, only the encoded search term. + """ + base = urlparse(SEARXNG_URL) + if base.scheme not in {"http", "https"} or not base.hostname: + raise RuntimeError("invalid configured SearXNG URL") + url = f"{SEARXNG_URL}/search?" + urlencode({ + "q": query, "format": "json", "language": "auto"}) + try: + with urlopen(Request(url, headers={ + "Accept": "application/json", + "User-Agent": f"mike-ai-web/{SERVER_VERSION}", + }), timeout=HTTP_TIMEOUT_SECONDS) as response: + payload = response.read(2_000_000) + except HTTPError as exc: + raise RuntimeError(f"searxng returned HTTP {exc.code}") from exc + except (URLError, TimeoutError) as exc: + raise RuntimeError("searxng unavailable") from exc + return json.loads(payload.decode("utf-8", errors="replace")) + + def infer_backend(query: str, requested: str = "auto") -> str: if requested not in {"auto", "web", "github", "huggingface"}: raise ValueError("backend must be auto, web, github or huggingface") @@ -746,7 +772,19 @@ def general_discovery(query: str, limit: int) -> tuple[list[dict[str, Any]], lis results: list[dict[str, Any]] = [] warnings: list[str] = [] try: - results.extend(parse_search_xml(client().call("search", {"query": query}), limit)) + if SEARXNG_URL: + data = searxng_json(query) + for row in (data.get("results") or [])[:limit]: + results.append({ + "title": clean_text(str(row.get("title", "")), 240), + "url": str(row.get("url", "")), + "preview": clean_text(str(row.get("content", "")), 500), + "source": "searxng", + "published_at": row.get("publishedDate") or row.get("published_date"), + }) + else: + results.extend(parse_search_xml( + client().call("search", {"query": query}), limit)) except Exception as exc: warnings.append(clean_text(str(exc), 240)) if len(results) < limit and BRAVE_SEARCH_API_KEY: diff --git a/router/ai_profile_router.py b/router/ai_profile_router.py index 3fdf4fb..a9b58b2 100755 --- a/router/ai_profile_router.py +++ b/router/ai_profile_router.py @@ -92,6 +92,19 @@ UPSTREAM_URL = os.environ.get("UPSTREAM_URL", "http://127.0.0.1:8080").rstrip("/ PROFILE_SCRIPT = os.environ.get("PROFILE_SCRIPT", "/usr/local/bin/llama-profile") PROFILE_DIR = os.environ.get( "PROFILE_DIR", "/etc/systemd/system/mike-ai-llama-ui.service.d") +PROFILE_CONTROL_URL = os.environ.get("PROFILE_CONTROL_URL", "").rstrip("/") +PROFILE_CONTROL_TOKEN_FILE = os.environ.get( + "PROFILE_CONTROL_TOKEN_FILE", "/run/secrets/controller-token") + +# Optional worker APIs. The clean Docker baseline deliberately ships only +# text/multimodal chat; absent workers must fail explicitly instead of trying +# legacy systemd paths inside the container. +ENABLE_IMAGE_GENERATION = os.environ.get( + "ENABLE_IMAGE_GENERATION", "true").lower() in {"1", "true", "yes"} +ENABLE_TTS = os.environ.get( + "ENABLE_TTS", "true").lower() in {"1", "true", "yes"} +ENABLE_STT = os.environ.get( + "ENABLE_STT", "true").lower() in {"1", "true", "yes"} SWITCH_TIMEOUT = float(os.environ.get("SWITCH_TIMEOUT", "600")) # s, Warten auf llama.cpp REQUEST_TIMEOUT = float(os.environ.get("REQUEST_TIMEOUT", "600")) # s, Read-Timeout Upstream @@ -420,12 +433,40 @@ def _read(path: str) -> str: return f.read().strip() +def _profile_controller_request(method: str, path: str) -> dict: + token = os.environ.get("PROFILE_CONTROL_TOKEN", "").strip() + if not token: + token = _read(PROFILE_CONTROL_TOKEN_FILE) + if len(token) < 32: + raise RuntimeError("Profil-Controller-Token fehlt oder ist zu kurz") + request = urllib.request.Request( + PROFILE_CONTROL_URL + path, + method=method, + headers={"Authorization": f"Bearer {token}"}, + ) + try: + with urllib.request.urlopen(request, timeout=120) as response: + return json.load(response) + except urllib.error.HTTPError as exc: + body = exc.read(500).decode(errors="replace") + raise RuntimeError( + f"Profil-Controller HTTP {exc.code}: {body}") from exc + + def current_profile() -> str | None: """Aktives Profil anhand semantischer Werte der override.conf. Kommentare, Leerraum oder die Reihenfolge anderer llama.cpp-Optionen beeinflussen die Erkennung nicht mehr. """ + if PROFILE_CONTROL_URL: + try: + profile = _profile_controller_request("GET", "/status").get( + "active_profile") + return profile if profile in PROFILES else None + except Exception as exc: + log.warning("Profil-Controller-Status nicht verfügbar: %s", exc) + return None try: override = _read(os.path.join(PROFILE_DIR, "override.conf")) except OSError: @@ -512,23 +553,27 @@ def switch_profile(profile: str, implicit: bool = False) -> None: f"llama.cpp nicht erreichbar (Profil {profile} ist " f"bereits aktiv; Neustart über /{profile})") log.info("Profilwechsel: %s -> %s", cur, profile) - try: - proc = subprocess.run( - [PROFILE_SCRIPT, profile], - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - timeout=120, - ) - out = proc.stdout.decode(errors="replace").strip() - if out: - log.info("llama-profile: %s", out[-500:]) - if proc.returncode != 0: - raise RuntimeError( - f"llama-profile fehlgeschlagen (Exit-Code " - f"{proc.returncode}): {out[-500:]}") - except subprocess.TimeoutExpired: - raise RuntimeError("llama-profile hat 120 s überschritten") + if PROFILE_CONTROL_URL: + _profile_controller_request( + "POST", f"/profiles/{profile}/activate") + else: + try: + proc = subprocess.run( + [PROFILE_SCRIPT, profile], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=120, + ) + out = proc.stdout.decode(errors="replace").strip() + if out: + log.info("llama-profile: %s", out[-500:]) + if proc.returncode != 0: + raise RuntimeError( + f"llama-profile fehlgeschlagen (Exit-Code " + f"{proc.returncode}): {out[-500:]}") + except subprocess.TimeoutExpired: + raise RuntimeError("llama-profile hat 120 s überschritten") if current_profile() != profile: raise RuntimeError( f"Profildatei wurde nicht gesetzt (erwartet: {profile})") @@ -1041,11 +1086,23 @@ class Handler(BaseHTTPRequestHandler): elif path == "/v1/audio/voices" and self.command == "GET": self._send_json(200, self._audio_voices_payload()) elif path == "/v1/images/generations" and self.command == "POST": - self._image_generate() + if ENABLE_IMAGE_GENERATION: + self._image_generate() + else: + self._send_error(503, "Bildgenerierung ist nicht installiert", + "server_error", "feature_disabled") elif path == "/v1/audio/speech" and self.command == "POST": - self._speech() + if ENABLE_TTS: + self._speech() + else: + self._send_error(503, "Sprachausgabe ist nicht installiert", + "server_error", "feature_disabled") elif path == "/v1/audio/transcriptions" and self.command == "POST": - self._transcribe() + if ENABLE_STT: + self._transcribe() + else: + self._send_error(503, "Spracherkennung ist nicht installiert", + "server_error", "feature_disabled") elif path == "/images" and self.command == "GET": self._images_list() elif path.startswith("/images/") and self.command == "GET": diff --git a/router/router_profiles.json b/router/router_profiles.json index 2c6a34b..1a95481 100644 --- a/router/router_profiles.json +++ b/router/router_profiles.json @@ -2,15 +2,15 @@ "profiles": { "fast": { "context": 76800, - "model_alias": "qwen38-27b-iq4mix-76k-mtp2-vision" + "model_alias": "qwen-fast" }, "medium": { "context": 94208, - "model_alias": "qwen38-27b-iq4xs-pure-92k" + "model_alias": "qwen-medium" }, "long": { "context": 131072, - "model_alias": "qwen38-27b-iq4mix-128k-mtp2-ffn12" + "model_alias": "qwen-long" } } }