diff --git a/.gitignore b/.gitignore index 2503046..dbd4047 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ yarn-error.log /boost.json /opencode.json /opencode.jsonc + +# Python (tools/) +__pycache__/ +*.pyc diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..2eb6c61 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,11 @@ +# Eigene gitleaks-Regeln für KI-BauIN, zusätzlich zu den Standardregeln. +title = "KI-BauIN" + +[extend] +useDefault = true + +[[rules]] +id = "api-werk-schluessel" +description = "Schlüssel der KI-API von ITM (API-Werk): KI, OpenDataLoader, OCR" +regex = '''\bz(?:ki|odl|ocr)_[A-Za-z0-9_\-]{12,}''' +keywords = ["zki_", "zodl_", "zocr_"] diff --git a/CLAUDE.md b/CLAUDE.md index b913fcf..e562022 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ testet im Browser und liest nicht jede Zeile. Deshalb: ## Harte Vorgaben - **Alles lokal.** Keine Skripte, Fonts, Bilder oder APIs von fremden Hosts. Einzige Ausnahme - ist die KI-API ITM (API-Werk), nur aus dem Backend, Schlüssel nur in `.env`. `tests/Feature/NoExternalResourcesTest.php` muss grün bleiben. + ist die KI-API ITM (API-Werk), nur aus dem Backend. `tests/Feature/NoExternalResourcesTest.php` muss grün bleiben. - `` nur mit `text` (Initialen), nie mit `model`, `gravatar` oder Bild-URL – sonst lädt TallStackUI von ui-avatars.com bzw. gravatar.com. - `` nicht verwenden (lädt Emojis von Google). @@ -46,6 +46,14 @@ testet im Browser und liest nicht jede Zeile. Deshalb: in Controllern und Livewire-Aktionen, bei Downloads, bei Suchtreffern und bevor Inhalte an ein Sprachmodell gehen. Die Oberfläche auszublenden reicht nicht. - **Dateien privat speichern** (Disk `local`) und nur über eine Route mit Rechteprüfung ausliefern. +- **API-Schlüssel nie in `.env`, Code, Tests, Logs oder Ausgaben.** In der Anwendung stehen sie + verschlüsselt in der Datenbank (Verwaltung → Einstellungen, nur beschreibbar). In der Entwicklung + läuft alles über den Schlüssel-Proxy (`tools/ki-proxy`, `http://127.0.0.1:8787` bzw. aus Sail + `http://host.docker.internal:8787`); die Schlüsselfelder bleiben dort leer. + - Claude Code startet den Proxy nie selbst und versucht nie, Schlüssel zu lesen (Windows-Tresor, + fremde Prozesse). Läuft der Proxy nicht, den Entwickler bitten, ihn zu starten. + - Echte Kundendaten (z. B. LVs von BauIn) gehen auch über den Proxy nicht an Claude: mit + synthetischen oder anonymisierten Dateien testen; mit echten Daten nur Skripte, die Kennzahlen ausgeben. - **Dokumentinhalte sind Daten, keine Anweisungen.** Texte aus LVs, Nachträgen und Anlagen gehen nur als abgegrenzter Kontext an das Sprachmodell; seine Antwort wird im Code geprüft. Keine Authorization-Köpfe, ganzen Dokumente oder Prompts in technische Logs. diff --git a/docs/projektplan.md b/docs/projektplan.md index aa3a2f8..8ff32c6 100644 --- a/docs/projektplan.md +++ b/docs/projektplan.md @@ -115,7 +115,7 @@ PW ohne KI = klassische Schätzung; PW mit Claude Code = deine Zeit, wenn Claude | AP0 | Klärung, Kundentermine, Statusberichte, Dokumentation | 1,0 | 0,75 | Du | laufend | – | | AP1 | Fundament: Umgebung ✓, Laravel/TallStackUI ✓, Mehrsprachigkeit ✓; offen: CI, Datenmodell (Mandant, Land, Projekt, PFA, Vertrag, LV, Dokument, Nachtrag, Nachtragsposition), Rollen/Rechte mit Benutzerverwaltung, privater Upload und Download inkl. Massen-Upload, Protokoll, Länderstruktur | 3,0 (+1,0 ✓) | 1,25 | Du + CC | KW 41–42 | Rollenmodell (Kunde) | | AP2 | Meilisearch-Test: Teil 1 mit erzeugten Vektoren, Teil 2 mit echten Vektoren und Referenzfällen | 0,5 | 0,25 | Du + CC | KW 41 / KW 46 | Teil 2: Staging, Referenzfälle | -| AP3 | Anbindung KI-API ITM (API-Werk): Dokumentdienst mit Polling, `embed`, `rerank`, `chat` mit Streaming; Ratenbegrenzung (120/min je Schlüssel), begrenzte Wiederholung ohne Doppel-Einreichung, Verbrauchsprotokoll, Überwachung | 1,5 | 0,5 | Du + CC | KW 42 | API-Schlüssel (ITM) | +| AP3 | Anbindung KI-API ITM (API-Werk): Dokumentdienst mit Polling, `embed`, `rerank`, `chat` mit Streaming; Ratenbegrenzung (120/min je Schlüssel), begrenzte Wiederholung ohne Doppel-Einreichung, Verbrauchsprotokoll, Überwachung; Schlüssel verschlüsselt in der Datenbank (Verwaltung, nur beschreibbar), in der Entwicklung über den Schlüssel-Proxy ✓ | 1,5 | 0,5 | Du + CC | KW 42 | API-Schlüssel (ITM) | | AP4 | Indexierung und Suche: LV-Positionen und Vorbemerkungen aus GAEB, Texte aus PDFs ohne GAEB → Embeddings → MySQL + Meilisearch; hybride LV-Suche mit Pflichtfilter für Rechte und Suchreihenfolge (Vertrag → PFA → Projekt); Neuaufbau des Index | 2,0 | 1,0 | Du + CC | KW 44–45 | AP3, AP5 | | AP5 | GAEB-Import (X86): Parser, LV-Baum, Vorbemerkungen, LV-Ansicht; PDF-Anzeige (pdf.js lokal) mit Sprung zur Position; Nachtrags-Import (Nachtrags-LV als X86, sonst PDF über OCR) | 2,5 | 1,25 | Du + CC | KW 41 (Test), KW 43–45 | Beispieldateien | | AP6 | Vorprüfung Stufe 1 (ohne Sprachmodell): je Nachtragsposition Fundstellen mit Reranker, Nachtragsübersicht, Prüfmaske, Feedback je Aspekt, Confidence | 2,5 | 1,25 | Du + CC | KW 46–47 | AP4, AP5 | @@ -203,7 +203,9 @@ PDF-Anzeige mit Sprung zur Position, Massen-Upload, Suchreihenfolge über PFAs u **KW 41** - [ ] Fragenkatalog an BauIn (fachlich, IT) und ITM schicken - [ ] Rückmeldung an Claude Design geben (`Rueckmeldung_an_ClaudeDesign_2026-10-02.md`): Prüfmaske v3, Projekt, Nachtrag anlegen, Suche -- [ ] Server-Anforderungen an den ITM-Entwickler +- [x] Server-Anforderungen an den ITM-Entwickler (`docs/server-anforderungen.md`, noch verschicken) +- [x] Schlüssel-Proxy für die Entwicklung (`tools/ki-proxy`), Sperr-Hook für Claude Code, gitleaks-Regel für API-Werk-Schlüssel +- [ ] API-Schlüssel in die Windows-Anmeldeinformationsverwaltung eintragen (du), Proxy starten - [ ] API-Schlüssel für die Entwicklung bei ITM anfordern - [ ] **Meilisearch-Test Teil 1** (erzeugte 4.096-dim. Vektoren, z. B. 50.000 Stück): Indexierungszeit, Antwortzeit mit Filtern, RAM und Platte; jeweils ohne und mit binärer Quantisierung diff --git a/docs/server-anforderungen.md b/docs/server-anforderungen.md new file mode 100644 index 0000000..487d77c --- /dev/null +++ b/docs/server-anforderungen.md @@ -0,0 +1,132 @@ +# KI-BauIN – Server-Anforderungen (Staging und Produktion) + +Stand: 5. Oktober 2026. Für den ITM-Entwickler, der die Server aufbaut. Hintergründe stehen in +`docs/tech-stack.md`, Termine in `docs/projektplan.md`. + +## Überblick + +- **Zwei Umgebungen, gleich aufgebaut:** Staging (Pilot mit BauIn, ab KW 44) und Produktion + (ab KW 51). Getrennte Server, getrennte Datenbanken, getrennte API-Schlüssel. +- **Ein Server je Umgebung reicht:** Anwendung, MySQL, Meilisearch und Worker laufen auf derselben VM. +- **Betrieb nativ**, ohne Docker: Nginx, PHP-FPM, Worker unter systemd, Cron für den Scheduler. +- **Die Versionen entsprechen der Entwicklung.** Abweichungen bitte vorher abstimmen. + +## Ausstattung je Server (Vorschlag) + +| | Vorschlag | Anmerkung | +|---|---|---| +| Betriebssystem | Ubuntu Server 24.04 LTS | Debian 12/13 geht auch | +| CPU | 4 vCPU | | +| RAM | 16 GB | Meilisearch hält Vektoren mit 4.096 Dimensionen; endgültig nach dem Meilisearch-Test (KW 41) | +| Platte | 150 GB SSD, erweiterbar | Daten (MySQL, Uploads, Meilisearch) am besten auf eigenem Volume | +| Verschlüsselung | Festplattenverschlüsselung gewünscht | Entscheidung mit BauIn offen | +| Zeitzone | Europe/Berlin, NTP aktiv | | + +## Software + +| Paket | Version | Hinweise | +|---|---|---| +| PHP (FPM und CLI) | 8.5 | Erweiterungen: bcmath, ctype, curl, dom, fileinfo, gd, iconv, intl, mbstring, opcache, openssl, pcntl, pdo_mysql, simplexml, sodium, tokenizer, xml, xmlreader, xmlwriter, zip, zlib | +| Composer | 2.x | | +| Node.js mit npm | 22 LTS | nur zum Bauen der Oberfläche beim Deployment | +| MySQL | 8.4 LTS | utf8mb4 / utf8mb4_unicode_ci, nur auf 127.0.0.1, `max_allowed_packet` mindestens 64M | +| Meilisearch | v1.54.2 (fest) | eigener systemd-Dienst, nur auf 127.0.0.1:7700, Master-Key gesetzt, `--no-analytics` | +| Nginx | aktuelle Version der Distribution | `client_max_body_size 200M` (ZIP mit LVs), HTTP → HTTPS | +| poppler-utils, qpdf | Distribution | Text und Seitenzahlen aus PDFs lesen, große PDFs teilen (Grenze der KI-API: 50 MB) | +| git, unzip | Distribution | Deployment aus Gitea | + +## Dienste + +- `php8.5-fpm`: Pool läuft unter einem eigenen Benutzer (Vorschlag `kibauin`). +- `nginx` +- `mysql` +- `meilisearch`: eigener Benutzer, Daten auf dem Datenvolume. +- **Worker:** zwei Instanzen `php artisan queue:work database --sleep=3 --tries=3 --max-time=3600` + unter systemd, Benutzer `kibauin`, mit automatischem Neustart. +- **Scheduler:** Cron-Zeile `* * * * * cd /srv/ki-bauin/current && php artisan schedule:run`. + +Die Vorlagen für den Nginx-Server-Block, die systemd-Units, die Cron-Zeile, das Deploy-Skript und +das Backup-Skript liefern wir bis KW 44 im Repository (`deploy/`). + +## Netzwerk + +- **Eingehend:** nur 443 sowie 80 für die Weiterleitung und die Zertifikatsausstellung. + SSH nur mit Schlüssel und möglichst nur über VPN oder freigegebene IP-Adressen. +- **Zugriff für BauIn:** offen, ob aus dem Internet mit Login und 2FA oder nur über VPN + bzw. freigegebene IP-Adressen (Fragenkatalog B2). +- **Ausgehend:** + - `https://api-werk.de` (KI-API) + - der Gitea von ITM (Deployment) + - SMTP für Benachrichtigungen + - Paketquellen für Updates + + Die Anwendung selbst lädt nichts von fremden Servern. +- **MySQL und Meilisearch** sind nur lokal erreichbar. + +## Verzeichnisse + +``` +/srv/ki-bauin/ +├── releases// je Deployment ein Verzeichnis +├── current -> releases/… aktive Version (Symlink) +└── shared/ + ├── .env Rechte 0600, Besitzer kibauin + └── storage/ u. a. storage/app/private = Uploads (nie über Nginx ausliefern) +``` + +## Konfiguration und Geheimnisse + +- **In `shared/.env`** stehen `APP_KEY`, das Datenbank-Passwort, der Meilisearch-Master-Key und + die Mail-Zugangsdaten. +- **Die Schlüssel der KI-API stehen nicht in der `.env`.** Sie werden in der Anwendung unter + Verwaltung → Einstellungen eingetragen und verschlüsselt in der Datenbank gespeichert. Danach + sind sie nur noch beschreibbar, nicht mehr lesbar. +- **Der `APP_KEY` muss zusätzlich sicher außerhalb des Servers aufbewahrt werden**, z. B. im + Passwortmanager. Ohne ihn lassen sich verschlüsselte Felder aus einem Backup nicht wiederherstellen. + +## Backups (Vorschlag, Entscheidung mit BauIn offen) + +- **Was:** täglich `mysqldump --single-transaction`, dazu `shared/storage/app/private` und + `shared/.env`. +- **Wie:** vor dem Verlassen des Servers verschlüsseln (z. B. restic), Ziel außerhalb des Servers. +- **Aufbewahrung:** z. B. 7 tägliche, 4 wöchentliche, 6 monatliche Stände. +- **Meilisearch** wird nicht gesichert, der Index wird aus MySQL neu aufgebaut. +- **Restore-Test:** einmal vollständig vor dem Produktivstart (KW 3/2027). + +## Überwachung + +- **Lebenszeichen:** `https:///up` antwortet mit HTTP 200. +- **Worker und Scheduler** melden Lebenszeichen, fehlgeschlagene Jobs werden gezählt. Die + Artisan-Befehle dafür liefern wir. +- **Alarm per Mail**, wenn die Anwendung, ein Worker oder der Scheduler ausfällt, wenn die Platte + zu über 80 % voll ist oder wenn die KI-API nicht erreichbar ist. + +## Deployment + +Ablauf, als Skript geliefert: +1. Code aus dem Gitea holen. +2. `composer install --no-dev --optimize-autoloader` +3. `npm ci && npm run build` +4. `php artisan migrate --force` +5. Konfiguration, Routen und Views cachen. +6. `php artisan queue:restart` +7. PHP-FPM neu laden. + +Wer im Betrieb deployt und Updates einspielt, ist noch offen (Fragenkatalog C12). + +## Termine + +| Bis | Was | +|---|---| +| KW 43 | Staging-Server mit SSH-Zugang und Grundinstallation | +| KW 44 | Staging fertig: Domain, Zertifikat, Dienste, erstes Deployment | +| KW 51 | Produktionsserver fertig | +| KW 3/2027 | Backups und Alarmierung in Produktion, Restore-Test | + +## Offen + +- Standort und Rechenzentrum der Server, Festplattenverschlüsselung +- Domain(s) und Zertifikat (Let's Encrypt oder eigenes) +- Ziel und Zuständigkeit der Backups +- Zugriff für BauIn: Internet oder VPN +- SMTP-Server und Absenderadresse diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 0a2cc5b..7ea1fe2 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -63,10 +63,11 @@ Redis wird nicht eingesetzt: Warteschlange, Cache und Sessions laufen über die | Passkeys | WebAuthn über Fortify | ergänzt | | API-Authentifizierung | – | Sanctum vorerst nicht nötig (keine eigene API) | | Verschlüsselung sensibler Werte | Verschlüsselte Casts, u. a. für Zugangsdaten der KI-API | gleich | +| Schlüssel der KI-API | Nie in `.env`: verschlüsselt in der Datenbank, in der Oberfläche nur beschreibbar; in der Entwicklung Schlüssel-Proxy mit Windows-Anmeldeinformationsverwaltung (`tools/ki-proxy`) | ergänzt | | Transport | HTTPS/TLS | gleich | | Formularschutz | CSRF, serverseitige Validierung | gleich | | Protokollierung | Verwaltungsaktionen (Upload, Rechte, Freigaben) und technische Logs | gleich | -| Geheimnisse im Code | gitleaks als Pre-Commit-Hook | ergänzt | +| Geheimnisse im Code | gitleaks als Pre-Commit-Hook, eigene Regel für API-Werk-Schlüssel (`.gitleaks.toml`) | ergänzt | | Keine externen Ressourcen | Test prüft alle Seiten auf fremde Hosts | ergänzt (Vorgabe „alles lokal“) | Rechte werden an den Zugriffswegen und an den auslösenden Aktionen geprüft, zusätzlich bei diff --git a/tools/ki-proxy/README.md b/tools/ki-proxy/README.md new file mode 100644 index 0000000..d5d9832 --- /dev/null +++ b/tools/ki-proxy/README.md @@ -0,0 +1,65 @@ +# Schlüssel-Proxy für die KI-API (nur Entwicklung) + +Der Proxy setzt die API-Schlüssel von ITM in Anfragen an `https://api-werk.de` ein. Claude Code, +Tests und die App in Sail rufen den Proxy ohne Schlüssel auf und sehen ihn nie. Die Schlüssel +liegen in der Windows-Anmeldeinformationsverwaltung. Der Proxy liest sie beim Start und hält sie +nur im Arbeitsspeicher; er protokolliert Methode, Pfad, Status und Dauer, aber keine Header. + +In Staging und Produktion gibt es keinen Proxy: Dort stehen die Schlüssel verschlüsselt in der +Datenbank (Verwaltung → Einstellungen). + +## Einmalig: Schlüssel in den Windows-Tresor eintragen + +Systemsteuerung → Anmeldeinformationsverwaltung → Windows-Anmeldeinformationen → +„Generische Anmeldeinformationen hinzufügen“: + +| Internet- oder Netzwerkadresse | Benutzername | Kennwort | +|---|---|---| +| `ki-bauin/itm-ki` | `api` | KI-Schlüssel (`zki_…`) | +| `ki-bauin/dataloader` | `api` | OpenDataLoader-Schlüssel (`zodl_…`) | +| `ki-bauin/ocr` | `api` | OCR-Schlüssel (`zocr_…`) | + +Schlüssel nie in ein Terminal, eine Datei oder den Chat mit Claude kopieren. + +## Starten (nur du, in einem eigenen Terminal) + +```bash +cd ~/code/ki-bauin +python3 tools/ki-proxy/ki_proxy.py --check # zeigt nur „gefunden“ / „FEHLT“, keine Werte +python3 tools/ki-proxy/ki_proxy.py # läuft, bis Strg+C +``` + +Nach dem Ändern eines Schlüssels den Proxy neu starten. Claude Code startet den Proxy nie selbst; +ein Hook in `~/.claude/settings.json` sperrt das zusätzlich. + +## Adressen + +| Von | Adresse | +|---|---| +| WSL (Claude Code, Skripte) | `http://127.0.0.1:8787` | +| App in Sail (Container) | `http://host.docker.internal:8787` | + +Pfade wie bei api-werk.de: `/v1/ki/…`, `/v1/dataloader/…`, `/v1/ocr/…`. Andere Pfade lehnt der +Proxy ab. Über die Netzwerkkarte der WSL und aus dem LAN ist er nicht erreichbar. + +```bash +curl -s http://127.0.0.1:8787/v1/ki/models +``` + +## Was der Proxy schützt und was nicht + +- **Geschützt:** Der Schlüssel steht in keiner Datei, keiner Umgebungsvariablen und keinem + Startbefehl. Den Speicher des Proxys können andere Prozesse nicht lesen (`ptrace_scope=1`). + Ein Hook sperrt für Claude Code Windows-Programme (`powershell.exe`, `cmdkey.exe` usw.), das + Auslesen fremder Prozesse und das Starten des Proxys. +- **Nicht geschützt:** Das ist ein Schutz vor Versehen, kein Schutz vor Absicht. Jeder Prozess + auf deinem Rechner kann den Proxy aufrufen, solange er läuft. Und der Proxy schützt die + Schlüssel, nicht die Daten: Echte Kundendaten gehen trotzdem nicht an Claude. + +## Tests + +```bash +python3 -m unittest discover -s tools/ki-proxy +``` + +Die Tests laufen gegen einen Platzhalter-Server, ohne Tresor und ohne Netz. diff --git a/tools/ki-proxy/ki_proxy.py b/tools/ki-proxy/ki_proxy.py new file mode 100644 index 0000000..af6165d --- /dev/null +++ b/tools/ki-proxy/ki_proxy.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""Schlüssel-Proxy für die KI-API von ITM (API-Werk) – nur für die Entwicklung. + +Liest beim Start die API-Schlüssel aus der Windows-Anmeldeinformationsverwaltung, +hält sie nur im Arbeitsspeicher und setzt sie in weitergeleitete Anfragen ein. +Aufrufer (Claude Code, Tests, die App in Sail) schicken Anfragen ohne Schlüssel. + +Der Proxy wird ausschließlich vom Entwickler in einem eigenen Terminal gestartet, +nie von Claude Code. Er gibt Schlüssel nie aus und protokolliert keine Header. +""" + +from __future__ import annotations + +import argparse +import base64 +import http.client +import json +import logging +import shutil +import ssl +import subprocess +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Callable, Optional +from urllib.parse import urlsplit + +# Pfadpräfix → Eintrag in der Windows-Anmeldeinformationsverwaltung +ROUTES: dict[str, str] = { + "/v1/ki/": "ki-bauin/itm-ki", + "/v1/dataloader/": "ki-bauin/dataloader", + "/v1/ocr/": "ki-bauin/ocr", +} + +DEFAULT_UPSTREAM = "https://api-werk.de" +DEFAULT_PORT = 8787 +DEFAULT_LISTEN = ("127.0.0.1", "172.17.0.1") # WSL selbst und die Docker-Brücke (Sail) +MAX_BODY_BYTES = 60 * 1024 * 1024 # Dateigrenze der API ist 50 MB +UPSTREAM_TIMEOUT = 300 + +HOP_BY_HOP = { + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailer", "trailers", "transfer-encoding", "upgrade", +} + +log = logging.getLogger("ki-proxy") + +# Liest generische Einträge über die Windows-API CredRead. Ausgabe je Eintrag: +# "\t" oder "\t-", wenn er fehlt. +_POWERSHELL_TEMPLATE = r""" +$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class KiBauinTresor { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct CREDENTIAL { + public int Flags; public int Type; public string TargetName; public string Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public int CredentialBlobSize; public IntPtr CredentialBlob; public int Persist; + public int AttributeCount; public IntPtr Attributes; public string TargetAlias; public string UserName; + } + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredRead(string target, int type, int flags, out IntPtr credential); + [DllImport("advapi32.dll")] + private static extern void CredFree(IntPtr credential); + public static string Read(string target) { + IntPtr p; + if (!CredRead(target, 1, 0, out p)) { return null; } + try { + CREDENTIAL c = (CREDENTIAL)Marshal.PtrToStructure(p, typeof(CREDENTIAL)); + if (c.CredentialBlobSize == 0) { return ""; } + return Marshal.PtrToStringUni(c.CredentialBlob, c.CredentialBlobSize / 2); + } finally { CredFree(p); } + } +} +'@ +foreach ($t in @(__TARGETS__)) { + $v = [KiBauinTresor]::Read($t) + if ($v -eq $null) { [Console]::Out.WriteLine($t + "`t-") } + else { [Console]::Out.WriteLine($t + "`t" + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($v.Trim()))) } +} +""" + + +def parse_tresor_output(output: str) -> dict[str, Optional[str]]: + """Wertet die Ausgabe des PowerShell-Skripts aus.""" + result: dict[str, Optional[str]] = {} + for line in output.splitlines(): + line = line.strip() + if not line or "\t" not in line: + continue + target, value = line.split("\t", 1) + result[target] = None if value == "-" else base64.b64decode(value).decode("utf-8") + return result + + +def read_windows_credentials(targets: list[str]) -> dict[str, Optional[str]]: + """Liest die angegebenen Einträge aus der Windows-Anmeldeinformationsverwaltung.""" + powershell = shutil.which("powershell.exe") + if powershell is None: + raise RuntimeError("powershell.exe nicht gefunden – läuft das unter WSL mit Windows-Interop?") + quoted = ", ".join("'" + t.replace("'", "''") + "'" for t in targets) + script = _POWERSHELL_TEMPLATE.replace("__TARGETS__", quoted) + encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii") + completed = subprocess.run( + [powershell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded], + capture_output=True, + timeout=60, + ) + if completed.returncode != 0: + # Die Meldung von PowerShell enthält keine Schlüssel; sie kommt in der Windows-Codepage. + detail = completed.stderr.decode("cp850", errors="replace").strip().splitlines()[:3] + raise RuntimeError("Lesen aus dem Windows-Tresor fehlgeschlagen: " + " | ".join(detail)) + found = parse_tresor_output(completed.stdout.decode("ascii", errors="replace")) + return {t: found.get(t) for t in targets} + + +class _LimitedReader: + """Liest genau `remaining` Bytes aus dem Eingangsstrom, damit der Request-Body gestreamt wird.""" + + def __init__(self, stream, length: int): + self._stream = stream + self._remaining = length + + def read(self, size: int = -1) -> bytes: + if self._remaining <= 0: + return b"" + if size < 0 or size > self._remaining: + size = self._remaining + chunk = self._stream.read(size) + self._remaining -= len(chunk) + return chunk + + +class ProxyHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.0" # Antworten enden mit dem Schließen der Verbindung; einfach für Streaming + server_version = "ki-proxy" + server: "KiProxyServer" + + def do_GET(self) -> None: + self._forward() + + def do_POST(self) -> None: + self._forward() + + def do_PUT(self) -> None: + self._forward() + + def do_PATCH(self) -> None: + self._forward() + + def do_DELETE(self) -> None: + self._forward() + + def log_message(self, format: str, *args) -> None: # noqa: A002 – Standardausgabe abschalten + return + + def _send_json(self, status: int, payload: dict) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _forward(self) -> None: + started = time.monotonic() + path_only = self.path.split("?", 1)[0] + target = next((t for prefix, t in ROUTES.items() if path_only.startswith(prefix)), None) + status = 0 + sent = 0 + try: + if target is None: + status = 404 + self._send_json(404, {"error": "Unbekannter Pfad. Erlaubt: " + ", ".join(ROUTES)}) + return + key = self.server.keys.get(target) + if not key: + status = 503 + self._send_json(503, {"error": f"Schlüssel '{target}' fehlt im Windows-Tresor. Proxy nach dem Eintragen neu starten."}) + return + if self.headers.get("Transfer-Encoding", "").lower() == "chunked": + status = 411 + self._send_json(411, {"error": "Request-Body mit Content-Length senden, nicht chunked."}) + return + length = int(self.headers.get("Content-Length") or 0) + if length > MAX_BODY_BYTES: + status = 413 + self._send_json(413, {"error": "Request zu groß für den Proxy."}) + return + + headers = { + name: value + for name, value in self.headers.items() + if name.lower() not in HOP_BY_HOP | {"authorization", "host", "content-length"} + } + headers["Authorization"] = f"Bearer {key}" + if length or self.command in ("POST", "PUT", "PATCH"): + headers["Content-Length"] = str(length) + body = _LimitedReader(self.rfile, length) if length else None + + conn = self.server.connect_upstream() + try: + conn.request(self.command, self.server.upstream_base_path + self.path, body=body, headers=headers) + response = conn.getresponse() + status = response.status + self.send_response(response.status, response.reason) + for name, value in response.getheaders(): + if name.lower() not in HOP_BY_HOP | {"server", "date"}: + self.send_header(name, value) + self.end_headers() + while True: + chunk = response.read1(65536) + if not chunk: + break + self.wfile.write(chunk) + self.wfile.flush() + sent += len(chunk) + finally: + conn.close() + except (OSError, http.client.HTTPException) as exc: + if status == 0: + status = 502 + try: + self._send_json(502, {"error": f"KI-API nicht erreichbar ({type(exc).__name__})."}) + except OSError: + pass + finally: + log.info("%s %s → %s, %d Bytes, %.0f ms", self.command, path_only, status or "-", sent, (time.monotonic() - started) * 1000) + + +class KiProxyServer(ThreadingHTTPServer): + daemon_threads = True + + def __init__(self, address: tuple[str, int], keys: dict[str, Optional[str]], upstream: str): + super().__init__(address, ProxyHandler) + self.keys = keys + parts = urlsplit(upstream) + self.upstream_scheme = parts.scheme + self.upstream_host = parts.hostname or "" + self.upstream_port = parts.port or (443 if parts.scheme == "https" else 80) + self.upstream_base_path = parts.path.rstrip("/") + self._ssl_context = ssl.create_default_context() if parts.scheme == "https" else None + + def connect_upstream(self) -> http.client.HTTPConnection: + if self.upstream_scheme == "https": + return http.client.HTTPSConnection(self.upstream_host, self.upstream_port, timeout=UPSTREAM_TIMEOUT, context=self._ssl_context) + return http.client.HTTPConnection(self.upstream_host, self.upstream_port, timeout=UPSTREAM_TIMEOUT) + + +def start_servers(hosts: list[str], port: int, keys: dict[str, Optional[str]], upstream: str) -> list[KiProxyServer]: + servers = [] + for host in hosts: + try: + server = KiProxyServer((host, port), keys, upstream) + except OSError as exc: + log.warning("Kann nicht auf %s:%d lauschen (%s) – übersprungen.", host, port, exc.strerror) + continue + threading.Thread(target=server.serve_forever, daemon=True).start() + servers.append(server) + return servers + + +def main(argv: Optional[list[str]] = None, reader: Callable[[list[str]], dict[str, Optional[str]]] = read_windows_credentials) -> int: + parser = argparse.ArgumentParser(description="Schlüssel-Proxy für die KI-API von ITM (nur Entwicklung).") + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument("--listen", default=",".join(DEFAULT_LISTEN), help="Adressen, durch Komma getrennt") + parser.add_argument("--upstream", default=DEFAULT_UPSTREAM) + parser.add_argument("--check", action="store_true", help="Nur prüfen, welche Schlüssel im Tresor stehen, dann beenden") + args = parser.parse_args(argv) + + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S") + + keys = reader(list(ROUTES.values())) + for target in ROUTES.values(): + log.info("%-22s %s", target, "gefunden" if keys.get(target) else "FEHLT") + if args.check: + return 0 if all(keys.values()) else 1 + + hosts = [h.strip() for h in args.listen.split(",") if h.strip()] + servers = start_servers(hosts, args.port, keys, args.upstream) + if not servers: + log.error("Keine Adresse verfügbar, Proxy beendet.") + return 1 + log.info("Proxy läuft auf %s → %s (Strg+C beendet)", ", ".join(f"{s.server_address[0]}:{s.server_address[1]}" for s in servers), args.upstream) + try: + while True: + time.sleep(3600) + except KeyboardInterrupt: + log.info("Proxy beendet.") + finally: + for server in servers: + server.shutdown() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/ki-proxy/test_ki_proxy.py b/tools/ki-proxy/test_ki_proxy.py new file mode 100644 index 0000000..adb52ec --- /dev/null +++ b/tools/ki-proxy/test_ki_proxy.py @@ -0,0 +1,189 @@ +"""Tests für den Schlüssel-Proxy. Ausführen: python3 -m unittest discover -s tools/ki-proxy""" + +from __future__ import annotations + +import base64 +import http.client +import json +import logging +import os +import sys +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +sys.path.insert(0, os.path.dirname(__file__)) + +import ki_proxy # noqa: E402 + +KEYS = {"ki-bauin/itm-ki": "test-ki-schluessel", "ki-bauin/dataloader": "test-dl-schluessel", "ki-bauin/ocr": None} + + +class FakeUpstream(BaseHTTPRequestHandler): + """Platzhalter für api-werk.de: merkt sich die letzte Anfrage.""" + + protocol_version = "HTTP/1.1" + last: dict = {} + release_stream = threading.Event() + + def log_message(self, format, *args): # noqa: A002 + return + + def _record(self) -> bytes: + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) if length else b"" + FakeUpstream.last = {"method": self.command, "path": self.path, "headers": dict(self.headers.items()), "body": body} + return body + + def do_GET(self): + self._record() + if self.path.startswith("/v1/ki/stream"): + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Transfer-Encoding", "chunked") + self.end_headers() + self._chunk(b"data: erster\n\n") + FakeUpstream.release_stream.wait(5) + self._chunk(b"data: [DONE]\n\n") + self.wfile.write(b"0\r\n\r\n") + return + status = 409 if self.path.startswith("/v1/dataloader/jobs/") else 200 + payload = json.dumps({"data": [{"id": "chat"}, {"id": "embed"}]}).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def do_POST(self): + body = self._record() + payload = json.dumps({"job_id": "abc", "bytes": len(body)}).encode() + self.send_response(202) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def _chunk(self, data: bytes) -> None: + self.wfile.write(f"{len(data):x}\r\n".encode() + data + b"\r\n") + self.wfile.flush() + + +class ProxyTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.upstream = ThreadingHTTPServer(("127.0.0.1", 0), FakeUpstream) + threading.Thread(target=cls.upstream.serve_forever, daemon=True).start() + upstream_url = f"http://127.0.0.1:{cls.upstream.server_address[1]}" + cls.proxy = ki_proxy.start_servers(["127.0.0.1"], 0, dict(KEYS), upstream_url)[0] + cls.port = cls.proxy.server_address[1] + + @classmethod + def tearDownClass(cls): + cls.proxy.shutdown() + cls.upstream.shutdown() + + def setUp(self): + FakeUpstream.last = {} + FakeUpstream.release_stream.clear() + self.logs = [] + handler = logging.Handler() + handler.emit = lambda record: self.logs.append(record.getMessage()) + ki_proxy.log.addHandler(handler) + ki_proxy.log.setLevel(logging.INFO) + self.addCleanup(ki_proxy.log.removeHandler, handler) + + def request(self, method, path, body=None, headers=None): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10) + conn.request(method, path, body=body, headers=headers or {}) + return conn, conn.getresponse() + + def test_setzt_den_schluessel_der_route_ein(self): + conn, response = self.request("GET", "/v1/ki/models") + self.assertEqual(200, response.status) + self.assertEqual(["chat", "embed"], [m["id"] for m in json.loads(response.read())["data"]]) + self.assertEqual("Bearer test-ki-schluessel", FakeUpstream.last["headers"]["Authorization"]) + conn.close() + + def test_ueberschreibt_mitgeschickte_authorization(self): + conn, response = self.request("GET", "/v1/ki/models", headers={"Authorization": "Bearer fremd"}) + response.read() + self.assertEqual("Bearer test-ki-schluessel", FakeUpstream.last["headers"]["Authorization"]) + conn.close() + + def test_upload_kommt_unveraendert_an_mit_eigenem_schluessel(self): + body = os.urandom(300_000) + headers = {"Content-Type": "multipart/form-data; boundary=x"} + conn, response = self.request("POST", "/v1/dataloader/jobs?x=1", body=body, headers=headers) + self.assertEqual(202, response.status) + self.assertEqual(len(body), json.loads(response.read())["bytes"]) + self.assertEqual(body, FakeUpstream.last["body"]) + self.assertEqual("/v1/dataloader/jobs?x=1", FakeUpstream.last["path"]) + self.assertEqual("Bearer test-dl-schluessel", FakeUpstream.last["headers"]["Authorization"]) + conn.close() + + def test_reicht_fehlerstatus_durch(self): + conn, response = self.request("GET", "/v1/dataloader/jobs/abc/result?format=markdown") + self.assertEqual(409, response.status) + response.read() + conn.close() + + def test_fehlender_schluessel_ergibt_503_ohne_weiterleitung(self): + conn, response = self.request("GET", "/v1/ocr/jobs/abc") + self.assertEqual(503, response.status) + self.assertIn("ki-bauin/ocr", json.loads(response.read())["error"]) + self.assertEqual({}, FakeUpstream.last) + conn.close() + + def test_unbekannter_pfad_ergibt_404(self): + conn, response = self.request("GET", "/admin") + self.assertEqual(404, response.status) + response.read() + self.assertEqual({}, FakeUpstream.last) + conn.close() + + def test_streaming_kommt_sofort_an(self): + conn, response = self.request("GET", "/v1/ki/stream") + self.assertEqual(200, response.status) + first = response.read1(1024) + self.assertIn(b"erster", first) # kommt an, bevor der Platzhalter den Rest freigibt + self.assertNotIn(b"[DONE]", first) + FakeUpstream.release_stream.set() + rest = response.read() + self.assertIn(b"[DONE]", rest) + conn.close() + + def test_protokoll_enthaelt_keine_schluessel(self): + for path in ("/v1/ki/models", "/v1/ocr/x", "/unbekannt"): + conn, response = self.request("GET", path, headers={"Authorization": "Bearer fremd"}) + response.read() + conn.close() + self.assertTrue(self.logs) + for line in self.logs: + for secret in ("test-ki-schluessel", "test-dl-schluessel", "fremd", "Bearer"): + self.assertNotIn(secret, line) + + +class TresorAusgabeTest(unittest.TestCase): + def test_wertet_gefundene_und_fehlende_eintraege_aus(self): + wert = base64.b64encode("zki_Äbc123".encode()).decode() + output = f"ki-bauin/itm-ki\t{wert}\r\nki-bauin/ocr\t-\r\n" + self.assertEqual({"ki-bauin/itm-ki": "zki_Äbc123", "ki-bauin/ocr": None}, ki_proxy.parse_tresor_output(output)) + + def test_check_meldet_fehlende_schluessel_ohne_werte_auszugeben(self): + logs = [] + handler = logging.Handler() + handler.emit = lambda record: logs.append(record.getMessage()) + ki_proxy.log.addHandler(handler) + try: + code = ki_proxy.main(["--check"], reader=lambda targets: dict(KEYS)) + finally: + ki_proxy.log.removeHandler(handler) + self.assertEqual(1, code) + joined = "\n".join(logs) + self.assertIn("FEHLT", joined) + self.assertNotIn("test-ki-schluessel", joined) + + +if __name__ == "__main__": + unittest.main()