visionA/docker/nginx.stage.conf
jim800121chen 838d10b084 fix(stage): nginx 加 /preset-models/ 反代到 api-server(B8 stage 修正)
preset .nef 公用下載端點 GET /preset-models/{id}.nef 由 api-server :3721
串流。nginx.stage.conf 原本無此 location → 落 catch-all 反代到 frontend
:3000 → 404。

加 /preset-models/ location(在 /storage 後、catch-all 前),比照 /storage
pattern:proxy_buffering off 串流大檔、X-Forwarded-Proto https、安全 header
re-add、cache public max-age=86400(preset 公用內容固定)。nginx -t 通過。

配套(同一交付):Dockerfile.stage preset COPY(1d4977a)+ .env.stage 設
VISIONA_PRESET_BASE_URL(git-ignored 不進)。stage e2e 驗證:
GET /preset-models/kl520-yolov5-detection.nef → 200 + 7506224 bytes(與原檔一致)。

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

349 lines
16 KiB
Plaintext
Raw 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;
}
# ============================================================
# 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;
}
}