visionA/docs/autoflow/04-architecture/tunnel-ws-network-requirements.md
jim800121chen dbe5d6a78e docs(architecture): WS tunnel 網路/防火牆需求文件
整理 WS tunnel 要通需要的網路設定(正式環境交接用):
- 核心:邊界反代開 WebSocket upgrade 轉發(或 L4 TCP passthrough),
  現在剝 WS upgrade header → tunnel 400
- 配套:idle timeout≥90s、wss TLS、DNS、上線前移除 demo 繞法
- Port 清單:唯一對公網開 :9527,:3800/:3801 絕不對外
- 含 HAProxy/ALB/Cloudflare/F5 等效設定 + 驗證方法 + 回傳碼對照

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

333 lines
21 KiB
Markdown
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.

# Tunnel WebSocket 網路 / 防火牆 / 反代設定需求
> **文件目的**:讓「公網使用者的 visionA Agent 能建立 tunnel WebSocket 連線」,需要在網路層、防火牆、反向代理上做哪些設定。
> **適用對象**:網管 / 維運 / 部署交接人員(不需先懂 tunnel 內部協定)。
> **本文件與 `tunnel.md` 的分工**`tunnel.md` 講 tunnel 內部協定yamux / session / 訊息格式本文件只講「WS 連線要能穿過網路各層並成功握手,每一層要開/設什麼」。
> **最後更新**2026-07整段連線排查確認後整理供正式上線 + 交接用)
---
## 0. TL;DR給趕時間的人
正式環境要讓 tunnel 通,**最關鍵的一件事**
> **公網入口那台「邊界反向代理」必須顯式轉發 WebSocket upgrade`Connection: Upgrade` + `Upgrade: websocket`),並用 HTTP/1.1。**
> 目前它沒設,會剝掉 upgrade header導致後端收到殘缺握手回 **400 bad handshake**。這是 tunnel 建不起來的唯一根因。
其餘四件事:
1. **防火牆**:放行 agent 出站 → 公網入口 `:9527`wss/TLS
2. **Idle timeout**:邊界反代 / LB 的 idle timeout 拉長到 **≥ 90s**tunnel 是長連線,靠 10s 心跳維持)。
3. **TLS**agent 走 `wss://`公網入口憑證需有效Let's Encrypt 已有)。
4. **DNS**`stage-9527.innovedus.com` 解析到公網入口 `1.34.63.223`
---
## 1. WebSocket tunnel 的網路本質(先建立共識)
### 1.1 為什麼 tunnel 用 WebSocket
Agent 跑在使用者的內網電腦、沒有公網 IP雲端無法「主動連進去」。所以改由 **agent 主動撥一條長連線出來**,之後雲端所有要送給 agent 的請求都走這條反向通道。這條長連線用 WebSocket 承載(底層再跑 yamux 多工),因為 WebSocket
- 走標準 `443`/HTTP(S) port穿透企業防火牆的成功率最高比自訂 TCP port 好穿);
- 是雙向全雙工,雲端能主動往 agent 推資料;
- 能被大多數反代 / LB 理解。
### 1.2 為什麼 WebSocket 對反代設定「特別敏感」
WebSocket 連線是從一個**普通 HTTP 請求「升級upgrade」**而來的。這個升級靠兩個 HTTP header
```
Connection: Upgrade
Upgrade: websocket
```
這兩個是 **hop-by-hop header**——依 HTTP 規範,它們只對「當前這一跳」有效,**每一層反向代理都必須「主動、顯式」把它們再往下轉發**。反代若沒特別設定,預設行為就是**把它們吃掉不轉**。
結果:
```
agent 送Connection: Upgrade / Upgrade: websocket
→ 邊界反代沒設 WS 轉發 → 把這兩個 header 剝掉
→ 後端 remote-proxy 收到一個「沒有 upgrade 意圖」的普通 HTTP 請求
→ 無法完成 WebSocket 握手 → 回 400 Bad Requestbad handshake
```
**這就是本專案排查到的根本卡點**。實測鐵證:同一個 WS 握手agent 直連內網 `192.168.0.130:9527` 成功回 **101 Switching Protocols**;一旦改走公網邊界反代,就變 **400**。差別只在「中間那層有沒有轉發 upgrade header」。
> 白話總結:**tunnel 建不起來 99% 是某一層反代把 upgrade header 吃掉了。** 每多一層反代,就要多確認一次那層有沒有開 WS 轉發。
### 1.3 為什麼要強制 HTTP/1.1(不能 HTTP/2
`Connection` 這個 header 在 **HTTP/2 是被禁止的**h2 用不同的多工機制,沒有 hop-by-hop header 概念)。如果 agent 跟反代之間 TLS 協商ALPN談成了 h2`Connection: Upgrade` 這套 RFC 6455 的 WebSocket 握手方式根本無法運作。
因此:
- **Agent 端已修正**TLS 握手強制 ALPN 只提供 `http/1.1`,避免被協商成 h2。
- **反代端要求**:面向 backend 轉發時用 `proxy_http_version 1.1`nginx或等效設定不要用 h2 轉發 WS。
> 注意:這裡指的是「承載 WebSocket 那一跳」要用 h1.1。TLS 本身照常wss = WebSocket over TLS
---
## 2. 完整連線鏈路與每一跳的要求
### 2.1 架構拓樸
```
┌─────────────┐ wss (TLS, h1.1) ┌──────────────────────────┐ http (內網) ┌────────────────────────┐
│ visionA │ WS upgrade │ 邊界反向代理(公網入口) │ 轉發 │ container 內 nginx │
│ Agent │ ──────────────────► │ stage-9527.innovedus.com │ ──────────────► │ 192.168.0.130:9527 │
│ (桌面程式) │ :9527 │ 1.34.63.223:9527 │ │ /tunnel/connect │
│ gorilla/ws │ │ · LE HTTPS termination │ │ (Host 白名單 + WS 轉發) │
└─────────────┘ │ · NAT → 內網 130 │ └───────────┬────────────┘
│ ⚠ 目前剝掉 WS upgrade │ │ http (loopback)
└──────────────────────────┘ ▼
↑ 本專案碰不到、非 repo 內 ┌────────────────────────┐
↑ 需網管在此開 WS 轉發 │ remote-proxy :3800 │
│ (tunnel WS server, │
│ yamux, 10s keepalive) │
└────────────────────────┘
```
### 2.2 逐跳要求
| 跳 | 從 → 到 | 協定 | 這一跳要能過,需要什麼 |
|----|---------|------|----------------------|
| ① | Agent → 邊界反代 | wss (TLS+h1.1) | 防火牆放行 agent 出站到 `:9527`DNS 解析正確;憑證有效 |
| ② | **邊界反代 → container nginx** | httpNAT/內網) | **⚠ 最關鍵:邊界反代必須顯式轉發 `Upgrade`/`Connection` header + 用 h1.1 + 長 read timeout。目前缺這段 → 400。** |
| ③ | container nginx → remote-proxy | httploopback | 已正確設定(`/tunnel/connect` location 已含 WS 轉發,見 §3.2。Host 需命中白名單。 |
| ④ | remote-proxy 內部 | yamux over WS | 已就緒10s 心跳 / 30s 判定掉線)。 |
**每一跳都要「WS 通」,只要任何一層剝掉 upgrade header整條就斷。** ② 是目前唯一未達標的一跳。
---
## 3. 正式環境要做什麼(重點,給網管 / 交接)
### 3.1 邊界反向代理(公網入口那台,**最關鍵**
這台做 Let's Encrypt HTTPS termination + NAT 轉發到內網 `192.168.0.130`。它**目前是 L7 HTTP proxy 且沒設 WS 轉發**,所以剝掉 upgrade header。要修二選一
#### 選項 A推薦在邊界反代加 WebSocket upgrade 轉發
如果這台是 **nginx**,針對 `/tunnel/connect`(或整個 domain
```nginx
# http contextserver block 外)先定義 upgrade map
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 9527 ssl;
server_name stage-9527.innovedus.com;
# ... LE 憑證設定 ...
location /tunnel/connect {
proxy_pass http://192.168.0.130:9527; # NAT 到內網 container nginx
# ── WebSocket 轉發三要素(缺一不可)──
proxy_http_version 1.1; # ① 必須 h1.1h2 不支援 hop-by-hop header
proxy_set_header Upgrade $http_upgrade; # ② 顯式轉發 upgrade header
proxy_set_header Connection $connection_upgrade; # ③ 顯式轉發 connection header
# ── 長連線 timeouttunnel 是長連線,靠心跳維持)──
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;
}
}
```
> **驗收**:改完後,從外部對 `wss://stage-9527.innovedus.com:9527/tunnel/connect` 發 WS 握手應回 **101**(見 §5
#### 選項 B如果那台不是 nginx可能是防火牆 / LB / WAF
不同設備的等效設定名稱:
| 設備類型 | 要開的等效設定 | 備註 |
|----------|--------------|------|
| **HAProxy** | `mode http` + 預設即支援 WSh1.1);或直接 `mode tcp` 做 L4 passthrough | 確認 `timeout tunnel` 拉長(如 `timeout tunnel 1h` |
| **AWS ALB / GCP HTTPS LB** | 原生支援 WebSocket但要確認 **idle timeout** 拉長ALB 預設 60s | Target group 用 HTTP/1.1 |
| **Cloudflare / CDN** | 開啟 WebSockets帳號設定內有開關避免走會剝 header 的 transform rule | 免費/付費方案都支援 WS |
| **F5 / Nginx Plus / 商用 WAF** | 需開「WebSocket profile / passthrough」部分 WAF 預設攔 upgrade | 若 WAF 檢測 body長連線可能被誤殺 |
| **通用防火牆 / NAT** | 若只是 L4 轉發TCP passthrough**天生支援 WS**(它不看 HTTP header只要把 `:9527` TCP 轉到內網即可 | 最單純、最不會出錯的做法 |
> **最保險做法**:若邊界那台能做 **L4 / TCP passthrough**(純轉 TCP、不解 HTTPWebSocket 天生就通,因為它根本不碰 HTTP header。代價是 HTTPS termination 要往內移(改由 container 那層或另設)。是否採用取決於現有架構,需與網管確認。
### 3.2 Container 內 nginx已就緒僅供交接核對
Repo 內 `docker/nginx.stage.conf``/tunnel/connect` **已正確設定**,交接時只需確認未被改壞:
```nginx
map $http_upgrade $connection_upgrade { # http context
default upgrade;
'' close;
}
location /tunnel/connect {
proxy_pass http://visiona_tunnel; # → 127.0.0.1:3800 (remote-proxy)
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;
# ...
}
```
**Host 白名單注意**:此 nginx 只接受 `Host: stage-9527.innovedus.com`;不符的 Host含直接打 IP一律 `444`(直接關連線)。這是 trust boundary 防護。若邊界反代轉發時改寫或遺漏 Host會被打 444——邊界反代必須帶正確的 `Host` header 下來。
### 3.3 防火牆 / Port 放行清單
| Port | 協定 | 方向 | 開在哪 | 用途 | 必要性 |
|------|------|------|--------|------|--------|
| **9527** | TCP / TLS(wss) | agent 出站 → 公網入口入站 | 邊界防火牆(公網側) | agent 建 tunnel 的唯一對外入口 | **必須** |
| 9527 | TCP | 邊界反代 → 內網 130 | 內網防火牆 | NAT 轉發到 container nginx | **必須**(內部) |
| 3800 | TCP | container nginx → remote-proxy | container loopback | tunnel WS server面向 agent 側) | 內部(同 container無需對外 |
| 3801 | TCP | api-server → remote-proxy | 內部 | remote-proxy internal HTTP面向 api-server 側) | 內部(**絕不可對外** |
| 3721 | TCP | nginx → api-server | container loopback | api-server | 內部 |
| 3000 | TCP | nginx → frontend | container loopback | Next.js frontend | 內部 |
**關鍵放行只有一條**:讓使用者端 agent 能出站連到 **公網入口 `:9527`**TCP/wss。其餘都在雲端內部 / container loopback不對公網開放。
> ⚠️ **安全提醒**`:3800`tunnel WS server與 `:3801`remote-proxy internal無認證的 HTTP 轉發端點)**絕對不能直接暴露到公網**。它們只該從 container loopback / 內網受信任來源存取。對外一律只走 `:9527` 經 nginx。
### 3.4 Idle timeout長連線存活
Tunnel 是長連線,靠 **yamux 每 10s 送一次心跳**維持(連續 3 次未回 = 30s 判定掉線)。中間任何一層若 idle timeout 太短,會在心跳間隔間把連線掐斷。
| 層 | 建議 idle / read timeout | 理由 |
|----|------------------------|------|
| 邊界反代 / LB | **≥ 90s**(建議直接設 3600s 或 86400s | 要大於心跳間隔且留安全邊際;雲端 LB 常見預設 60s 偏短 |
| container nginx | 86400s已設 | 長連線;心跳由 yamux 處理nginx 不該中途斷 |
| remote-proxy session idle | `VISIONA_TUNNEL_IDLE_TIMEOUT`,預設 **30s** | 這是「判定對端失聯」的時間(= 3 次心跳未回。demo 曾調 24h 避免誤斷,但正式環境**建議保留 30s 預設**(配合 10s 心跳,這是正確的掉線判定值),不要盲目拉長,否則殭屍 session 不會被清 |
> 釐清:`VISIONA_TUNNEL_IDLE_TIMEOUT=30s` 是「多久沒心跳就判死」,不是「多久沒 API 請求就斷 tunnel」。tunnel 只要心跳正常就永遠在線。demo 當時調成 24h 是為了繞過另一個問題(心跳未被中間層正確轉發時的誤斷),**正式環境把中間層 WS 轉發修對後,應回到 30s 預設**。
### 3.5 HTTPS / TLS
- Agent 走 `wss://`WebSocket over TLS。公網入口的憑證Let's Encrypt必須有效、未過期、CN/SAN 涵蓋 `stage-9527.innovedus.com`
- Agent 端**不應**在正式環境用 `InsecureSkipVerify`(略過憑證驗證)——那只允許 dev 自簽憑證時使用。
- TLS termination 在公網入口那台LE 憑證container nginx 收到的是純 HTTP內網這是正常的`X-Forwarded-Proto: https` 已由 nginx 補回給 backend
### 3.6 DNS
`stage-9527.innovedus.com` 必須解析到公網入口 `1.34.63.223`。agent 的連線 URLtunnel endpoint指向此 domain。若走內網繞法見附錄則直接用 IP不經 DNS。
---
## 4. 各層設定 Checklist交接核對表
| 層 | 要做什麼 | 為什麼 | 誰負責 | 狀態 |
|----|---------|--------|--------|------|
| **邊界反向代理**(公網入口) | 針對 `/tunnel/connect`(或整 domain**WebSocket upgrade 轉發**h1.1 + `Upgrade`/`Connection` header + read timeout ≥ 90s + buffering off或改 L4 TCP passthrough | 這層目前剝掉 upgrade header → 400是 tunnel 建不起來的唯一根因 | **網管**(此設備非 repo 內) | ❌ **待做(最優先)** |
| **防火牆 / Port** | 放行 agent 出站 → 公網入口 `:9527`TCP/wss確認邊界反代 → 內網 `130:9527` 內部轉發通;`:3800`/`:3801` 不對外 | agent 唯一對外入口;內部端點防護 | 網管 | 需確認 |
| **Idle timeout** | 邊界反代 / LB idle timeout 拉長到 ≥ 90s建議 3600s+ | tunnel 長連線靠 10s 心跳維持,太短會誤斷 | 網管 | 需確認 |
| **HTTPS / TLS** | 公網入口 LE 憑證有效、涵蓋 domainagent 走 wss、正式不 skip verify | wss 安全;憑證錯 agent 連不上 | 網管 / 部署 | LE 已有,需核對 |
| **DNS** | `stage-9527.innovedus.com``1.34.63.223` | agent 找得到入口 | 網管 | 需確認 |
| **container nginx** | 確認 `/tunnel/connect` 的 WS 轉發設定未被改壞Host 白名單接受 `stage-9527...`;正式上線移除 demo 用的 IP 直連 server block或加 `allow 內網網段; deny all;` | 這層已就緒demo 繞法不可留在正式 | 部署 | ✅ 已就緒demo block 待移除) |
| **remote-proxy** | 確認 `:3800` tunnel port、`VISIONA_TUNNEL_IDLE_TIMEOUT` 回到 30s 預設 | 心跳/掉線判定正確 | 部署 | ✅idle timeout 需從 demo 的 24h 改回) |
| **agent 端** | 強制 ALPN `http/1.1`(已修);連線 URL 指向公網 domain正式憑證不 skip verify | 避免被協商成 h2 導致 WS 握手失敗 | 已修code | ✅ 已修 |
---
## 5. 驗證方法(怎麼確認每一層 WS 通)
WebSocket 握手成功的標誌是 HTTP **101 Switching Protocols**。用以下方式逐層測,快速定位是哪一跳斷的。
### 5.1 用 curl 測 WS 握手(看回傳 code
```bash
# 對「內網 container nginx」直測繞過邊界反代— 預期 101
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Host: stage-9527.innovedus.com" \
http://192.168.0.130:9527/tunnel/connect
# 對「公網邊界反代」測(正式路徑)— 修好前是 400修好後應為 101
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://stage-9527.innovedus.com:9527/tunnel/connect
```
### 5.2 回傳 code 對照表(快速定位斷點)
| 收到 | 意義 | 該查哪一層 |
|------|------|-----------|
| **101** Switching Protocols | ✅ 握手成功WS 通 | 這一層 OK |
| **400** Bad Requestbad handshake | upgrade header 被中間層剝掉 → 後端收到殘缺握手 | **邊界反代沒開 WS 轉發**(本專案根因);或中間某層吃掉 header |
| **444**(連線直接被關、無回應) | Host 白名單擋掉 | 反代沒帶正確 `Host: stage-9527.innovedus.com` 下來,命中 container nginx 的 `default_server` → 444 |
| **502 / 504** | 到達 nginx 但上游 remote-proxy 不通 / timeout | remote-proxy`:3800`)沒起,或 read timeout 太短 |
| **連線逾時 / 拒絕** | 網路層不通 | 防火牆沒放行 `:9527`,或 DNS 解錯 |
### 5.3 用 wscat 做完整 WS 連線測試(更真實)
```bash
# npm i -g wscat
wscat -c "wss://stage-9527.innovedus.com:9527/tunnel/connect?token=<測試 token>"
# 成功:連上不立即斷;失敗:印出 400 / handshake 錯誤
```
### 5.4 用 openssl 檢查 TLS / ALPN排 h2 誤協商)
```bash
openssl s_client -connect stage-9527.innovedus.com:9527 -alpn http/1.1 -servername stage-9527.innovedus.com </dev/null 2>/dev/null | grep -i 'ALPN\|Verify'
# 確認 ALPN 談成 http/1.1、憑證 Verify return code: 0 (ok)
```
### 5.5 逐層排查建議順序
1. 先測 §5.1 **內網直連**130→ 若 101證明 container nginx + remote-proxy 都 OK問題純在邊界反代。
2. 再測 §5.1 **公網邊界反代** → 若 400就是邊界反代沒轉 WS照 §3.1 修)。
3. 若 444 → 檢查邊界反代有沒有把 `Host` 改成 / 帶成 `stage-9527.innovedus.com`
4. 若逾時 → 回頭查防火牆 §3.3 與 DNS §3.6。
---
## 附錄 Ademo 環境的臨時繞法(**正式不可用**
排查期間為了讓 tunnel 先 online 做 demo採用「**agent 直連內網、繞過邊界反代**」的臨時 hack。記錄於此供理解但**正式上線一律不可沿用**。
### A.1 demo 怎麼繞通的
| 繞法 | 做了什麼 | 為什麼能通 |
|------|---------|-----------|
| agent 直連內網 | agent 連 `ws://192.168.0.130:9527`(不走公網 `:9527` 邊界反代) | 直接打 container nginx跳過「會剝 header 的邊界反代」 |
| nginx 加 IP server block | `docker/nginx.stage.conf``server_name 192.168.0.130` 的 block含完整 WS 轉發) | agent 用 IP 當 Host 時能過白名單(否則打 444 |
| `VISIONA_RELAY_PUBLIC_URL` 改內網 | pairing exchange 回給 agent 的 relay URL 改成內網 `ws://192.168.0.130:9527/...` | 讓 agent 拿到內網 URL 去連 |
| `VISIONA_TUNNEL_IDLE_TIMEOUT` 改 24h | 拉長掉線判定 | 繞過 demo 當時中間層心跳未正確轉發導致的誤斷 |
### A.2 為什麼正式不能用
- **只在同一內網可行**`192.168.0.130` 是私有 IP公網使用者的 agent 根本連不到。
- **明文 http無 TLS**demo 走 `ws://`(非 wsstunnel 流量未加密,正式環境不可接受。
- **繞過 trust boundary**IP 直連 server block 是刻意開的後門,正式上線前**必須移除**,或改成 `allow <內網網段>; deny all;` 限制來源。
- **idle timeout 24h 掩蓋問題**:正式環境把中間層 WS 轉發修對後,應回到 30s 預設,讓殭屍 session 能被正確清理。
> **正式上線正解**:修好 §3.1 的邊界反代 WS 轉發,讓公網使用者的 agent 能經 `wss://stage-9527.innovedus.com:9527/tunnel/connect` 正常建立 tunnel然後移除本附錄的所有 demo 繞法。
---
## 附錄 B相關檔案 / 設定索引
| 項目 | 位置 |
|------|------|
| container nginx 設定(含 `/tunnel/connect` WS 轉發 + demo IP block | `docker/nginx.stage.conf` |
| tunnel 內部協定 / session 管理 / yamux 心跳參數 | `docs/autoflow/04-architecture/tunnel.md` |
| tunnel port / idle timeout 等環境變數 | `visionA-backend/internal/config/config.go``load.go``VISIONA_TUNNEL_PORT=3800``VISIONA_PROXY_INTERNAL_PORT=3801``VISIONA_TUNNEL_IDLE_TIMEOUT=30s``VISIONA_RELAY_PUBLIC_URL` |
| stage 部署設定 | `docs/autoflow/04-architecture/stage-deployment.md``docs/autoflow/07-delivery/stage-deployment-setup.md` |