llamacppctl/docs/SECURITY_AND_OPERATIONS.md
dschlueter 3158d16f9b Initial commit: llamacppctl – llama.cpp Docker server control CLI
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>
2026-07-06 16:36:27 +02:00

13 KiB
Raw Blame History

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):

  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, …).
  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.

[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.

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.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.

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-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):

[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.

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