Hinter einem Reverse Proxy betreiben¶
Blunderbase ist ein Prozess auf einem Port. Die Seite, die JSON-API, das WebSocket unter
/events und der Transport unter /mcp kommen alle aus derselben Origin. Ein Proxy davor
hat genau einen Grund: TLS. Das Sitzungs-Cookie trägt Secure auf jedem Host, der nicht
Loopback ist – eine Installation, die über einfaches HTTP erreicht wird, hält dich also
nicht angemeldet.
Veröffentliche den Port des Containers nur auf Loopback und lass den Proxy ihn dort erreichen.
Drei Regeln¶
Alles Weitere ist diese drei Regeln, zweimal ausgeschrieben, einmal je Proxy.
- Reich
Authorizationunverändert durch. Er ist die gesamte Authentifizierung von/mcp. Ein Proxy, der den Header für seine eigene Auth verbraucht oder ihn löscht, macht aus jeder MCP-Anfrage eine 401. - Puffere
/mcpund/eventsnicht./mcpantwortet mit Streamable HTTP – einemtext/event-stream, der offen bleibt – und/eventsist ein WebSocket. Ein Proxy, der erst die ganze Antwort einsammelt, bevor er sie weiterreicht, reicht nie eine weiter. - Leite
/mcpnicht um. Es ist ein einzelner Pfad, kein Verzeichnis: kein Rewrite, kein abschließender Schrägstrich, kein301oder307auf/mcp/. Ein MCP-Client, der JSON-RPC postet, hat keinen Grund, einer Umleitung zu folgen, und die meisten tun es nicht.
Caddy¶
blunderbase.example.com {
# Der MCP-Transport und das Event-Socket der Oberfläche streamen beide. `flush_interval -1`
# reicht jedes Byte weiter, sobald es ankommt, statt auf eine Antwort zu warten, die nie endet.
@stream path /mcp /events
reverse_proxy @stream 127.0.0.1:8765 {
flush_interval -1
}
reverse_proxy 127.0.0.1:8765
}
Das ist die ganze Datei. Caddy terminiert TLS selbst, reicht Authorization weiter und
setzt X-Forwarded-*, ohne gefragt zu werden – nach der Verbindung, die tatsächlich bei
ihm ankam; ein X-Forwarded-For, das der Client selbst mitschickt, ignoriert er –, und
reverse_proxy erfindet nie eine
Umleitung auf einen abschließenden Schrägstrich – path /mcp trifft genau /mcp und
nichts sonst.
Läuft der Proxy als Container neben diesem, wird aus 127.0.0.1:8765 der Servicename:
reverse_proxy blunderbase:8765. Wenn du encode ergänzt, nimm die beiden streamenden
Pfade davon aus – ein Kompressor ist ein Puffer.
nginx¶
nginx verwirft jeden geerbten proxy_set_header, sobald eine location einen eigenen
deklariert. Der gemeinsame Satz liegt deshalb in einem Snippet, das jede location einbindet,
statt auf Server-Ebene.
/etc/nginx/snippets/blunderbase-proxy.conf:
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Ersetzen, nie anhängen: ein angehängter Header trägt vor der echten Adresse weiter, was
# der Client hineingeschrieben hat, und das Anmeldelimit zählt je Adresse.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# Standardmäßig weitergereicht; hier ausgeschrieben, damit eine Überschreibung auf
# Server-Ebene nicht stillschweigend den einen Header entfernt, mit dem sich /mcp
# authentifiziert.
proxy_set_header Authorization $http_authorization;
/etc/nginx/sites-available/blunderbase:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream blunderbase {
server 127.0.0.1:8765;
keepalive 16;
}
server {
listen 443 ssl;
http2 on;
server_name blunderbase.example.com;
ssl_certificate /etc/letsencrypt/live/blunderbase.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/blunderbase.example.com/privkey.pem;
# Ein PGN-Export von zehn Jahren Partien ist ein einziger Request-Body.
client_max_body_size 64m;
# `location =` und ein proxy_pass ohne URI-Teil: der Pfad erreicht die App genau so,
# wie er gesendet wurde, und nichts hier kann einen JSON-RPC-POST mit einer Umleitung
# auf /mcp/ beantworten.
location = /mcp {
include snippets/blunderbase-proxy.conf;
proxy_pass http://blunderbase;
# Streamable HTTP in beide Richtungen, offen gehalten, solange der Client arbeitet.
proxy_buffering off;
proxy_request_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
location /events {
include snippets/blunderbase-proxy.conf;
proxy_pass http://blunderbase;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_read_timeout 1h;
}
location / {
include snippets/blunderbase-proxy.conf;
proxy_pass http://blunderbase;
}
}
Steht nginx selbst hinter einem CDN oder einem weiteren Proxy, ist $remote_addr dieser
Proxy und nicht der Besucher. Dann gehört das real_ip-Modul von nginx dazu
(set_real_ip_from für die Adressen dieses Proxys, real_ip_header), damit
$remote_addr schon der Besucher ist, bevor es weitergereicht wird.
Nachprüfen¶
Der MCP-Endpunkt durch den Proxy ist https://<dein Host>/mcp, und der Header lautet
Authorization: Bearer <ein Schlüssel von der Seite Assistent>. Zwei
curl-Aufrufe sagen dir, ob der Proxy aus dem Weg gegangen ist.
$ curl -i -sS https://blunderbase.example.com/mcp | head -3
HTTP/2 401
www-authenticate: Bearer realm="blunderbase"
content-type: application/json
$ curl -sS -N https://blunderbase.example.com/mcp \
-H "Authorization: Bearer $BLUNDERBASE_KEY" \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":
{"protocolVersion":"2026-07-28","capabilities":{},
"clientInfo":{"name":"curl","version":"1"}}}'
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"blunderbase",…
Was die Fehler bedeuten:
| Was du siehst | Was es ist |
|---|---|
301 oder 307 auf /mcp/ |
Regel 3: ein Rewrite oder eine Umleitung auf den abschließenden Schrägstrich im Proxy |
200 mit HTML |
Die Anfrage ist beim Fallback der Web-App gelandet, nicht bei /mcp – die location trifft nicht |
401 mit einem Schlüssel, von dem du weißt, dass er stimmt |
Regel 1: Der Header kommt nicht an – oder der Schlüssel wurde unter Assistent widerrufen |
| Der zweite curl-Aufruf hängt ohne Ausgabe | Regel 2: Die Antwort wird gepuffert |
502 nach genau 60s |
Ein Read-Timeout kürzer als der längste MCP-Tool-Aufruf |
Dann trag ihn in einem Client ein:
claude mcp add --transport http blunderbase https://blunderbase.example.com/mcp \
--header "Authorization: Bearer <a key from Assistant>"
Einstellungen, die hier zählen¶
Die vollständige Liste ist Konfiguration; drei davon spielen hier eine Rolle.
BLUNDERBASE_PUBLIC_URL ist die Adresse, unter der diese Installation von außen erreicht
wird. Sie wird in die runner.yaml geschrieben, die beim Anlegen eines Runners
herauskommt; ohne sie kann der Server nur aus der Anfrage raten, die er gerade beantwortet.
Setz sie auf die URL des Proxys. Die Verbindung des Runners trägt in jedem Frame ein
Bearer-Token, sie sollte also https:// sein, und der Runner leitet wss:// daraus ab.
Siehe Remote Runner. Ein Server, der nur auf Loopback lauscht – ein
Quellcode-Checkout mit dem voreingestellten BLUNDERBASE_HOST und dem Proxy auf derselben
Maschine –, erfährt von hier auch, unter welchem Namen er durch den Proxy angesprochen
wird: /mcp weist jeden Host außer localhost und dem Host dieser URL mit
421 Invalid Host header ab.
FORWARDED_ALLOW_IPS ist eine Variable von uvicorn, keine von Blunderbase. Sie nennt die
Adressen, deren X-Forwarded-For und X-Forwarded-Proto geglaubt wird – und das sollte
genau dein Proxy sein. So erfährt die App, dass eine Anfrage über TLS kam, und auf wessen
Adresse ein fehlgeschlagener Anmeldeversuch zählt. Fehlt die Einstellung, gilt jeder
Besucher als der Proxy: ein einziges Anmeldelimit für alle, und ein Fremder, der
Passwörter rät, sperrt dich gleich mit aus. Der Standardwert 127.0.0.1 passt nur, wenn die App
direkt auf der Maschine des Proxys läuft. In einem Container kommt der Proxy nie von
127.0.0.1:
-
Proxy auf dem Host, Port des Containers auf Loopback veröffentlicht. Die Verbindung kommt vom Gateway des Docker-Netzes, in dem der Container hängt. Gib dem Netz einen festen Adressbereich, damit das Gateway nicht wandert, wenn
docker compose downdas Netz neu anlegt, und vertraue genau dieser Adresse. Jeder private Bereich, den auf der Maschine sonst nichts belegt, ist recht:services: blunderbase: ports: - "127.0.0.1:8765:8765" environment: FORWARDED_ALLOW_IPS: 10.87.65.1 networks: default: ipam: config: - subnet: 10.87.65.0/24 gateway: 10.87.65.1Mit
docker runlegst du das Netz vorher an (docker network create --subnet 10.87.65.0/24 --gateway 10.87.65.1 blunderbase) und ergänzt--network blunderbase -e FORWARDED_ALLOW_IPS=10.87.65.1. -
Proxy in einem anderen Container. Vertraue der eigenen Adresse des Proxys in dem Netz, das beide teilen, und nagle sie mit
ipv4_addressin einem Netz mit festem Bereich fest – sonst kommt ein neu angelegter Proxy unter einer anderen Adresse zurück, und seine alte bekommt irgendein anderer Container. Der Proxy erreicht Blunderbase über dieses Netz, der Port muss also gar nicht veröffentlicht sein:services: proxy: networks: default: ipv4_address: 10.87.65.10 blunderbase: # kein ports: – nur der Proxy spricht mit ihm environment: FORWARDED_ALLOW_IPS: 10.87.65.10 networks: default: ipam: config: - subnet: 10.87.65.0/24 gateway: 10.87.65.1Dem ganzen Bereich (
10.87.65.0/24) zu vertrauen geht nur gut, solange außer dem Proxy und Blunderbase niemand im Netz hängt und Blunderbase keinen Port veröffentlicht. Die mitgeliefertedocker-compose.ymlveröffentlicht8765:8765, und eine Verbindung über einen veröffentlichten Port kann vom Gateway des Netzes kommen,10.87.65.1– und das liegt in diesem Bereich: Dockers Portweiterleitung bei einer Verbindung von der Maschine selbst, ein anderer Container, der über den Host wieder hereinkommt, jeder Prozess auf dem Host. Ihnen allen würdeX-Forwarded-Forgeglaubt, und ein gefälschter Header verschafft jedem Rateversuch wieder ein frisches Anmeldelimit.
Setz sie nie auf *. Dann glaubt uvicorn der ersten Adresse in X-Forwarded-For, und
die schreibt der Client selbst hinein: Jeder Rateversuch kann sich eine neue Adresse und
damit ein frisches Limit geben. Im Server-Log steht zu jeder Anfrage, woher sie kam –
solange dem Proxy nicht vertraut wird, ist das der Proxy, danach der Besucher.
BLUNDERBASE_CROSS_ORIGIN_ISOLATION ist voreingestellt an und das Einzige hier, was ein
Proxy stillschweigend kaputt machen kann. Die Seite wird mit
Cross-Origin-Opener-Policy: same-origin und Cross-Origin-Embedder-Policy: require-corp
ausgeliefert, dem Preis, den der Browser für SharedArrayBuffer verlangt – und eine Engine
im Tab läuft ohne den auf einem Thread. Reich beide Response-Header unverändert durch,
so wie Regel 1 es für Authorization verlangt. Weder Caddy noch die nginx-Snippets oben
fassen einen Response-Header an, aber ein Proxy, der seine eigene Policy setzt oder
entfernt, was er nicht selbst gesetzt hat, nimmt die Threads weg, ohne dass irgendwo ein
Fehler auftaucht. Der Preis dafür, das anzulassen: jede Subresource aus einer fremden
Origin, die die Seite lädt, muss mit Cross-Origin-Resource-Policy zustimmen – der Build
lädt keine. Setz die Variable auf false, wenn dein Proxy diese Header umschreibt oder
wenn du ein Asset aus einer anderen Origin ergänzt hast: die Seite arbeitet dann genau wie
vorher, nur auf einem Thread.
Eine öffentliche Demo, schreibgeschützt¶
Blunderbase kann eine Bibliothek für alle ausliefern, schreibgeschützt und ohne Passwort.
Genau das ist demo.blunderbase.org. Betreib das nur auf einer Datenbank, die
blunderbase demo create gebaut hat, nie auf einer echten Bibliothek.
Im Demo-Modus steht die Tür offen und jeder Schreibzugriff ist zu: Der Wächter lässt jede
Anfrage durch, und eine zweite Prüfung weist alles außer GET, HEAD und OPTIONS mit
403 read_only ab. Ausgenommen sind drei Lesezugriffe, die als POST geschrieben sind und
keine Zeile anfassen – das Analysebrett, Maias Antwort für eine Stellung und eine einmalige
Engine-Bewertung –, damit die Partieansicht lebendig bleibt. /mcp wird gar nicht erst
eingehängt. Die Seite zeigt oben in der linken Leiste neben dem Namen einen Hinweis
Demo ↗ – sein Tooltip sagt, dass alles schreibgeschützt ist, und er führt zu
blunderbase.org – und beim ersten abgewiesenen Schreibzugriff einen Hinweis.
Zwei Lesezugriffe sind ebenfalls zu: die Datenbanksicherung und der PGN-Export der ganzen
Bibliothek. Beide kosten pro Anfrage Sekunden an Rechenzeit und liefern die komplette
Bibliothek als Datei, an jeden – eine Demo antwortet darauf mit demselben 403 read_only.
Die Bibliothek ist ohnehin erfunden; wer eine Kopie will, lässt blunderbase demo create
auf dem eigenen Rechner laufen.
Drei Schritte.
1. Bau die Bibliothek auf einem Rechner, der deine echte hat. Jede Partie kommt mit ihrer schon kopierten Analyse an, und das Ergebnis trägt keine Engine-Zeile, der Demo-Host startet also nie eine Binärdatei.
Der Befehl baut den PGN-Text ohne Kommentare neu auf und erfindet jede identifizierende Angabe; Zugangsdaten und persönliche Notizen werden nie kopiert.
2. Starte den Stack und leg die Datei in sein Volume.
Die Beispieldatei
docker-compose.demo.yml
im Repository ist die ganze Konfiguration: BLUNDERBASE_RUNTIME_MODE=demo, die Datenbank
unter /data/demo.db, die Worker aus, weil in einer schreibgeschützten Bibliothek nie etwas
eingereiht wird, ein niedrigeres BLUNDERBASE_STREAM_MAX_SESSIONS und Traefik-Labels statt
eines veröffentlichten Ports.
docker compose -f docker-compose.demo.yml up -d
docker cp demo.db blunderbase-demo:/data/demo.db
docker restart blunderbase-demo
Dieselben zwei Befehle frischen sie nach einem neueren demo create wieder auf.
3. Gib ihr einen Hostnamen und behalt die Isolations-Header. Mit einem weiteren Proxy
veröffentlichst du den Port auf Loopback und ergänzt eine Site in der Caddyfile oder der
nginx-Konfiguration von oben. Die drei Regeln gelten unverändert, und die Cross-Origin-
Isolation zählt hier mehr als irgendwo sonst: Stockfish im Browser ist die einzige Engine,
die ein Besucher haben kann. Ein Proxy, der Cross-Origin-Opener-Policy oder
Cross-Origin-Embedder-Policy entfernt, lässt das Analysebrett der Demo also ohne alles
dastehen. Ein /mcp gibt es auf einer Demo nicht, ein Proxy, der /, /api und /events
weiterreicht, reicht deshalb alles weiter, was es gibt.
Was ein Besucher den Rechner tun lassen kann, ist genauso begrenzt wie beim Besitzer:
höchstens BLUNDERBASE_STREAM_MAX_SESSIONS Analysebretter gleichzeitig, jedes
BLUNDERBASE_STREAM_IDLE_SECONDS nach dem Verschwinden seines Tabs verworfen, und einmalige
Bewertungen gedeckelt durch das Knotenbudget, das die Anfrage nennt. Jeder Schreibzugriff
wird an der Tür abgewiesen, es gibt zwischen Besuchern also nichts aufzuräumen.
Der Runner-Transport ist auf einer Demo eingehängt. Ein Runner wählt sich mit einem Token
ein, das nur du erzeugt hast, und eines zu erzeugen ist ein Schreibzugriff, den die Demo
abweist. blunderbase demo create --runners ist deshalb der Weg, einen deiner eigenen
Rechner hinter das Analysebrett der Demo zu stellen, ohne einem Besucher irgendetwas in die
Hand zu geben.