Schlüssel-Proxy, Server-Anforderungen und gitleaks-Regel für API-Werk
- tools/ki-proxy: setzt API-Schlüssel aus der Windows-Anmeldeinformationsverwaltung ein, nur im Speicher, ohne Header-Protokoll; Tests gegen Platzhalter-Server - docs/server-anforderungen.md für den ITM-Entwickler - .gitleaks.toml erkennt zki_/zodl_/zocr_-Schlüssel - CLAUDE.md, Plan, Tech-Stack: Schlüssel nie in .env, in der App verschlüsselt in der DB Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6d6f8f8f27
commit
ca09d23a78
@@ -36,3 +36,7 @@ yarn-error.log
|
|||||||
/boost.json
|
/boost.json
|
||||||
/opencode.json
|
/opencode.json
|
||||||
/opencode.jsonc
|
/opencode.jsonc
|
||||||
|
|
||||||
|
# Python (tools/)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|||||||
@@ -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_"]
|
||||||
@@ -36,7 +36,7 @@ testet im Browser und liest nicht jede Zeile. Deshalb:
|
|||||||
## Harte Vorgaben
|
## Harte Vorgaben
|
||||||
|
|
||||||
- **Alles lokal.** Keine Skripte, Fonts, Bilder oder APIs von fremden Hosts. Einzige Ausnahme
|
- **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.
|
||||||
- `<x-ts-avatar>` nur mit `text` (Initialen), nie mit `model`, `gravatar` oder Bild-URL –
|
- `<x-ts-avatar>` nur mit `text` (Initialen), nie mit `model`, `gravatar` oder Bild-URL –
|
||||||
sonst lädt TallStackUI von ui-avatars.com bzw. gravatar.com.
|
sonst lädt TallStackUI von ui-avatars.com bzw. gravatar.com.
|
||||||
- `<x-ts-reaction>` nicht verwenden (lädt Emojis von Google).
|
- `<x-ts-reaction>` 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
|
in Controllern und Livewire-Aktionen, bei Downloads, bei Suchtreffern und bevor Inhalte an
|
||||||
ein Sprachmodell gehen. Die Oberfläche auszublenden reicht nicht.
|
ein Sprachmodell gehen. Die Oberfläche auszublenden reicht nicht.
|
||||||
- **Dateien privat speichern** (Disk `local`) und nur über eine Route mit Rechteprüfung ausliefern.
|
- **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
|
- **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.
|
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.
|
Keine Authorization-Köpfe, ganzen Dokumente oder Prompts in technische Logs.
|
||||||
|
|||||||
+4
-2
@@ -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 | – |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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**
|
**KW 41**
|
||||||
- [ ] Fragenkatalog an BauIn (fachlich, IT) und ITM schicken
|
- [ ] 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
|
- [ ] 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
|
- [ ] 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):
|
- [ ] **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
|
Indexierungszeit, Antwortzeit mit Filtern, RAM und Platte; jeweils ohne und mit binärer Quantisierung
|
||||||
|
|||||||
@@ -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/<zeitstempel>/ 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://<domain>/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
|
||||||
+2
-1
@@ -63,10 +63,11 @@ Redis wird nicht eingesetzt: Warteschlange, Cache und Sessions laufen über die
|
|||||||
| Passkeys | WebAuthn über Fortify | ergänzt |
|
| Passkeys | WebAuthn über Fortify | ergänzt |
|
||||||
| API-Authentifizierung | – | Sanctum vorerst nicht nötig (keine eigene API) |
|
| 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 |
|
| 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 |
|
| Transport | HTTPS/TLS | gleich |
|
||||||
| Formularschutz | CSRF, serverseitige Validierung | gleich |
|
| Formularschutz | CSRF, serverseitige Validierung | gleich |
|
||||||
| Protokollierung | Verwaltungsaktionen (Upload, Rechte, Freigaben) und technische Logs | 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“) |
|
| 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
|
Rechte werden an den Zugriffswegen und an den auslösenden Aktionen geprüft, zusätzlich bei
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
|
# "<eintrag>\t<base64 des Werts in UTF-8>" oder "<eintrag>\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())
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user