llamacppctl/docs/SECURITY_AND_OPERATIONS.md
dschlueter 600c128fed docs: bring docs/ and build_archive.py up to date
Die docs/ waren gegenüber den neuen Features veraltet.

- SECURITY_AND_OPERATIONS.md: Netzwerk-Exposition (Loopback-Default,
  --expose/--api-key inkl. 401-Verhalten der Endpunkte), DNS-Pinning,
  --start/--change unter Lock + --force, --change validate-before-remove,
  Chat-Parameter (max_tokens/chat_temperature/--stream), ${ENV}-Expansion,
  --check-Exit-Codes, smoke.sh, ChatReply; Fehler-Tabelle erweitert
  (401, Trunkierung, Docker-health vs. HTTP-OK).
- How_to_use.md: Abschnitt zu den neuen Optionen.
- Archiv-Report: Testzahl (130) und Dateiliste aktualisiert.
- build_archive.py: scripts/ in INCLUDE_DIRS (sonst fehlt smoke.sh im Archiv),
  test_actions.py/How_to_use.md/smoke.sh in REQUIRED_FILES aufgenommen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:32:46 +02:00

309 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# llamacppctl — Sicherheits- und Betriebsdokumentation
Diese Datei beschreibt die Architektur, das Konfigurationsmodell, das
Sicherheitsmodell der Prompt-Eingabeschicht sowie den empfohlenen
Betriebsmodus auf einem dedizierten GPU-Host.
## 1. Architektur / Modulübersicht
```
src/llamacppctl/
cli.py Argumentparsing (argparse) + semantische Cross-Field-Validierung
schema.py Dataclasses: ServerConfig, PromptConfig, ChatReply, CheckResult
config.py INI-Konfiguration laden/mergen -> ServerConfig/PromptConfig
(inkl. ${ENV}- und ~-Expansion in hf_home)
prompt_io.py Sicherheitsgehärtete Eingabeschicht für System-/User-Prompts
docker_ops.py Alle Docker-Interaktionen (subprocess mit Argumentlisten)
http_ops.py HTTP-Health-Checks, Chat-Completions und SSE-Streaming gegen
die OpenAI-kompatible llama.cpp-API
lock_ops.py Exklusives, nicht-blockierendes File-Locking für --start/--change
actions.py Orchestrierung: do_start, do_check, do_stop, do_change, do_chat
main.py Dünner Einstiegspunkt: parse -> validate -> resolve -> dispatch
```
Verantwortungstrennung: `prompt_io.py` kennt kein argparse und arbeitet nur
mit `PromptSource` + `InputPolicy`. `config.py` kennt keine Docker- oder
HTTP-Details. `docker_ops.py`/`http_ops.py` kennen keine CLI-Semantik. Diese
Trennung hält die sicherheitsrelevante Logik (Eingabevalidierung) unabhängig
testbar von der Orchestrierung.
## 2. Konfigurationsmodell
`llama.cpp.config` ist eine Standard-INI-Datei (`configparser`). Auflösung
(niedrigste zu höchster Priorität):
1. **Eingebaute Defaults** (`config.builtin_defaults()`) — spiegeln die
Standardwerte der ursprünglichen Shell-Skripte wider (Image
`ghcr.io/ggml-org/llama.cpp:server-cuda`, `host_port=8001`,
`container_name=va_llm`, `gpu_device=1`, `jinja/fa/kv_unified/
cont_batching/no_context_shift=true`, `reasoning=on`,
`cache_type_k/v=q4_0`, `batch_size=1024`, `ubatch_size=512`,
`timeout=300`, `poll_interval=2`, `expose=false`, `api_key=`,
`max_tokens=2048`, `chat_temperature=`, …).
2. **`[default]`**-Sektion — überschreibt die eingebauten Defaults global.
3. **`[model.<profile>]`**-Sektion — ausgewählt über `--profile <name>`,
überschreibt `[default]` selektiv (nur gesetzte Schlüssel).
4. **CLI-Overrides** — z. B. `--image`, `--host-port`, `--container-name`,
`--ctx-size`, … überschreiben alles Vorherige.
`hf_home` unterstützt Environment-Variablen und `~` (z. B. `hf_home = ${HF_HOME}`);
die Expansion erfolgt in `build_server_config()` über `os.path.expandvars` +
`expanduser`.
Neben den Container-/Server-Parametern liefert die Auflösung auch die
**Chat-Request-Parameter** (`PromptConfig`, wirken nur auf `--chat` und die
Antwort nach `--start`, nicht auf den Container): `max_tokens` (Default 2048;
Reasoning-Modelle brauchen viel Budget, sonst bricht die Antwort mitten im
„Denken“ ab und `content` ist leer) und `chat_temperature` (leer = die
Server-`temp` gilt). CLI-Overrides: `--max-tokens`, `--chat-temp`. Die Ausgabe
kann mit `--stream` token-weise (SSE) erfolgen.
`[prompt.<name>]`-Sektionen werden **separat** aufgelöst und liefern
ausschließlich einen *Fallback*-Systemprompt (`system_prompt = ...`). Ein
explizit übergebener `-s/--system`, `--system-file` oder `--system-url`
gewinnt in jedem Fall.
### `container_name` ist verpflichtend
`config.build_server_config()` wirft einen `ConfigError`, wenn
`container_name` nach vollständiger Auflösung leer oder nur aus Whitespace
besteht. Grund: `container_name` ist der **einzige Identitätsanker** für:
- Docker-Container-Benennung (`docker run --name <container_name>`)
- Ziel für `--stop`/--check`
- Ableitung des Lock-Pfads (`/tmp/llamacppctl.<container_name>.lock`),
sofern `--lock-file`/`lock_file` nicht explizit gesetzt ist
Wer mehrere `[model.<name>]`-Profile definiert, die **gleichzeitig** als
separate Container laufen sollen (z. B. zwei Modelle auf zwei GPUs), **muss**
jedem Profil einen eigenen, eindeutigen `container_name` geben. Andernfalls
würde ein zweiter `--start` den ersten Container unbeabsichtigt ersetzen
(`start_llama_container()` entfernt einen vorhandenen Container gleichen
Namens, bevor es einen neuen startet).
Eine leere `--container-name`-Angabe auf der Kommandozeile wird bereits von
`cli.validate_args()` zurückgewiesen (Exit-Code 2, bevor überhaupt eine
Konfigurationsdatei gelesen wird). Eine leere Angabe **in der Config-Datei**
selbst (z. B. `container_name =` ohne Wert) wird erst bei der
Konfigurationsauflösung als `ConfigError` erkannt.
## 3. Sicherheitsmodell der Prompt-Eingabeschicht (`prompt_io.py`)
Jedes der beiden Eingabefelder (System-Prompt, User-Prompt) akzeptiert genau
**eine** von drei Quellenarten, gegenseitig ausschließend über
argparse-Mutually-Exclusive-Groups **und** eine zweite Verteidigungslinie in
`resolve_source()`:
- **Literal** (`-s`/`-p`): Text direkt auf der Kommandozeile.
- **Datei** (`--system-file`/--prompt-file`): lokaler Dateipfad.
- **URL** (`--system-url`/--prompt-url`): entfernte HTTPS(S)-Ressource.
### 3.1 Datei-Quellen
`load_text_file()`:
- Nur reguläre Dateien (`path.is_file()`); Verzeichnisse etc. werden
abgelehnt.
- Symlinks werden standardmäßig abgelehnt und sind nur über `--allow-symlinks`
freischaltbar (`InputPolicy.allow_symlinks`, Default `False`).
- Größenlimit `--max-input-bytes` (Default 1 MiB) wird **vor** dem Lesen
anhand von `stat().st_size` und **nach** der Normalisierung anhand der
UTF-8-kodierten Zeichenlänge geprüft.
- Binärinhalte (NUL-Byte in den Rohbytes) werden abgelehnt.
- Dekodierung: UTF-8, mit Fallback auf `utf-8-sig` (BOM); ungültige
UTF-8-Daten führen zu einem `PromptSourceError`.
- Zeilenenden werden normalisiert (`\r\n`/`\r` -> `\n`), ein führendes BOM
wird entfernt.
### 3.2 URL-Quellen (SSRF-Härtung)
`validate_url_target()` wird vor **jedem** HTTP-Request ausgeführt —
einschließlich jedem einzelnen Redirect-Hop, falls Redirects aktiviert sind:
1. Nur `https://` standardmäßig erlaubt; `http://` erfordert explizit
`--allow-insecure-http`.
2. Kein Hostname -> Ablehnung.
3. Eingebettete Zugangsdaten in der URL (`user:pass@host`) -> Ablehnung.
4. Optionale Host-Allowlist (`--url-allow-host`, wiederholbar): falls
gesetzt, muss der Hostname exakt (case-insensitiv) enthalten sein.
5. `localhost` als Hostname wird immer abgelehnt.
6. IP-Literale als Hostname (`https://127.0.0.1/...`) werden standardmäßig
abgelehnt; `--allow-ip-host` erlaubt sie explizit.
7. Alle DNS-Antworten für den Hostnamen werden aufgelöst
(`socket.getaddrinfo`); **jede** zurückgegebene Adresse wird klassifiziert
(`ipaddress`-Modul: `is_private`, `is_loopback`, `is_link_local`,
`is_multicast`, `is_reserved`, `is_unspecified`). Trifft **eine** dieser
Eigenschaften zu, wird die Anfrage abgelehnt (Rebinding-Schutz: es reicht,
dass irgendeine aufgelöste Adresse privat ist).
8. Die Cloud-Metadata-Adresse `169.254.169.254` wird explizit zusätzlich
geprüft und immer blockiert, sofern nicht `--allow-private-url` gesetzt
ist.
9. `--allow-private-url` deaktiviert die IP-Klassifikationsprüfungen 7 und 8
komplett (bewusste Eskalation für vertrauenswürdige interne Ziele) und
kann **nicht** gleichzeitig mit `--url-allow-host` verwendet werden
(widersprüchliche Sicherheitsmodelle: „alles erlauben“ vs. „nur
bestimmte Hosts“).
Nach erfolgreicher Zielvalidierung (`load_text_url()`):
- Anfrage mit `stream=True`, `allow_redirects=False` (Redirects werden
manuell behandelt, damit jeder Hop erneut validiert wird), separaten
Connect-/Read-Timeouts (`--connect-timeout`, `--read-timeout`).
- Redirect-Statuscodes (3xx) werden nur verfolgt, wenn `--follow-redirects`
gesetzt ist; sonst Ablehnung. Die maximale Anzahl Hops ist begrenzt
(`InputPolicy.max_redirects`, Default 3).
- Content-Type-Allowlist: nur Text-artige Typen
(`text/plain`, `text/markdown`, `text/csv`, `application/json`,
`application/xml`, `application/yaml`, …); `text/html` nur mit
`--allow-html-input`.
- Größenlimit wird sowohl über den `Content-Length`-Header **als auch**
während des Streamens (`iter_content`) durchgesetzt, sodass ein Server, der
einen falschen (zu niedrigen) `Content-Length`-Header sendet, keinen
Speicher-Erschöpfungsangriff durchführen kann.
- Antwort muss gültiges UTF-8 sein.
- **DNS-Pinning gegen Rebinding:** `validate_url_target()` löst den Hostnamen
auf und klassifiziert die IPs; der eigentliche `requests.get` würde jedoch
**erneut** auflösen (TOCTOU-Fenster). `_pin_dns()` beschränkt `socket.getaddrinfo`
für die Dauer des Requests auf genau die bereits validierten IPs — löst der
Name zwischenzeitlich auf eine andere (z. B. private) Adresse um, wird der
Connect mit `gaierror` abgebrochen. TLS/SNI und Zertifikatsprüfung bleiben
intakt, weil die URL weiterhin den Hostnamen trägt.
### 3.3 Warum das wichtig ist
Diese Härtung verhindert, dass `--system-url`/--prompt-url` als
SSRF-Vektor missbraucht werden kann, um interne Dienste (z. B.
Docker-Socket-Proxies, interne Admin-APIs, Cloud-Metadata-Endpunkte) vom
GPU-Host aus zu erreichen, falls `llamacppctl` jemals in einem Kontext
läuft, in dem die übergebene URL nicht vollständig vertrauenswürdig ist
(z. B. durch ein Webhook, ein anderes Automatisierungs-Skript oder einen
mehrbenutzerfähigen Wrapper um `llamacppctl`).
## 4. Docker- / Host-Vertrauensmodell
- `docker_ops.py` ruft ausschließlich `subprocess.run(["docker", ...])` mit
**Argumentlisten** auf, niemals mit `shell=True` oder zusammengesetzten
Shell-Strings. Es gibt daher keine Shell-Injection-Fläche über
Konfigurationswerte oder CLI-Argumente.
- `llamacppctl` vertraut dem lokalen Docker-Daemon vollständig (wie jedes
Docker-CLI-Tool) — es ist kein Sandboxing gegenüber Docker selbst
vorgesehen. Wer `llamacppctl` einsetzt, muss dem Nutzerkonto, das den
Befehl ausführt, den gleichen Vertrauensgrad einräumen wie direktem
Docker-CLI-Zugriff (i. d. R. Mitgliedschaft in der `docker`-Gruppe).
- `HF_HOME` wird **read-only** (`:ro`) in den Container gemountet — der
Container kann Modelldateien lesen, aber nicht verändern oder löschen.
- GPU-Zuweisung erfolgt über `--gpus device=<gpu_device>`, sodass mehrere
Profile gezielt auf unterschiedliche GPUs (z. B. RTX 3090 #1/#2) gepinnt
werden können.
### 4.1 Netzwerk-Exposition und API-Key
Die llama.cpp-OpenAI-API ist standardmäßig **nicht** authentifiziert. Deshalb:
- Der Port wird per Default nur auf **`127.0.0.1`** veröffentlicht
(`_port_publish()` -> `-p 127.0.0.1:<host_port>:<container_port>`), d. h. der
Endpunkt ist **nicht** aus dem LAN erreichbar. `--expose`/`expose = true`
bindet auf alle Interfaces (`-p <host_port>:<container_port>`).
- Wird exponiert, sollte per `--api-key`/`api_key` ein Schlüssel gesetzt werden.
Er wird dem Server übergeben (`--api-key`) und vom Tool als
`Authorization: Bearer <key>` auf jeder Anfrage mitgesendet.
- **Wichtig:** llama.cpp schützt damit `/v1/chat/completions` (ohne Key -> 401),
lässt aber `/health` und `/v1/models` bewusst offen (für Health-Checks). Die
Reachability-Checks des Tools (`check_health`/`check_models`) funktionieren
daher auch ohne Key; nur die Chat-Endpunkte verlangen ihn.
## 5. Locking (`--start` / `--change`)
`lock_ops.FileLock` verwendet `fcntl.flock(LOCK_EX | LOCK_NB)` auf einer
Lock-Datei unter `/tmp/llamacppctl.<container_name>.lock` (Default, ableitbar
über `--lock-file`). **Sowohl `--start` als auch `--change`** halten den Lock
über die gesamte (Stop-)Reconfigure-Restart-Sequenz. Ein zweiter, gleichzeitiger
Aufruf für **denselben** `container_name` schlägt sofort mit `LockError`
(Exit-Code 6) fehl, statt zu blockieren oder Race-Conditions am Container zu
riskieren. Da der Lock-Pfad an `container_name` gekoppelt ist, blockieren sich
zwei Profile mit unterschiedlichem `container_name` gegenseitig nicht.
`--force` umgeht einen belegten Lock bewusst (mit Warnung auf stderr) — gedacht
für hängende/verwaiste Locks. Zusätzlich lässt `--force` `--stop`
Inkonsistenzen (z. B. halb erstellte Container) schlucken, statt mit
Docker-Fehler abzubrechen.
`--dry-run` ist eine reine Vorschau und nimmt **weder** den Lock **noch**
entfernt es den Container; `--change` validiert außerdem den Modellpfad
**vor** dem Entfernen des laufenden Containers, sodass eine Fehlkonfiguration
keinen vermeidbaren Ausfall verursacht.
## 6. `--dry-run` und `--print-effective-config` als Sicherheitswerkzeuge
- `--print-effective-config` gibt die vollständig aufgelöste Konfiguration
(Server- und Prompt-Konfiguration) als JSON aus, **bevor** irgendeine
Docker- oder HTTP-Aktion ausgeführt wird. Damit lässt sich prüfen, welche
Werte aus welcher Quelle (Defaults/[default]/[model.*]/CLI) tatsächlich
gewonnen haben, ohne einen Container anzufassen.
- `--dry-run` (in Kombination mit `--start`/--change`) zeigt den vollständig
zusammengesetzten `docker run`-Befehl (Shell-quotiert zur Anzeige) an,
**ohne** ihn auszuführen. Empfohlen vor jeder Änderung an einer
Produktionskonfiguration, insbesondere nach Anpassungen an
`llama.cpp.config`.
Beide Flags erfordern **keinen** erreichbaren Docker-Daemon für ihre reine
Ausgabe — `main.run()` prüft `docker_available()` derzeit vor der
Konfigurationsauflösung; auf einem Host ganz ohne Docker (z. B. zur reinen
Konfigurationsvalidierung) schlägt der Aufruf entsprechend mit einer
expliziten Fehlermeldung fehl statt still falsche Annahmen zu treffen.
## 7. Betriebsmodell auf einem dedizierten GPU-Host
Empfohlenes Setup (passend zu einer Zwei-GPU-Umgebung, z. B. 2× RTX 3090):
```ini
[default]
hf_home = /srv/models
gpu_device = 0
container_name = llama_cpp_server
host_port = 8001
[model.qwen35]
container_name = llama_cpp_qwen35
gpu_device = 1
host_port = 8002
[model.deepseek]
container_name = llama_cpp_deepseek
gpu_device = 1
host_port = 8003
ctx_size = 131072
```
- Jedes Profil, das parallel laufen soll, bekommt eine eigene
`container_name`/`host_port`-Kombination.
- `--profile` wählt bei jedem Aufruf explizit aus, welches Profil
angesprochen wird — es gibt keinen impliziten "aktuellen" Zustand außer
dem, was tatsächlich in Docker läuft.
- `--check --profile <name>` ist der empfohlene Weg für Monitoring/Cronjobs,
um sowohl Container-Status als auch tatsächliche Inferenzfähigkeit
(`chat_ok`) zu prüfen. `--check` ist **scriptbar**: Exit-Code 0, wenn der
Container läuft und HTTP erreichbar ist, sonst 5.
- Für einen End-to-End-Test gegen einen echten Docker + llama.cpp-Server
(`--api-key`-Round-Trip, Streaming, Exit-Codes) gibt es das opt-in Skript
`scripts/smoke.sh` (eigener Container/Port, Cleanup am Ende).
## 8. Installation, Nutzung, Fehlersuche
Siehe [`README.md`](../README.md) für Installationsschritte und
Nutzungsbeispiele.
Häufige Fehlerbilder:
| Symptom | Ursache | Lösung |
|---|---|---|
| `docker is not available on PATH` | Docker-CLI fehlt oder Daemon nicht erreichbar | Docker installieren/starten, Nutzer zur `docker`-Gruppe hinzufügen |
| `container_name is mandatory and must not be empty` | Kein `container_name` in `[default]`/`[model.*]`/CLI gesetzt | `container_name` in der Config oder via `--container-name` setzen |
| `model profile not found: <name>` | `--profile <name>` verweist auf nicht existierende `[model.<name>]`-Sektion | Sektionsname prüfen/anlegen |
| `http requires --allow-insecure-http` | `--system-url`/--prompt-url` nutzt `http://` | Auf `https://` wechseln oder bewusst `--allow-insecure-http` setzen |
| `resolved to blocked IP ...` | URL löst auf eine private/loopback/reserved Adresse auf | Ziel korrigieren oder bewusst `--allow-private-url` setzen (nur für vertrauenswürdige interne Ziele) |
| `lock busy: /tmp/llamacppctl.<name>.lock` | Ein anderer `--change`-Lauf für denselben `container_name` läuft bereits | Warten, bis der andere Lauf beendet ist, oder Ursache des hängenden Laufs prüfen |
| `Server did not become ready within <n>s` | Modell braucht länger zum Laden als `timeout`, oder Startfehler | `--timeout` erhöhen, `--logs` für Container-Log-Tail nutzen |
| `chat completion returned HTTP 401: Invalid API Key` | Server verlangt `api_key`, das Tool sendet keinen/falschen | `--api-key`/`api_key` passend setzen |
| Leere Antwort + `Hinweis: ... abgeschnitten (finish_reason=length)` | Reasoning-Modell hat das `max_tokens`-Budget im „Denken“ verbraucht | `--max-tokens`/`max_tokens` erhöhen |
| `--check` meldet `health=unhealthy`, obwohl der Server antwortet | bildeigener Docker-Healthcheck vs. eigene HTTP-Probe des Tools | unkritisch: `HTTP: OK` + Exit 0 zählt; Docker-Health ist separat |