llamacppctl/docs/SECURITY_AND_OPERATIONS.md
dschlueter 3e3dd1c150 fix(cli): stop --print-effective-config from starting a container
argparse requires exactly one action, so the documented diagnostic form
`--print-effective-config --config … --start` never took the early-return
branch in main.run(): it printed the resolved config and then executed a
real do_start(), silently replacing a running container with the [default]
model. README, installation guide and manual all recommended that form.

Make it an action in the mutually exclusive group. It can no longer be
combined with --start/--check/--stop/--change/--chat (argparse error,
exit 2), and it skips the docker_available() check, so it now really is
the offline config check the docs promise. --dry-run remains a modifier
and still requires a reachable daemon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:25:07 +02:00

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

Repo-Inventur (über den Code hinaus)

tests/                    Test-Suite (pytest; gemocktes subprocess/requests, DNS-Pinning)
man/llamacppctl.1         Unix-Manpage: vollständige Optionsreferenz
llama.cpp.config.example  Konfigurationsvorlage -> nach llama.cpp.config kopieren
pyproject.toml            Paket-/Build-Metadaten, dev-Extras, ruff-/mypy-Config
requirements.txt          Laufzeit-Install (Zeiger auf das Paket; pyproject ist Quelle)
requirements-dev.txt      Entwickler-Install (`-e .[dev]`)
build_archive.py          Reproduzierbarer .tar.gz-Builder mit Selbstverifikation
scripts/check.sh          Lokales CI-Gate: ruff + mypy + pytest
scripts/smoke.sh          Opt-in End-to-End-Rauchtest gegen echten Docker + GPU
.githooks/pre-push        Pre-Push-Hook (ruft scripts/check.sh) — aktivieren via
                          `git config core.hooksPath .githooks`
.forgejo/workflows/ci.yml Forgejo-Actions-CI (läuft, sobald ein Runner registriert ist)
README.md                 Schnelleinstieg + Doku-Index
CHANGELOG.md / LICENSE    Versionshistorie / MIT-Lizenz
docs/BEDIENUNGSANLEITUNG.md    Vollständiges Benutzerhandbuch
docs/INSTALL_FROM_ARCHIVE.md   Installation aus dem Archiv + Archiv bauen/verifizieren
docs/SECURITY_AND_OPERATIONS.md  Dieses Dokument (Architektur/Sicherheit/Betrieb)

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 per Digest gepinnt für Reproduzierbarkeit, 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 ist eine eigene Aktion in der Mutually-Exclusive-Gruppe: sie gibt die vollständig aufgelöste Konfiguration (Server- und Prompt-Konfiguration) als JSON aus und beendet sich. Damit lässt sich prüfen, welche Werte aus welcher Quelle (Defaults/[default]/[model.*]/CLI) tatsächlich gewonnen haben, ohne einen Container anzufassen.

    Bis einschließlich 0.1.0 war dies ein Flag. Da argparse immer eine Aktion verlangt, führte --print-effective-config --start die Konfigurationsausgabe und anschließend einen echten do_start() aus — der einen laufenden Container stillschweigend ersetzte. Die Kombination ist jetzt ein argparse-Fehler (Exit-Code 2).

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

--print-effective-config erfordert keinen erreichbaren Docker-Daemon; main.run() überspringt die docker_available()-Prüfung für diese Aktion. Für --dry-run gilt das nicht: es ist ein Modifikator von --start/--change, und die Docker-Prüfung läuft vor der Konfigurationsauflösung. Auf einem Host ganz ohne Docker schlägt --start --dry-run daher 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