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