# Tunnel 協定與 Session 管理 > 本文件聚焦「local agent ↔ remote-proxy」的 tunnel 協定,以及 session 管理機制。 --- ## 1. 架構回顧 ``` [使用者電腦] [雲端 remote-proxy] ▲ local agent (tunnel client) │ WS + yamux (長連線) ├─ dial WS ──────────────────────────────► │ ├─ yamux.Client() ───────────────────────► │ yamux.Server() └─ accept stream ◄──── stream opened by ── │ session.Open() ▼ localhost:3721 (local agent HTTP server) ``` 與 POC 完全相同。差異只在**認證機制**與**session 存放位置**。 --- ## 2. Tunnel 建立流程 ### 2.1 Local agent → Remote proxy ``` Step 1: local agent 讀 pairing token(config / CLI flag / env) Step 2: dial wss://proxy.visiona.cloud/tunnel/connect?token= Step 3: remote-proxy.handleTunnel: ├─ 取 token = r.URL.Query().Get("token") ├─ 若無 token → 401 ├─ PairingStore.Validate(ctx, token) │ ├─ 雛形 StaticPairingStore:比對 env │ └─ Phase 1 PostgresPairingStore:查 token_hash 是否存在 / 未撤銷 / 未過期 ├─ Validate 失敗 → 401 ├─ Upgrader.Upgrade → *websocket.Conn ├─ wsconn.New(conn) → net.Conn ├─ yamux.Server(netConn, DefaultConfig()) → *yamux.Session ├─ SessionStore.Register(ctx, token, sessionHandle) ├─ PairingStore.MarkUsed(ctx, token, deviceID)(若首次) └─ 阻塞 <-session.CloseChan(),等待斷線後清理 ``` ### 2.2 斷線處理 - **Local agent 主動斷**:`Stop()` → close WS → server 端 `session.CloseChan()` 觸發 → `SessionStore.Unregister` - **網路閃斷**:local agent 的 `tunnel.Client.run()` 指數退避重連(初始 1s,上限 30s) - **Proxy 重啟**:重新連接會走完整 Step 2;舊 session 被覆蓋(POC 邏輯沿用) ### 2.3 同 token 多次連線(Q5 覆蓋行為 + Multi-tab 澄清,2026-04-22 M-7) POC 邏輯:後來連線**覆蓋舊 session**(舊的直接關閉)。**雛形沿用此行為**(Q5 裁決);Phase 1 再重新設計為「拒絕後來連線」或「踢掉舊連線並通知使用者」以避免 race(單裝置單 tunnel)。 #### Q5「後連覆蓋前連」適用層級澄清 **Q5 只描述 tunnel 這層(remote-proxy ↔ local agent):** - 同一個 **Pairing/Session Token** 被兩個 **local agent 進程** 拿去連(例:使用者在兩台電腦裝了同一個 agent、都貼了同一組 token)→ 後連的 agent 會踢掉前一個 - 正常使用情境下這不會發生(Session Token 每裝置專屬,Phase 1 升級機制會自動綁 device_id) - 雛形階段刻意貼同一個 `VISIONA_PAIRING_TOKEN` 到兩台電腦時會出現,這是 dev 行為不是產品情境 #### Multi-tab 觀看:屬於 API server ↔ browser 層,**不受 Q5 影響** 同一使用者在瀏覽器開多個分頁、都在看同一個裝置的推論結果 → 這完全不影響 tunnel: ``` browser tab 1 ─┐ ┌─ api-server │ │ (in-memory broadcaster or WS fan-out) browser tab 2 ─┼─ WSS /ws/xxx ────►│ │ ├─ 內部只開一條 internal WS/HTTP forward browser tab 3 ─┘ │ 經 remote-proxy 進 tunnel(共用 session) └─ ↓ local agent ``` - 雛形階段:API server 維護一個 **in-process fan-out** — 多個 browser WebSocket client 訂閱同一個 device/cluster 事件,API server 只開**一條** yamux stream 從 local agent 取資料,再複製推給所有 browser 訂閱者 - 此機制完全在 API server 層,**不跨 tunnel session**,因此與 Q5 覆蓋行為無衝突 - Phase 1 若 api-server 水平擴展到多 instance,multi-tab 的 fan-out 需要額外機制(pub/sub 或 sticky LB),屆時另寫 ADR **結論**: - **Q5** = tunnel 層「一個 device 只允許一條 active tunnel」 - **Multi-tab 觀看** = 應用層 fan-out,與 Q5 互不干涉 - 雛形兩者皆支援(tunnel 後連覆蓋 + 單 instance fan-out);Phase 1 需重新設計的是**兩者都要**(Q5 改為拒絕後連 + fan-out 跨 instance) 雛形實作重點(`remote-proxy` `InMemoryStore.Register`): ```go func (s *InMemoryStore) Register(ctx, token, h) error { s.mu.Lock() defer s.mu.Unlock() if old, ok := s.sessions[token]; ok { old.Close() // 後連覆蓋前連(POC 行為,Q5 裁決沿用) } s.sessions[token] = h return nil } ``` --- ## 3. 訊息格式 ### 3.1 Tunnel 建立後 yamux session 建立完成後,**控制 frame 由 yamux 處理**,使用者資料透過 `session.Open()` / `session.Accept()` 產生的 stream 傳輸。 ### 3.2 每個 yamux stream 的內容 **純 HTTP request/response 明文**,格式同 RFC 7230: Stream 開啟方(proxy)寫入: ``` GET /api/devices HTTP/1.1 Host: 127.0.0.1:3721 Content-Length: 0 ... ``` Stream 另一端(local agent)讀取後轉發到本機,再把 response 寫回: ``` HTTP/1.1 200 OK Content-Type: application/json Content-Length: 124 ... {"success":true,"data":[...]} ``` **WebSocket upgrade 特殊處理**: - Proxy 端偵測 `Upgrade: websocket` header → 切換成 raw bytes 雙向 pipe(POC `proxyWebSocket`) - Local agent 端同步切換 → raw TCP to localhost + 雙向 pipe(POC `handleWebSocket`) ### 3.3 範例:瀏覽器 fetch `GET /api/devices`(雛形雙 binary 模式) ``` [瀏覽器] HTTPS → api-server [api-server] 需要走 tunnel → 呼叫 internal HTTP: POST http://remote-proxy:3801/internal/forward/http?token= body = 完整 HTTP request 原始 bytes [remote-proxy] handleInternalForward: sessions[token] = *yamux.Session → OpenStream 把 request bytes 寫進 stream 從 stream 讀 response bytes 寫回 caller(api-server) [remote-proxy → local agent via yamux stream] [local agent tunnel client] session.Accept() → stream [local agent] http.ReadRequest(stream) → 送到 127.0.0.1:3721 [local agent] RoundTrip → response [local agent] response.Write(stream) [remote-proxy] 收 response bytes 原封寫回 api-server [api-server] 收 response bytes → http.ReadResponse → 寫回 gin.Context.Writer [瀏覽器] 收到 JSON ``` **關鍵差異(vs POC 單 binary)**:api-server 與 remote-proxy 之間多一次 internal HTTP hop(localhost 時 ~0.1ms,跨機 LAN ~1-5ms)。此 hop 是 **雛形就存在**,不是 Phase 1 才引入 —— 這讓 api-server 保持無狀態,未來 Phase 1 只需評估多 proxy 節點間如何共享 session metadata(見 ADR-006)。 --- ## 4. 連線參數 ### 4.1 WebSocket - Binary frame(`websocket.BinaryMessage`) - 無 compression(yamux 已是位元流,壓縮效益低且增加 CPU) - Origin check:**remote-proxy 端接受任意 Origin**(local agent 不跑在瀏覽器,不需 origin 防護;Phase 1 加 token 驗證即可) - Ping/Pong:**使用 yamux 內建 keepalive**,統一心跳週期(見 §4.2) ### 4.2 yamux Config(2026-04-22 M-5 統一:10s 心跳 / 30s 判定掉線) | 參數 | 值 | 說明 | |------|-----|-----| | `AcceptBacklog` | 256 | POC default | | `EnableKeepAlive` | `true` | — | | `KeepAliveInterval` | **10s**(非 POC default 30s)| 每 10s 送一次 yamux ping | | `ConnectionWriteTimeout` | 10s | 單一寫入超時 | | `MaxStreamWindowSize` | 256KB | Phase 1 壓測後可能調整(大串流場景)| #### 心跳 / 掉線判定標準 - **心跳間隔**:10 秒 - **判定掉線**:連續 **3 次**未收到對端 pong(= 30 秒)→ 視為 tunnel 斷線 - **端點行為**: - `remote-proxy` 端偵測到斷線 → `yamux.Session.Close()` → `SessionStore.Unregister(token)` → 更新 Device 的 `remoteStatus = 'offline'` + `lastSeenAt` - `local agent` 端偵測到斷線 → 進入重連迴圈(指數退避 1s → 30s 上限) - **為何選 10s / 30s**: - 太短(< 5s)→ 心跳流量過多,尤其在手機網路下耗電 - 太長(> 30s)→ 前端呈現「裝置離線」的反應延遲過大,UX 差 - 10s + 3 次 ≈ 30 秒判定,跟一般雲端 LB idle timeout(通常 60s)留安全邊際 **雛形與 Phase 1 一致**:此參數從雛形就定死,不做區分,避免日後調整時產生回歸風險。 ### 4.3 超時 | 場景 | 超時 | 處理 | |------|------|------| | WebSocket dial | 10s | local agent 退避重試 | | Tunnel idle(無 stream)| 不超時(yamux keepalive 即可)| — | | Stream 建立 | 5s | 回 `502 Bad Gateway` | | Stream 資料讀寫 | 使用 HTTP request context;`context.WithTimeout` 由 api-server 層決定(預設 30s)| — | --- ## 5. Session 管理 ### 5.1 SessionStore Interface ```go // internal/session/store.go package session import ( "context" "net" "time" ) // Summary 是跨節點可序列化的 session 描述。 type Summary struct { Token string UserID string DeviceID string ConnectedAt time.Time LastSeenAt time.Time ProxyNodeID string // Phase 1 多節點時使用 ProxyInternalURL string // Phase 1 多節點時 api-server 要連的 URL } // Handle 是實際可操作的 session(綁在某個 proxy 節點的記憶體)。 // 雛形單節點:直接 wrap *yamux.Session。 // Phase 1 多節點:可能是 local handle(本地)或 remote handle(轉發到其他 proxy)。 type Handle interface { OpenStream(ctx context.Context) (net.Conn, error) Close() error IsClosed() bool Summary() *Summary } type Store interface { Register(ctx context.Context, token string, h Handle) error Unregister(ctx context.Context, token string) error Lookup(ctx context.Context, token string) (Handle, error) Exists(ctx context.Context, token string) (bool, error) List(ctx context.Context) ([]*Summary, error) Heartbeat(ctx context.Context, token string) error // CleanupExpired 移除所有 LastSeenAt 超過 expireAfter 的 session(判定為失聯)。 // 由 remote-proxy 的 background goroutine 每 30s 呼叫一次(對齊 §4.2 掉線判定)。 // 實作應 Close() 對應 Handle 以釋放 yamux.Session / WS conn。 // 回傳被清理的 session 數量(observability 用)。 // // 2026-04-22 Minor-4 新增:原本僅依賴 yamux keepalive 偵測斷線, // 但若連線進入殭屍狀態(TCP half-open)未觸發 keepalive 錯誤, // 需要主動清理,避免 SessionStore 留下無效 entry。 CleanupExpired(ctx context.Context, expireAfter time.Duration) (removed int, err error) } ``` ### 5.2 雛形:InMemoryStore(只在 remote-proxy 端) ```go // internal/session/inmemory.go // 只有 remote-proxy binary 載入此實作;api-server 不持有 session state。 type InMemoryStore struct { sessions map[string]Handle // token → Handle mu sync.RWMutex } func (s *InMemoryStore) Register(ctx, token, h) error { s.mu.Lock() defer s.mu.Unlock() // 覆蓋舊 session(POC 行為 / Q5 裁決沿用) if old, ok := s.sessions[token]; ok { old.Close() } s.sessions[token] = h return nil } // Lookup / Unregister / Exists / List 為 trivial map 操作。 // Heartbeat 更新 h.Summary().LastSeenAt。 ``` ### 5.2.1 雛形:ProxyClientStore(只在 api-server 端) ```go // internal/session/proxy_client.go // 只有 api-server binary 載入。實質是 internal HTTP client,代表「session 狀態在 remote-proxy 那邊」。 type ProxyClientStore struct { proxyInternalURL string // 例:http://localhost:3801 或 http://remote-proxy.internal:3801 http *http.Client } func (s *ProxyClientStore) Lookup(ctx, token) (Handle, error) { // 呼叫 GET {proxyInternalURL}/internal/session/:token 確認 session 存在 // 回傳 RemoteHandle(OpenStream 會再呼叫 /internal/forward/*) } func (s *ProxyClientStore) Register(...) error { return ErrNotSupported } // register 只能在 remote-proxy ``` ### 5.3 LocalHandle(雛形) ```go // internal/session/local_handle.go type LocalHandle struct { yamuxSession *yamux.Session summary *Summary mu sync.Mutex } func (h *LocalHandle) OpenStream(ctx context.Context) (net.Conn, error) { if h.yamuxSession.IsClosed() { return nil, ErrSessionClosed } return h.yamuxSession.Open() } // ... ``` ### 5.4 Phase 1:多 remote-proxy 節點間的 metadata 共享 雛形只有 1 個 `remote-proxy` instance,api-server 的 `ProxyClientStore` 直接指向那唯一的 URL。 Phase 1 當 `remote-proxy` 水平擴展到 N 個節點後,出現的問題: ``` [瀏覽器請求 tunnel X] → api-server-1 → 它要怎麼知道 tunnel X 連在 proxy-A 還是 proxy-B? ``` **雛形不預設解法**(見 ADR-006)。Phase 1 再評估以下候選: | 候選 | 說明 | 優點 | 缺點 | |------|------|------|------| | Redis | 共享 `token → proxy_node_url` map | 簡單、成熟 | 引入新依賴、單點 | | Consul / etcd | Service registry | 原生支援 watch / TTL | 運維複雜 | | Gossip (memberlist) | proxy 節點互傳 session 清單 | 無中心點 | 資料一致性弱 | | Sticky routing by L7 LB | token → 固定 proxy 節點 | 無需共享 state | LB 要會看 header | | Fan-out query | api-server 問所有 proxy「tunnel X 在你那嗎?」 | 最簡單 | N 大時效率差 | **採用的方案會產出新 ADR**,屆時 `ProxyClientStore` 可能替換或擴充(例如增加「先查 registry 再轉發」的邏輯)。 **雛形的好處**:api-server 已經是無狀態、已經透過 HTTP 跟 proxy 溝通,所以 Phase 1 的改動只在「怎麼找到對的 proxy URL」這一層,API handler 程式碼不變。 --- ## 6. 跨節點路由的思考 Phase 1 多節點時,必須決定: 1. **當 proxy-A 剛好是 tunnel 所在節點** → 直接 `OpenStream` 2. **當收到的 api 請求在 api-server-X,而 tunnel 在 proxy-Y** → api-server-X 透過 HTTP 呼叫 proxy-Y 單元 HTTP forward 會增加一次網路 hop(~1-5ms LAN),可接受。 **不採用的方案**:RPC(gRPC)— 多一個 proto 定義,收益不大。 **不採用的方案**:Redis pub/sub 轉發 request body — 非串流語義,MJPEG 無法。 --- ## 7. 雛形實作細節(給 Backend Agent) 雛形即雙 binary(Q1 裁決,交付物)。本機開發便利工具 `make run-dev`(平行跑兩個 binary)**非交付物**;不做 `cmd/dev-all-in-one`(見 `design-doc.md` §1.9 N10)。 ### 7.1 `cmd/remote-proxy/main.go` ```go func main() { cfg := config.Load() pairingStore := auth.NewStaticPairingStore(cfg.Pairing.Token, cfg.Auth.StaticUserID) sessionStore := session.NewInMemoryStore() // 唯一持有 session state 的地方 // Tunnel listener(面向 local agent) tunnelHandler := relay.NewServer(pairingStore, sessionStore).Handler() // Internal HTTP listener(面向 api-server) internalHandler := relay.NewInternalServer(sessionStore).Handler() // 提供: // GET /internal/session/:token // POST /internal/forward/http?token=... // GET /internal/forward/ws?token=... // POST /internal/session/:token/close go http.ListenAndServe(fmt.Sprintf(":%d", cfg.RemoteProxy.TunnelPort), tunnelHandler) http.ListenAndServe(fmt.Sprintf(":%d", cfg.RemoteProxy.InternalPort), internalHandler) } ``` ### 7.2 `cmd/api-server/main.go` ```go func main() { cfg := config.Load() authSvc := auth.NewStaticAuthService(cfg.Auth) deviceRepo := device.NewInMemoryRepository() modelRepo := model.NewInMemoryRepository() storage := storage.NewLocalFS(cfg.Storage.LocalFSRoot, cfg.Storage.LocalFSBaseURL) converterClient := converter.NewStubClient() // Session store 是 remote-proxy 的 HTTP client(無本地 state) sessionStore := session.NewProxyClientStore(cfg.Session.ProxyInternalURL, http.DefaultClient) apiRouter := api.NewRouter(api.Deps{ Auth: authSvc, SessionStore: sessionStore, DeviceRepo: deviceRepo, ModelRepo: modelRepo, Storage: storage, Converter: converterClient, }) http.ListenAndServe(fmt.Sprintf(":%d", cfg.APIServer.Port), apiRouter) } ``` ### 7.3 本機開發啟動 Makefile 提供 `make dev` 同時啟動兩個 binary(例:用 `foreman` / `overmind` / 自製 shell): ```makefile dev: @( ./bin/remote-proxy & ./bin/api-server & wait ) ``` 或 `docker-compose.yml` 定義兩個 service 同時起。 ### 7.4 Phase 1 差異 - `remote-proxy` 仍然是唯一持有 `*yamux.Session` 實體的地方(無論多少節點) - `api-server` 仍然用 `ProxyClientStore`,只是底層要能找到「該 session 在哪個 proxy 節點」(見 §5.4 方案評估) --- ## 8. 重連語意 從瀏覽器的角度,**tunnel 斷線期間的請求如何處理**? | 情境 | 處理 | |------|------| | 瀏覽器發 API request 時 tunnel 斷 | api-server 立刻回 `502 { code: TUNNEL_DISCONNECTED }`,前端顯示「裝置離線,請檢查 local-tool」 | | 瀏覽器訂閱 WS,tunnel 斷 | api-server 關閉瀏覽器 WS,前端自動重連 | | Tunnel 重連後 | 瀏覽器下一次 API 請求直接成功 | **不做 transparent retry**:若有 side-effect 的操作(例:flash firmware)期間 tunnel 斷,透明重試會導致重複執行。讓使用者明確看到錯誤並決定是否重試。 --- ## 9. 觀測(Phase 1) Metrics: - `tunnel_active_count{proxy_node}` - `tunnel_connect_total{result}`(result=success/auth_failed/upgrade_failed) - `tunnel_stream_opened_total` - `tunnel_stream_duration_seconds` histogram - `tunnel_disconnect_total{reason}` Log 欄位: - `token_prefix`(前 8 字元,不記完整 token) - `user_id` - `device_id` - `proxy_node_id` - `event`(connect / disconnect / stream_open / stream_error) --- **雛形實作(Q1 裁決)**:雙 binary;`remote-proxy` 用 `InMemoryStore` + internal HTTP endpoints;`api-server` 用 `ProxyClientStore`。**不引入 Redis**(見 ADR-006)。 **未來擴展**:Phase 1 多 proxy 節點時再評估 session metadata 共享機制(§5.4 候選方案比較);壓測 yamux 參數。