Improve orientation across the doc set without duplicating content: - README: add a "Dokumentation" table mapping each doc (README, BEDIENUNGSANLEITUNG, INSTALL_FROM_ARCHIVE, SECURITY_AND_OPERATIONS, man page, CHANGELOG) to who it is for and what it covers. - SECURITY_AND_OPERATIONS §1: extend the module map with a repo-level inventory (tests, config template, packaging, build/CI/hook scripts, docs) so "which file does what" is answered beyond the src/ modules. 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
BEDIENUNGSANLEITUNG.md Vollständiges Benutzerhandbuch
INSTALL_FROM_ARCHIVE.md Installation aus dem Archiv + Archiv bauen/verifizieren
CHANGELOG.md / LICENSE Versionshistorie / MIT-Lizenz
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-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.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 |