# visionA — stage 環境 nginx 設定 # # 角色定位: # 公司 host nginx ── HTTPS :9527(LE 證書) ──→ container :80(本檔) # ↓ # ┌───────────────────────────────────────────────────────────────┐ # │ container 內 nginx :80 │ # │ /healthz → 直接回 200(給 docker healthcheck) │ # │ /api/* → 127.0.0.1:3721 (api-server) │ # │ /storage/* → 127.0.0.1:3721 (api-server presigned URL) │ # │ /tunnel/connect→ 127.0.0.1:3800 (remote-proxy WS upgrade) │ # │ /_next/static/ → 127.0.0.1:3000 (next standalone, 1y cache) │ # │ / → 127.0.0.1:3000 (next standalone server.js) │ # └───────────────────────────────────────────────────────────────┘ # # 上游 process 由 entrypoint.stage.sh 啟動,全在同一 container loopback。 # ────────── upstream 定義 ────────── # 在 server block 外層 alias,讓 proxy_pass 可重用。 upstream visiona_api { server 127.0.0.1:3721; keepalive 32; } upstream visiona_tunnel { server 127.0.0.1:3800; keepalive 16; } upstream visiona_frontend { server 127.0.0.1:3000; keepalive 32; } # WebSocket upgrade map(給 /tunnel/connect 用) # 在 http context 用,server block 內也可繼承。 map $http_upgrade $connection_upgrade { default upgrade; '' close; } # ────────── Host header 白名單(M2 — trust boundary) ────────── # # 公司 host nginx 已 termination HTTPS(stage-9527.innovedus.com:9527), # 但 reverse proxy 把原始 Host header 透傳進來。為避免 backend 拿到偽造 Host # 來組 absolute URL(return_to / email link / cache key),這層強制白名單: # # - 任何不符合 server_name 的 host(含直接打 IP、攻擊者偽造 Host)→ 444 close # - 命中 stage-9527.innovedus.com 的請求才進真正的 server block # # 444 = nginx 專屬 status,直接關連線、不回 response,不給攻擊者反饋。 server { listen 80 default_server; listen [::]:80 default_server; server_name _; # /healthz 例外:Docker healthcheck 從 container 內打 localhost/healthz # (Host: localhost 不命中 stage-9527 白名單,但內部源頭可信任) # 限制 source = 127.0.0.0/8 防止外部偽造 Host 跳過白名單 location = /healthz { allow 127.0.0.0/8; allow ::1/128; deny all; # 直接內部回 200,不轉到 api-server(避免 api-server 也要實作 /healthz) return 200 "ok\n"; add_header Content-Type "text/plain" always; } # 其他任何 host header 不符合白名單 → 444 close(不回 response 給攻擊者反饋) return 444; } server { listen 80; listen [::]:80; server_name stage-9527.innovedus.com; # ============================================================ # 全域行為 # ============================================================ # 模型上傳上限(PRD §8.4 — Phase 0 為 100 MB)。 # /api/models/* 走 multipart/form-data,這個值是上限。 client_max_body_size 100M; # 大量 long-lived 連線(tunnel WS、SSE、模型轉檔輪詢)— 拉長 read timeout proxy_read_timeout 3600s; proxy_send_timeout 3600s; # gzip — 對 JSON / JS / CSS 有效,避免重複壓縮二進位資源 gzip on; gzip_min_length 1024; gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml image/svg+xml; # 安全 headers(nginx 已有公司 host 那層 HTTPS,這層補通用安全) # X-Frame-Options 預設 SAMEORIGIN 給 iframe 防護(agent / pair view 不嵌 iframe) add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; # 注意:不在這層加 HSTS(HTTPS termination 在公司 host nginx,由那層加) # ============================================================ # 健康檢查 — 不打到 backend,docker healthcheck 用 # ============================================================ location = /healthz { access_log off; return 200 "ok\n"; add_header Content-Type text/plain; } # ============================================================ # /api/* → api-server :3721 # 包含 /api/auth/*(OIDC callback)、/api/devices、/api/models、/api/pairing 等 # ============================================================ location /api/ { proxy_pass http://visiona_api; proxy_http_version 1.1; proxy_buffering off; # 模型上傳串流 / SSE friendly # ── Proxy headers ── # X-Forwarded-Proto = https:讓 backend 產的 redirect / cookie Secure 判斷正確 # (公司 host nginx 已 termination;container 收到的是 HTTP) proxy_set_header Host $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 https; proxy_set_header X-Forwarded-Host $host; proxy_set_header Connection ""; # ── Cache 防護(M3) ── # /api/auth/me 等含 PII;/api/auth/callback 帶 code/state # 一律禁止任何中間 proxy / browser cache(含 BFCache) proxy_no_cache 1; proxy_cache_bypass 1; add_header Cache-Control "no-store, no-cache, must-revalidate, private" always; add_header Pragma "no-cache" always; # nginx add_header 在 location level 會完全覆蓋 server level(不 merge) # 因此 server level 的安全 header 必須在這裡 re-add add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; } # ============================================================ # /storage/* → api-server :3721 # presigned URL 走這條(HMAC 簽章 query string、不帶 cookie) # 雛形 LocalFS backend 的下載端點 # ============================================================ location /storage/ { proxy_pass http://visiona_api; proxy_http_version 1.1; proxy_buffering off; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Connection ""; # ── Cache 防護(M3 — 保守作法) ── # presigned URL 含短期 HMAC、理論上不可重複使用,但內容是用戶上傳模型(敏感)。 # 一律禁止中間 proxy 共用 cache,避免 query string 漏進 cache key 時被旁人取得。 # 若未來改 S3 backend + presigned 直連,這條 location 會被拆掉,屆時改 backend 自行決定 cache 策略。 proxy_no_cache 1; proxy_cache_bypass 1; add_header Cache-Control "private, no-store" always; # nginx location level 的 add_header 會完全覆蓋 server level — re-add 安全 header add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; } # ============================================================ # /tunnel/connect — WebSocket upgrade,由 visionA Agent 連入 # 要 long-lived(24h 心跳級別);拉到 86400s。 # 注意:path 必須是 /tunnel/connect(不是 /tunnel/)— remote-proxy 只開這個 endpoint # ============================================================ location /tunnel/connect { proxy_pass http://visiona_tunnel; proxy_http_version 1.1; # WebSocket upgrade headers proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; # tunnel 連線可長達數小時,且心跳由 yamux 處理;nginx 不要中途斷 proxy_read_timeout 86400s; proxy_send_timeout 86400s; # 不 buffer,避免延遲 WS 訊框 proxy_buffering off; proxy_set_header Host $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 https; } # ============================================================ # Next.js hashed static assets — 永久 cache # /_next/static/{hash}.js 等 # ============================================================ location /_next/static/ { proxy_pass http://visiona_frontend; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header Connection ""; # Hash 帶在路徑裡,內容變了路徑就變 → 可以 immutable 1 年 expires 1y; add_header Cache-Control "public, max-age=31536000, immutable" always; } # ============================================================ # 其他靜態資源(public/ 下的圖片、字型等)— 1 day cache # ============================================================ location ~* ^/(favicon\.ico|.*\.(?:png|jpg|jpeg|gif|svg|webp|ico|woff|woff2|ttf|eot))$ { proxy_pass http://visiona_frontend; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header Connection ""; expires 1d; add_header Cache-Control "public, max-age=86400" always; } # ============================================================ # 全部其他請求 → Next.js standalone server (:3000) # 包含: # - / (首頁) # - /login, /register, /account, /clusters, /devices, /devices/[id], # /devices/pair, /models, /models/[id], /workspace/[deviceId], /settings # - /_next/data/* (RSC payload) # - /_next/image (Next image optimizer,雖然 standalone 預設啟用 sharp) # 不在這裡做 SPA fallback — Next.js server 自己會處理 404 與動態 route # ============================================================ location / { proxy_pass http://visiona_frontend; proxy_http_version 1.1; proxy_set_header Host $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 https; proxy_set_header X-Forwarded-Host $host; proxy_set_header Connection ""; # SSE / streaming 友善 proxy_buffering off; proxy_cache off; } }