Steuert einen llama.cpp-Server als Docker-Container: --start/--check/--stop/
--change/--chat, INI-Konfiguration (builtin defaults -> [default] ->
[model.<profile>] -> CLI), SSRF-gehärtete Prompt-Eingabe (Datei/HTTPS-URL),
File-Locking für --start/--change und ein OpenAI-kompatibler HTTP-Layer.
Enthält u. a.:
- Env-Var-Expansion in hf_home (hf_home = ${HF_HOME})
- konfigurierbares Chat-Antwortbudget (max_tokens/chat_temperature,
CLI: --max-tokens/--chat-temp); temperature defer an Server-Default
- DNS-Pinning gegen DNS-Rebinding bei URL-Quellen
- dry-run als nebenwirkungsfreie Vorschau (kein Lock/Removal/Modell-Check)
- 98 Tests (pytest)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
13 KiB
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, CheckResult
config.py INI-Konfiguration laden/mergen -> ServerConfig/PromptConfig
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 und Chat-Completions gegen die
OpenAI-kompatible llama.cpp-API
lock_ops.py Exklusives, nicht-blockierendes File-Locking für --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):
- Eingebaute Defaults (
config.builtin_defaults()) — spiegeln die Standardwerte der ursprünglichen Shell-Skripte wider (Imageghcr.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, …). [default]-Sektion — überschreibt die eingebauten Defaults global.[model.<profile>]-Sektion — ausgewählt über--profile <name>, überschreibt[default]selektiv (nur gesetzte Schlüssel).- CLI-Overrides — z. B.
--image,--host-port,--container-name,--ctx-size, … überschreiben alles Vorherige.
[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_filenicht 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-symlinksfreischaltbar (InputPolicy.allow_symlinks, DefaultFalse). - Größenlimit
--max-input-bytes(Default 1 MiB) wird vor dem Lesen anhand vonstat().st_sizeund 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 einemPromptSourceError. - 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:
- Nur
https://standardmäßig erlaubt;http://erfordert explizit--allow-insecure-http. - Kein Hostname -> Ablehnung.
- Eingebettete Zugangsdaten in der URL (
user:pass@host) -> Ablehnung. - Optionale Host-Allowlist (
--url-allow-host, wiederholbar): falls gesetzt, muss der Hostname exakt (case-insensitiv) enthalten sein. localhostals Hostname wird immer abgelehnt.- IP-Literale als Hostname (
https://127.0.0.1/...) werden standardmäßig abgelehnt;--allow-ip-hosterlaubt sie explizit. - 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). - Die Cloud-Metadata-Adresse
169.254.169.254wird explizit zusätzlich geprüft und immer blockiert, sofern nicht--allow-private-urlgesetzt ist. --allow-private-urldeaktiviert die IP-Klassifikationsprüfungen 7 und 8 komplett (bewusste Eskalation für vertrauenswürdige interne Ziele) und kann nicht gleichzeitig mit--url-allow-hostverwendet 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-redirectsgesetzt 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/htmlnur 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.
3.3 Warum das wichtig ist
Diese Härtung verhindert, dass --system-url/--prompt-urlals 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, fallsllamacppctljemals 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 umllamacppctl`).
4. Docker- / Host-Vertrauensmodell
docker_ops.pyruft ausschließlichsubprocess.run(["docker", ...])mit Argumentlisten auf, niemals mitshell=Trueoder zusammengesetzten Shell-Strings. Es gibt daher keine Shell-Injection-Fläche über Konfigurationswerte oder CLI-Argumente.llamacppctlvertraut dem lokalen Docker-Daemon vollständig (wie jedes Docker-CLI-Tool) — es ist kein Sandboxing gegenüber Docker selbst vorgesehen. Werllamacppctleinsetzt, muss dem Nutzerkonto, das den Befehl ausführt, den gleichen Vertrauensgrad einräumen wie direktem Docker-CLI-Zugriff (i. d. R. Mitgliedschaft in derdocker-Gruppe).HF_HOMEwird 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.
5. Locking (--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). --change hält den Lock über die gesamte
Stop-Reconfigure-Restart-Sequenz. Ein zweiter, gleichzeitiger --change-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.
6. --dry-run und --print-effective-config als Sicherheitswerkzeuge
--print-effective-configgibt 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 zusammengesetztendocker run-Befehl (Shell-quotiert zur Anzeige) an, **ohne** ihn auszuführen. Empfohlen vor jeder Änderung an einer Produktionskonfiguration, insbesondere nach Anpassungen anllama.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):
[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. --profilewä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.
8. Installation, Nutzung, Fehlersuche
Siehe 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-urlnutzthttp://` |
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 |