deer-flow/docker/nginx/nginx.conf
Yufeng He 37598c45ee
fix(nginx): canonicalize the loopback origin onto localhost (#6158)
* fix(nginx): canonicalize the loopback origin onto localhost

localhost and 127.0.0.1 (or [::1]) are the same loopback listener but
separate cookie jars to a browser, so an admin who sets up on one spelling
lands on /login when they later open the other, with no hint why (#6156).
Cookies cannot bridge the gap: no Domain attribute spans both hosts.

All three nginx configs now map the numeric spellings to "localhost", the
name every banner and doc already prints, and the main server answers a 301
to the same URL on the canonical host. The published port survives because
the map keys on $http_host and reattaches the captured port; Docker's
arbitrary host-port mapping is unaffected. Safe methods only: GET/HEAD
redirect, while API POSTs pass through untouched, since a redirected POST
strands clients that do not re-send bodies. Non-loopback Hosts (LAN names,
ingress domains) never match, and the canonical target itself is not a
match, so there is no redirect loop.

The new suite parses both maps out of each config and evaluates the full
method x host matrix against nginx semantics, including the suffix-spoof
and redirect-loop cases, and pins the maps identical across the Docker,
local-dev, and Helm configs so they cannot drift.

Fixes #6156

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* docs: keep the loopback note in the nginx configs

The root AGENTS.md paragraph pushed the middlewares and sandbox
instruction chains over the 96 KiB hard limit. The nginx configs
already carry the full explanation next to the maps, so drop the
duplicated paragraph instead of shrinking the budgets.

* fix(nginx): let WebSocket upgrades bypass the loopback canonical redirect

willem-bd caught this running the stack locally: the canonicalization map
keyed on method:host alone, so a GET with an Upgrade header got the same
301 as a plain GET. WebSocket clients do not follow redirects, so every
WS handshake against a 127.0.0.1/[::1] spelling died at the redirect.

Key the origin map on method:upgrade:host instead. Handshakes carry a
non-empty Upgrade value, fall off the GET::/HEAD:: patterns, and proxy
through untouched. Plain GET/HEAD behavior is unchanged.

---------

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
2026-10-02 19:06:23 +08:00

384 lines
16 KiB
Nginx Configuration File

events {
worker_connections 1024;
}
pid /tmp/nginx.pid;
http {
# Basic settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Compress textual delivery only. SSE and already-compressed media are
# deliberately absent because buffering/compression hurts their latency.
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_min_length 1024;
gzip_comp_level 5;
gzip_types
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;
# Logging
access_log /dev/stdout;
error_log /dev/stderr;
# Docker internal DNS (for resolving k3s hostname)
resolver 127.0.0.11 valid=10s ipv6=off;
# Preserve an upstream proxy's X-Forwarded-Proto so the Gateway sees the real
# client scheme when DeerFlow's nginx runs BEHIND another TLS-terminating
# reverse proxy (e.g. Pangolin/Traefik, Cloudflare, Caddy). Without this the
# Gateway treats HTTPS browser traffic as HTTP and rejects the login POST with
# 403 "Cross-site auth request denied" (Origin scheme mismatch), and also drops
# the Secure flag / max-age on session cookies. Falls back to $scheme when nginx
# is itself the TLS edge (standalone `make dev` / Docker), so default
# deployments are unchanged.
map $http_x_forwarded_proto $forwarded_proto {
default $scheme;
"~*^https" https;
}
# Only mark frontend requests as connection upgrades when the browser
# actually requested one. Next.js dev HMR also uses long-lived HTTP
# streams, and forcing "Connection: upgrade" on those requests makes
# nginx treat the upstream response as an invalid upgrade handshake.
map $http_upgrade $connection_upgrade {
default upgrade;
'' '';
}
# Loopback has two spellings and browsers scope cookies per host, so
# serving both "localhost" and "127.0.0.1" (or "[::1]") splits the login
# session into separate jars (#6156). Canonicalize on "localhost", the
# spelling every banner and doc prints, keeping the caller's port.
map $http_host $loopback_canonical_host {
default "";
"127.0.0.1" "localhost";
"~*^127\.0\.0\.1:(?<loopback_port>\d+)$" "localhost:$loopback_port";
"[::1]" "localhost";
"~*^\[::1\]:(?<loopback_port>\d+)$" "localhost:$loopback_port";
}
# Safe methods only: redirecting an API POST would strand clients that
# do not re-send bodies across the redirect. A GET carrying an Upgrade
# header (a WebSocket handshake) must proxy straight through instead:
# WebSocket clients never follow the 301.
map $request_method:$http_upgrade:$loopback_canonical_host $loopback_origin {
default "";
"~*^GET::." $loopback_canonical_host;
"~*^HEAD::." $loopback_canonical_host;
}
# ── Main server (path-based routing) ─────────────────────────────────
server {
listen 2026 default_server;
listen [::]:2026 default_server;
server_name _;
# Canonicalize the loopback origin (#6156); see the maps above.
if ($loopback_origin != "") {
return 301 $scheme://$loopback_origin$request_uri;
}
# Resolve Docker service names at request time to avoid stale upstream
# IPs when containers restart and receive new addresses.
set $gateway_upstream gateway:8001;
set $frontend_upstream frontend:3000;
# Default proxy settings for all locations (streaming/SSE support)
proxy_buffering off;
proxy_cache off;
# Keep the unified nginx endpoint same-origin by default. When split
# frontend/backend or port-forwarded deployments need browser CORS,
# configure the Gateway allowlist with GATEWAY_CORS_ORIGINS so CORS and
# CSRF origin checks stay aligned instead of approving every origin at
# the proxy layer.
# LangGraph-compatible API routes served by Gateway.
# Rewrites /api/langgraph/* to /api/* before proxying to Gateway.
location /api/langgraph/ {
rewrite ^/api/langgraph/(.*) /api/$1 break;
proxy_pass http://$gateway_upstream;
proxy_http_version 1.1;
# Headers
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
proxy_set_header Connection '';
# SSE/Streaming support
proxy_set_header X-Accel-Buffering no;
# Long chat/text-prompt support (issue #3952): a sufficiently long
# pasted prompt otherwise exceeds nginx's default 1m
# client_max_body_size, or gets spooled to a temp file via
# proxy_request_buffering before reaching Gateway -- on a non-root
# local run that temp directory may not be writable, producing a
# raw nginx 500 instead of a graceful application error. Mirrors
# the uploads location's fix below (same mechanism, sized for text
# prompts rather than binary file uploads).
client_max_body_size 20M;
proxy_request_buffering off;
# Timeouts for long-running requests
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
# Chunked transfer encoding
chunked_transfer_encoding on;
}
# Custom API: Models endpoint
location /api/models {
proxy_pass http://$gateway_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
}
# Custom API: Memory endpoint
location /api/memory {
proxy_pass http://$gateway_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
}
# Custom API: MCP configuration endpoint
location /api/mcp {
proxy_pass http://$gateway_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
}
# Admin-only local .skill archive upload from Settings.
location = /api/skills/install/upload {
proxy_pass http://$gateway_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
client_max_body_size 101M;
proxy_request_buffering off;
proxy_read_timeout 600s;
}
# Custom API: Skills configuration endpoint
location /api/skills {
proxy_pass http://$gateway_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
# Installing a .skill archive runs one LLM security scan per file, and a
# custom-skill edit or rollback runs one more; none sets its own timeout.
# The upload endpoint above already allows 600s for the same work.
proxy_read_timeout 600s;
}
# Custom API: Agents endpoint
location /api/agents {
proxy_pass http://$gateway_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
}
# Custom API: Uploads endpoint
location ~ ^/api/threads/[^/]+/uploads {
proxy_pass http://$gateway_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
# Large file upload support
client_max_body_size 100M;
proxy_request_buffering off;
# Disable response buffering to avoid permission errors
}
# Custom API: Project documents (multipart upload, promote-from-thread,
# attach-to-thread, content download, delete). Without its own location
# the upload fell through to /api/ and nginx's 1m default rejected
# anything larger with a 413 before Gateway saw it, although Gateway
# allows uploads.max_file_size (50 MiB by default) on this route. Same
# ceiling and streaming settings as the thread uploads route above; the
# read timeout keeps what /api/ already granted these routes.
location ~ ^/api/projects/[^/]+/documents {
proxy_pass http://$gateway_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
client_max_body_size 100M;
proxy_request_buffering off;
proxy_read_timeout 600s;
}
# Live browser stream is a WebSocket upgrade. It must be matched before
# the generic /api/threads regex below (which omits Upgrade/Connection
# forwarding and would downgrade it to plain HTTP).
location ~ ^/api/threads/[^/]+/browser/stream {
proxy_pass http://$gateway_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_cache_bypass $http_upgrade;
# Long-lived Live stream: keep the upgraded connection open.
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
}
# Custom API: Other endpoints under /api/threads
location ~ ^/api/threads {
proxy_pass http://$gateway_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
# /compact and /suggestions hold the response open for a model call,
# /runs/wait for a whole run. nginx's 60s default would 504 them mid-work:
# the compaction still commits, and the waited run is cancelled.
proxy_read_timeout 600s;
}
# API Documentation: Swagger UI
location /docs {
proxy_pass http://$gateway_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
}
# API Documentation: ReDoc
location /redoc {
proxy_pass http://$gateway_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
}
# API Documentation: OpenAPI Schema
location /openapi.json {
proxy_pass http://$gateway_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
}
# Health check endpoint (gateway)
location /health {
proxy_pass http://$gateway_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
}
# ── Provisioner API (sandbox management) ────────────────────────
# Use a variable so nginx resolves provisioner at request time (not startup).
# This allows nginx to start even when provisioner container is not running.
location /api/sandboxes {
set $provisioner_upstream provisioner:8002;
proxy_pass http://$provisioner_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
}
# Catch-all for /api/ routes not covered above (e.g. /api/v1/auth/*).
# More specific prefix and regex locations above still take precedence.
location /api/ {
proxy_pass http://$gateway_upstream;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
# Disable buffering to avoid permission errors when nginx
# runs as a non-root user (e.g. local development).
# /api/runs/wait blocks on a whole run and /api/input-polish on a model
# call. nginx's 60s default would 504 them mid-work, and the waited run is
# cancelled on the disconnect.
proxy_read_timeout 600s;
}
# All other requests go to frontend
location / {
proxy_pass http://$frontend_upstream;
proxy_http_version 1.1;
# Headers
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_cache_bypass $http_upgrade;
# Timeouts
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
}
}
}