新增雲端版部署設定(Phase 0.6 dev + Phase 0.7 stage 分兩套): dev 環境(docker-compose.dev.yml): - 5 service all-in-one(postgres + member-center + visionA-backend + frontend + Caddy) - Caddy 自動 HTTPS for localhost - .env.dev.example 範本(使用者拷出 .env.dev 後 docker compose up -d) - Makefile dev-with-mc 9 個 target stage 環境(docker-compose.stage.yml + docker/Dockerfile.stage): - multi-stage build(node22 frontend + go1.26 backend × 2 + nginx-alpine runtime) 最終 image 319 MB,含 nginx + nodejs + tini + bash - entrypoint.stage.sh 4 process 共命運(nginx + api-server + remote-proxy + next.js standalone)用 wait -n + SIGTERM trap - nginx.stage.conf:白名單 server_name stage-9527.innovedus.com + 444 default_server + /healthz 例外(127.0.0.0/8 only)+ /api/ 與 /storage/ 強制 no-store + /tunnel/connect WS upgrade + 100M body / 3600s timeout - 對外 mapping 0.0.0.0:9527:80(公司 host nginx 在外層處理 HTTPS termination — Let's Encrypt stage-9527.innovedus.com 自動續簽) - named volume visiona-data(不用 bind mount,因 stage docker daemon 在 host root 無 mkdir 權限) 部署腳本(scripts/deploy-stage.sh): - 仿 edge-ai-platform/scripts/deploy-docker.sh 早期 save/load 模式 - 為什麼不用 internal registry:公司 192.168.0.130:5000 開了 auth、無帳密 - 流程:buildx --load → docker save | gzip → DOCKER_HOST docker load → compose up - 含 --rollback <tag> / --skip-build / --no-push / --skip-deploy 選項 - timestamp + git SHA tag 留 rollback 餘地 文件(docs/): - DEV-SETUP.md:dev 環境一鍵起步驟 - SMOKE-TEST.md:手動煙測 checklist(OIDC flow / pairing / tunnel) - STAGE-DEPLOY.md:stage 完整手冊(架構圖 / 環境前置 / 部署 step / rollback / 7 種故障排除 / 緊急救回 POC) .env.stage.example 對齊 backend A1 改造: - VISIONA_OIDC_CLIENT_SECRET 留空(PKCE-only public client) - VISIONA_OIDC_SERVICE_CLIENT_ID/_SECRET 留空(Phase 1 預留鉤子) - 所有 secret 用 placeholder(CHANGE_ME_OPENSSL_RAND_HEX_32) .dockerignore:避免 node_modules / .next / .git 等進 build context Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
263 lines
11 KiB
Plaintext
263 lines
11 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 跳過白名單
|
||
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;
|
||
}
|
||
}
|