visionA/docker/nginx.stage.conf
jim800121chen 81a32a5bf8 feat(stage): nginx 加 /ws/ location(推論 inference WS + 其餘 /ws/* stub)
兩個 server block(公網 stage-9527 + 內網 IP 直連 192.168.0.130)各加
一條 location /ws/ → proxy_pass api-server(:3721)。缺這條時 /ws/* 會落到
catch-all → Next.js → 404,擋住前端推論頁 WS 握手。

- WS upgrade 三要素(http/1.1 + Upgrade + Connection $connection_upgrade)
- 長 timeout 86400s + proxy_buffering off(比照 /tunnel/connect)
- 不影響既有 /tunnel/connect(不同 path 前綴、互不 shadow)
- 兩 block header 對齊(含 X-Forwarded-Host)

驗證:/ws/devices/test-id/inference 不帶 cookie → 401(過 backend auth)
不再 404;/tunnel/connect 回歸 401;preset 下載 200/206;demo 設定保留。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 06:00:59 +08:00

583 lines
26 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# visionA — stage 環境 nginx 設定
#
# 角色定位:
# 公司 host nginx ── HTTPS :9527LE 證書) ──→ 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 HTTPSstage-9527.innovedus.com:9527
# 但 reverse proxy 把原始 Host header 透傳進來。為避免 backend 拿到偽造 Host
# 來組 absolute URLreturn_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 跳過白名單
#
# ⚠️ 為什麼這層不能用 server-level `return 444`(修正前的 bug
# server context 的 `return` 在 nginx rewrite phase 執行會「先於」location
# 匹配短路掉所有請求 —— 包含這個 `location = /healthz`。修正前 default_server
# 結尾寫 `return 444;`,導致 docker healthcheckHost: localhost、來源 127.0.0.1
# 的 /healthz 也被打成 444 → container 長期 unhealthyfalse 444
# 正解:把 catch-all 444 收進 `location /`,讓 exact-match `location = /healthz`
# 依 nginx location 優先序勝出。
location = /healthz {
allow 127.0.0.0/8;
allow ::1/128;
deny all;
access_log off;
# 直接內部回 200不轉到 api-server淺層只證明 nginx 活著)
return 200 "ok\n";
add_header Content-Type "text/plain" always;
}
# 其他任何 host header 不符合白名單 → 444 close不回 response 給攻擊者反饋)
# 用 location / 包起來(而非 server-level return才不會 shadow 掉上面的 /healthz。
location / {
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;
# 安全 headersnginx 已有公司 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;
# 注意:不在這層加 HSTSHTTPS termination 在公司 host nginx由那層加
# ============================================================
# 健康檢查(淺層)— 不打到 backend
# 用途「nginx 程序活著」的最廉價證明。docker healthcheck走 default_server
# 那條)與「只想確認反代層在線」的外部探針用這條。
# ⚠️ 注意:這條「不」反映 DB / Redis 健康 —— load balancer 若要在 DB 掛掉時
# 把本實例踢出輪替,必須打下面的 /healthz/deep不能打這條。
# ============================================================
location = /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
# ============================================================
# 健康檢查(深層)— proxy 到 backend /healthz會 ping Postgres + Redis
# 用途load balancer / 監控的 readiness 探針。
# - PG + Redis 都健康 → 200 {"status":"ok","checks":{"postgres":"ok","redis":"ok"}}
# - 任一依賴 ping 失敗 → 503 {"status":"unavailable","checks":{...:"down"}}
# 讓上游 LB 在 DB 掛掉時把本實例拉出輪替,而非繼續送流量進來碰 503 / 假資料。
#
# 設計取捨:
# - proxy_pass 改寫 path → 後端命中的是 /healthz後端只實作這一個健康端點
# - timeout 全部壓短2s健康探針不該 hangbackend 自身 ping 逾時也是 2s
# 這層再加一道 nginx 短逾時,避免單一探針卡住 worker。
# - access_log off高頻探針不洗版 access log與淺層一致
# - 不繼承 server-level 的 proxy_read_timeout 3600s那是給長連線用的
# ============================================================
location = /healthz/deep {
access_log off;
proxy_pass http://visiona_api/healthz;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
# 健康探針要快、不該 hang —— 壓短所有 timeout覆蓋 server-level 3600s
proxy_connect_timeout 2s;
proxy_read_timeout 2s;
proxy_send_timeout 2s;
# 探針結果不可被任何中間層 cache
proxy_no_cache 1;
proxy_cache_bypass 1;
add_header Cache-Control "no-store" always;
}
# ============================================================
# /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 已 terminationcontainer 收到的是 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;
}
# ============================================================
# /preset-models/* → api-server :3721 (B8 預設模型 .nef 公用下載)
#
# 7 個系統預設模型 .nef 由 api-server 直接讀 image 內 /app/assets/preset-models
# 串流GET /preset-models/{id}.nef不簽 token、preset 公用)。
#
# ⚠️ 為什麼必須有這條 location
# 下方 catch-all `location /` 反代到 Next.js frontend (:3000)。若沒有這條,
# /preset-models/* 會落到 frontend → Next 沒有此 route → 404preset 下載斷掉。
# 必須在 catch-all 之前用前綴 location 攔下來,導到 api-server。
#
# 比照 /storage/ pattern同為 api-server 大檔下載端點):
# - proxy_buffering off.nef 1-13MB 二進位,串流直送、不在 nginx 全 buffer 後才回
# - X-Forwarded-Proto httpsbackend 組對外 absolute URL 時判斷正確
# - server-level client_max_body_size 100M 是「上傳」上限,對下載無影響
# - cachepreset 公用、內容固定baked 進 imageid 對應檔不變)→ 允許瀏覽器
# 快取 1 天減少重複下載大檔;非用戶私有資料、無 PII不需 no-store。
# ============================================================
location /preset-models/ {
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 "";
# preset 公用、內容固定 → 可被瀏覽器快取(非敏感、非用戶私有)
expires 1d;
add_header Cache-Control "public, max-age=86400" 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-lived24h 心跳級別);拉到 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;
}
# ============================================================
# /ws/* → api-server :3721 (推論 inference WS + 既有 pairing/events/system WS)
#
# ⚠️ 為什麼必須有這條 location
# 下方 catch-all `location /` 反代到 Next.js frontend (:3000)。若沒有這條,
# /ws/devices/:id/inference 等所有 /ws/* 會落到 frontend → Next 沒有此 route
# → 404前端推論頁的 WS 握手斷掉。必須在 catch-all 之前用前綴 location 攔下,
# 導到 api-server (:3721),該處掛 wsAuthGroup/ws/devices/:id/inference 需 auth
# 與 registerWebSocketStubs其餘 /ws/* 目前 501 stub
#
# 與 /tunnel/connect 的關係:兩者是不同 path 前綴(/ws/ vs /tunnel/connect
# nginx 前綴 location 各自最長匹配、互不 shadow。/ws/ 不會吃到 /tunnel/connect。
#
# WS upgrade 三要素 + 長 timeout + buffering off比照 /tunnel/connect。
# $connection_upgrade 繼承自 http-context map檔頭 38-41 行)。
# cookie 由 proxy_pass 預設透傳inference WS 走 cookie/session auth
# ============================================================
location /ws/ {
proxy_pass http://visiona_api;
proxy_http_version 1.1;
# WebSocket upgrade headers
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# 推論 WS 為 long-lived 串流;拉長 timeout比照 /tunnel/connect
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;
proxy_set_header X-Forwarded-Host $host;
}
# ============================================================
# 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;
}
}
# ============================================================
# 內網 IP 直連 server blockdemo 用)
# ------------------------------------------------------------
# 目的:讓 visionA Agent / 內網瀏覽器以 http://192.168.0.130:9527
# 直連 container繞過公網邊界 proxy那層剝 WS upgrade header、
# 碰不到 /tunnel/connect
#
# 為什麼安全(見 spec §4 完整評估):
# - 公網邊界 proxy 進來的 Host 一律被改寫成 stage-9527.innovedus.com
# → 永遠走上面那個 block外部偽造 Host:192.168.0.130 也打不進這條。
# - 這條只有「能直接連到 130:9527 的內網來源」可命中。
# - default_server 仍是 server_name _ 那條,本 block 不加 default_server。
#
# DRY 取捨:務實複製 stage-9527 block 的 location。WS upgrade map
# $connection_upgrade 在 http context 定義(檔頭 38-41 行),本 block 直接繼承。
#
# X-Forwarded-Proto實際是 http 進來,但 backend 不讀 XFP已 code 確認),
# 設 http 或 https 都不影響。這裡設 http 以符合實際(避免 backend 未來
# 萬一改讀 XFP 時組出錯誤的 https absolute URL
#
# ⚠️ demo 專用,正式上線前應移除,或改用 allow <內網網段>; deny all; 限制來源。
# ============================================================
server {
listen 80;
listen [::]:80;
server_name 192.168.0.130;
# 與 stage-9527 block 對齊的全域行為
client_max_body_size 100M;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
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;
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;
# ── 淺層健康檢查 ──
location = /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
# ── /api/* → api-server :3721含 /api/pairing/exchange、models、devices──
location /api/ {
proxy_pass http://visiona_api;
proxy_http_version 1.1;
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 http;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Connection "";
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;
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 :3721presigned 下載)──
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 http;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Connection "";
proxy_no_cache 1;
proxy_cache_bypass 1;
add_header Cache-Control "private, no-store" always;
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;
}
# ── /preset-models/* → api-server :3721B8 預設模型 .nef──
location /preset-models/ {
proxy_pass http://visiona_api;
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto http;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Connection "";
expires 1d;
add_header Cache-Control "public, max-age=86400" always;
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最關鍵──
# 完整複製 stage-9527 block 的 WS 設定http/1.1 + Upgrade + Connection
# + 86400s timeout + buffering off。$connection_upgrade 繼承自 http-context map。
location /tunnel/connect {
proxy_pass http://visiona_tunnel;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
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 http;
}
# ── /ws/* → api-server :3721推論 inference WS + 其餘 /ws/* stub──
# 完整比照 stage-9527 block 的 /ws/ 設定http/1.1 + Upgrade + Connection
# + 86400s timeout + buffering off。$connection_upgrade 繼承自 http-context map。
# 不會 shadow 上面的 /tunnel/connect不同 path 前綴)。
location /ws/ {
proxy_pass http://visiona_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
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 http;
proxy_set_header X-Forwarded-Host $host;
}
# ── Next.js hashed static內網瀏覽器用──
location /_next/static/ {
proxy_pass http://visiona_frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# ── 其他靜態資源 ──
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;
}
# ── catch-all → Next.js frontend :3000內網瀏覽器用──
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 http;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
}
}