# 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 跳過白名單 # # ⚠️ 為什麼這層不能用 server-level `return 444`(修正前的 bug): # server context 的 `return` 在 nginx rewrite phase 執行,會「先於」location # 匹配短路掉所有請求 —— 包含這個 `location = /healthz`。修正前 default_server # 結尾寫 `return 444;`,導致 docker healthcheck(Host: localhost、來源 127.0.0.1) # 的 /healthz 也被打成 444 → container 長期 unhealthy(false 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; # 安全 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 # 用途:「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):健康探針不該 hang;backend 自身 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 已 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; } # ============================================================ # /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 → 404,preset 下載斷掉。 # 必須在 catch-all 之前用前綴 location 攔下來,導到 api-server。 # # 比照 /storage/ pattern(同為 api-server 大檔下載端點): # - proxy_buffering off:.nef 1-13MB 二進位,串流直送、不在 nginx 全 buffer 後才回 # - X-Forwarded-Proto https:backend 組對外 absolute URL 時判斷正確 # - server-level client_max_body_size 100M 是「上傳」上限,對下載無影響 # - cache:preset 公用、內容固定(baked 進 image,id 對應檔不變)→ 允許瀏覽器 # 快取 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-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; } } # ============================================================ # 內網 IP 直連 server block(demo 用) # ------------------------------------------------------------ # 目的:讓 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 :3721(presigned 下載)── 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 :3721(B8 預設模型 .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; } # ── 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; } }