# 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.]`**-Sektion — ausgewählt über `--profile `, ü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.]`-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 `) - Ziel für `--stop`/--check` - Ableitung des Lock-Pfads (`/tmp/llamacppctl..lock`), sofern `--lock-file`/`lock_file` nicht explizit gesetzt ist Wer mehrere `[model.]`-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-url` als 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, falls `llamacppctl` jemals 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 um `llamacppctl`). ## 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=`, 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::`), d. h. der Endpunkt ist **nicht** aus dem LAN erreichbar. `--expose`/`expose = true` bindet auf alle Interfaces (`-p :`). - 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 ` 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..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): ```ini [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 ` 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`](../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: ` | `--profile ` verweist auf nicht existierende `[model.]`-Sektion | Sektionsname prüfen/anlegen | | `http requires --allow-insecure-http` | `--system-url`/--prompt-url` nutzt `http://` | 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..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 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 |