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>
18 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, 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):
- Eingebaute Defaults (
config.builtin_defaults()) — spiegeln die Standardwerte der ursprünglichen Shell-Skripte wider (Imageghcr.io/ggml-org/llama.cppper 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=, …). [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.
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_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.
- DNS-Pinning gegen Rebinding:
validate_url_target()löst den Hostnamen auf und klassifiziert die IPs; der eigentlicherequests.getwürde jedoch erneut auflösen (TOCTOU-Fenster)._pin_dns()beschränktsocket.getaddrinfofü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 mitgaierrorabgebrochen. 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.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.
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.1veröffentlicht (_port_publish()->-p 127.0.0.1:<host_port>:<container_port>), d. h. der Endpunkt ist nicht aus dem LAN erreichbar.--expose/expose = truebindet auf alle Interfaces (-p <host_port>:<container_port>). - Wird exponiert, sollte per
--api-key/api_keyein Schlüssel gesetzt werden. Er wird dem Server übergeben (--api-key) und vom Tool alsAuthorization: Bearer <key>auf jeder Anfrage mitgesendet. - Wichtig: llama.cpp schützt damit
/v1/chat/completions(ohne Key -> 401), lässt aber/healthund/v1/modelsbewusst 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-configist 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 --startdie Konfigurationsausgabe und anschließend einen echtendo_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 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`.
--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. --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.--checkist 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 Skriptscripts/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 |