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:
Christoph
2026-10-05 08:25:30 +02:00
co-authored by Claude Opus 5.5
parent 6d6f8f8f27
commit ca09d23a78
9 changed files with 716 additions and 4 deletions
+4
View File
@@ -36,3 +36,7 @@ yarn-error.log
/boost.json
/opencode.json
/opencode.jsonc
# Python (tools/)
__pycache__/
*.pyc
+11
View File
@@ -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_"]
+9 -1
View File
@@ -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.
- `<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.
- `<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
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.
+4 -2
View File
@@ -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
+132
View File
@@ -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
View File
@@ -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
+65
View File
@@ -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.
+300
View File
@@ -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())
+189
View File
@@ -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()