llamacppctl/docs/SECURITY_AND_OPERATIONS.md
dschlueter d5a69e8bf8 docs(config): clarify profile names and make examples copy-paste safe
Rename the size-numbered profiles so the digits read as parameter count,
not Qwen version: qwen35 -> qwen35b (llama.cpp.config.example, build_archive
smoke test, SECURITY_AND_OPERATIONS.md) and qwen27 -> qwen27b
(llama.cpp.config).

Drop the misleading `--profile qwen35` from the README/man/install examples:
that profile only exists in the .example file, so pasted commands failed
against the real config. Primary examples now omit --profile (using the
[default] section, which always resolves), with one example plus a note
showing how to select a real [model.<name>] profile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 09:53:43 +02:00

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

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

[default]
hf_home = /srv/models
gpu_device = 0
container_name = llama_cpp_server
host_port = 8001

[model.qwen35b]
container_name = llama_cpp_qwen35b
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 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
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