Zum Inhalt

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.

Browser    ─┐
            ├─▶ Proxy :443 ──▶ blunderbase :8765 ──▶ /  /api  /events  /mcp
MCP-Client ─┘

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.

  1. Reich Authorization unverä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.
  2. Puffere /mcp und /events nicht. /mcp antwortet mit Streamable HTTP – einem text/event-stream, der offen bleibt – und /events ist ein WebSocket. Ein Proxy, der erst die ganze Antwort einsammelt, bevor er sie weiterreicht, reicht nie eine weiter.
  3. Leite /mcp nicht um. Es ist ein einzelner Pfad, kein Verzeichnis: kein Rewrite, kein abschließender Schrägstrich, kein 301 oder 307 auf /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 down das 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.1
    

    Mit docker run legst 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_address in 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.1
    

    Dem 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 mitgelieferte docker-compose.yml veröffentlicht 8765: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ürde X-Forwarded-For geglaubt, 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.

blunderbase demo create --games 3000

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.