Add reproducible Docker and WireGuard host bootstrap

This commit is contained in:
Mikei386
2026-08-20 21:23:16 +02:00
parent cb07779f5a
commit e83c0e2c70
25 changed files with 1584 additions and 835 deletions
+12
View File
@@ -0,0 +1,12 @@
.git
.gitignore
config/install.env
*.local.*
*.log
__pycache__
*.pyc
docs
dev
deploy
benchmarks
artifacts
+26
View File
@@ -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
+2
View File
@@ -28,3 +28,5 @@ logs/
*.bin
.venv/
venv/
config/install.env
platform/web-search/searxng-settings.yml
+65 -574
View File
@@ -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 | `<WG-IP>:8080` | Chat und Administration |
| Profile Router | `<WG-IP>: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/<datei>` | 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
<profil>` 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 /<profil>` angestoßen.
- **Ungültige Profile/Modelle**: `POST /<anderes>` → `400`;
`qwen-<anderes>` 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-<zeitstempel>-<seed>-<i>.png`) und ist über `GET /images/<datei>`
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` | `<router-dir>/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.
+399
View File
@@ -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:
+53
View File
@@ -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
+58
View File
@@ -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()
+67 -92
View File
@@ -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.
+4
View File
@@ -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
+63 -76
View File
@@ -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 <PRIVATE-REPOSITORY-URL> 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 = <AUSGABE-DES-INSTALLERS>
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://<WIREGUARD-IP>:8080`
- Router: `http://<WIREGUARD-IP>: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://<WIREGUARD-IP>: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.
+46
View File
@@ -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.
+6 -7
View File
@@ -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
+50 -60
View File
@@ -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/<pid>/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.
+55
View File
@@ -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 = <PUBLIC-KEY-DES-KI-HOSTS>
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 "<HEIM-WAN-INTERFACE>" 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.
Executable
+330
View File
@@ -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 <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: ${VERSION_CODENAME}
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y \
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
}
install_nvidia() {
if ! command -v nvidia-smi >/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 <<EOF
AI_BIND_ADDRESS=$AI_BIND_ADDRESS
MODEL_DIR=$MODEL_DIR
ROUTER_API_KEY=$(<$SECRETS_DIR/router-api-key)
CONTROLLER_TOKEN=$(<$SECRETS_DIR/controller-token)
WEBUI_SECRET_KEY=$(<$SECRETS_DIR/webui-secret)
OPENWEBUI_IMAGE=${OPENWEBUI_IMAGE:-ghcr.io/open-webui/open-webui:v0.9.5}
OPENWEBUI_ENABLE_SIGNUP=${OPENWEBUI_ENABLE_SIGNUP:-false}
AI_DNS=${WG_DNS:-1.1.1.1}
FAST_MODEL_FILE=$FAST_MODEL_FILE
MEDIUM_MODEL_FILE=$MEDIUM_MODEL_FILE
LONG_MODEL_FILE=$LONG_MODEL_FILE
EXPERIMENTAL_MODEL_FILE=$EXPERIMENTAL_MODEL_FILE
VISION_PROJECTOR_FILE=$VISION_PROJECTOR_FILE
MTP_MODEL_FILE=$MTP_MODEL_FILE
FAST_CONTEXT=${FAST_CONTEXT:-76800}
MEDIUM_CONTEXT=${MEDIUM_CONTEXT:-94208}
LONG_CONTEXT=${LONG_CONTEXT:-131072}
EXPERIMENTAL_CONTEXT=${EXPERIMENTAL_CONTEXT:-76800}
FAST_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}
MEDIUM_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}
LONG_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}
EXPERIMENTAL_GPU_DEVICES=${TEXT_GPU_DEVICES:-0}
LLAMA_THREADS=${LLAMA_THREADS:-6}
LLAMA_THREADS_BATCH=${LLAMA_THREADS_BATCH:-6}
EOF
chmod 0600 $SECRETS_DIR/stack.env
}
download_one() {
local relative=$1 url=$2 expected=$3 target="$MODEL_DIR/$1"
install -d -m 0755 "$(dirname "$target")"
if [[ -f $target ]] && printf '%s %s\n' "$expected" "$target" | sha256sum -c - >/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 <<EOF
$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
EOF
}
install_routing_guard() {
[[ ${WIREGUARD_ENABLE:-false} == true ]] || return 0
log "KI-Container auf WireGuard routen und Uni-Netz als Transit sperren"
cat >/usr/local/sbin/mike-ai-network-guard <<EOF
#!/usr/bin/env bash
set -euo pipefail
WG=$WG_INTERFACE
HOME_NET=$WG_HOME_SUBNET
TABLE=51820
for NET in 172.30.10.0/24 172.30.30.0/24 172.30.40.0/24; do
ip rule add from \"\$NET\" table \"\$TABLE\" priority 12000 2>/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 <<EOF
[Unit]
Description=Routing guard for Mike AI Docker networks
After=docker.service wg-quick@${WG_INTERFACE}.service
Requires=docker.service wg-quick@${WG_INTERFACE}.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/mike-ai-network-guard
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now mike-ai-network-guard.service
}
build_and_start() {
log "llama.cpp und Plattform-Container bauen"
local commit
commit=$(<"$STACK_DIR/platform/llama/LLAMA_CPP_COMMIT")
cd "$STACK_DIR"
docker build --build-arg LLAMA_CPP_COMMIT="$commit" \
-f platform/docker/llama-cpp/Dockerfile -t mike-ai/llama.cpp:local .
docker compose --env-file "$SECRETS_DIR/stack.env" --profile inference create \
llama-fast llama-medium llama-long llama-experimental
docker compose --env-file "$SECRETS_DIR/stack.env" up -d --build \
profile-controller router searxng open-webui
log "Fast-Profil aktivieren und Readiness prüfen"
docker compose --env-file "$SECRETS_DIR/stack.env" exec -T router python - <<'PY'
import json, os, time, urllib.request
key = os.environ["ROUTER_API_KEY"]
request = urllib.request.Request(
"http://127.0.0.1:8081/fast", method="POST",
headers={"Authorization": f"Bearer {key}"})
with urllib.request.urlopen(request, timeout=700) as response:
print(json.dumps(json.load(response), indent=2))
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
try:
with urllib.request.urlopen("http://127.0.0.1:8081/ready", timeout=5) as response:
if response.status == 200:
print("Router und Fast-Profil sind bereit.")
break
except Exception:
pass
time.sleep(2)
else:
raise SystemExit("Readiness-Check fehlgeschlagen")
PY
}
hostnamectl set-hostname "$AI_HOSTNAME"
install_base_packages
install_docker
install_nvidia
setup_wireguard
install_stack_files
download_models
install_routing_guard
build_and_start
log "Installation abgeschlossen"
cat <<EOF
OpenWebUI: http://${AI_BIND_ADDRESS}:8080
Router-API: http://${AI_BIND_ADDRESS}:8081
Die geheimen Schlüssel liegen ausschließlich unter $SECRETS_DIR (0600).
Konfiguriere auf der Heimseite für den KI-Peer mindestens die Rückroute
${WG_ADDRESS:-10.77.0.2/32}; bei Internet-Routing zusätzlich NAT/Forwarding.
EOF
+34
View File
@@ -0,0 +1,34 @@
ARG CUDA_VERSION=12.8.1
ARG UBUNTU_VERSION=24.04
FROM nvidia/cuda:${CUDA_VERSION}-devel-ubuntu${UBUNTU_VERSION} AS build
ARG DEBIAN_FRONTEND=noninteractive
ARG LLAMA_CPP_COMMIT
ARG CUDA_ARCHITECTURES="86;120"
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates cmake git libcurl4-openssl-dev ninja-build pkg-config && \
rm -rf /var/lib/apt/lists/*
RUN test -n "$LLAMA_CPP_COMMIT"
RUN git clone --filter=blob:none https://github.com/ggml-org/llama.cpp /src/llama.cpp && \
git -C /src/llama.cpp checkout "$LLAMA_CPP_COMMIT"
RUN cmake -S /src/llama.cpp -B /src/llama.cpp/build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DGGML_CUDA=ON \
-DGGML_NATIVE=OFF \
-DCMAKE_CUDA_ARCHITECTURES="$CUDA_ARCHITECTURES" && \
cmake --build /src/llama.cpp/build --target llama-server -j "$(nproc)"
FROM nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu${UBUNTU_VERSION}
ARG DEBIAN_FRONTEND=noninteractive
ARG LLAMA_CPP_COMMIT
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl libcurl4 libgomp1 python3 && \
rm -rf /var/lib/apt/lists/* && \
useradd --system --uid 10003 --home /nonexistent --shell /usr/sbin/nologin llama
COPY --from=build /src/llama.cpp/build/bin/ /opt/llama/bin/
COPY platform/web-search/web_search_mcp.py /opt/mike-ai/mcp/web_search_mcp.py
LABEL org.opencontainers.image.source="https://github.com/ggml-org/llama.cpp" \
com.mike-ai.llama-cpp-commit="$LLAMA_CPP_COMMIT"
ENV LD_LIBRARY_PATH=/opt/llama/bin
USER 10003:10003
ENTRYPOINT ["/opt/llama/bin/llama-server"]
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"web": {
"command": "/usr/bin/python3",
"args": ["/opt/mike-ai/mcp/web_search_mcp.py"],
"env": {
"SEARXNG_URL": "http://searxng:8080"
},
"timeout_ms": 120000
}
}
}
@@ -0,0 +1,3 @@
FROM python:3.13.7-slim
COPY profile_controller.py /app/profile_controller.py
ENTRYPOINT ["python", "/app/profile_controller.py"]
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Strict Docker profile switcher for the local AI stack.
Only status and activation of a fixed set of labelled llama.cpp containers are
exposed. Callers cannot provide images, commands, mounts or Docker API paths.
"""
from __future__ import annotations
import http.client
import json
import logging
import os
import socket
import threading
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HOST = os.environ.get("CONTROLLER_HOST", "0.0.0.0")
PORT = int(os.environ.get("CONTROLLER_PORT", "8090"))
SOCKET_PATH = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")
TOKEN_FILE = os.environ.get("CONTROLLER_TOKEN_FILE", "/run/secrets/controller-token")
ALLOWED = tuple(x.strip() for x in os.environ.get(
"ALLOWED_PROFILES", "fast,medium,long,experimental").split(",") if x.strip())
LABEL_KEY = "com.mike-ai.llama-profile"
LOCK = threading.Lock()
log = logging.getLogger("profile-controller")
class UnixConnection(http.client.HTTPConnection):
def connect(self) -> 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()
+9
View File
@@ -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"]
+4
View File
@@ -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
+3 -2
View File
@@ -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"
+39 -1
View File
@@ -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:
+57
View File
@@ -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,6 +553,10 @@ 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)
if PROFILE_CONTROL_URL:
_profile_controller_request(
"POST", f"/profiles/{profile}/activate")
else:
try:
proc = subprocess.run(
[PROFILE_SCRIPT, 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":
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":
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":
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":
+3 -3
View File
@@ -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"
}
}
}