依 autoflow-agent workspace v2 設計把 PRD / 設計 / 架構 / 交付類 共享文件從個人層 .autoflow/(ignored)搬到 docs/autoflow/(進 git), 讓團隊可共享產品與架構文件,個人層只留 progress / review / testing 等 per-branch 筆記。 - 02-prd/ 21 個檔(PRD、features、market-analysis 等) - 03-design/ 18 個檔(design-spec、wireframes、flows 等) - 04-architecture/ 31 個檔(TDD、design-doc、ADR×14、API 規格等) - 07-delivery/ 3 個檔(project-summary、phase-0.6-handover、stage-deployment-setup) 合計 73 檔。原檔已從 .autoflow/ 移除(migration 工具執行 git mv, 但因 .autoflow/ 在 .gitignore 中、git 將此操作視為新增、無 rename history)。
18 KiB
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=<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):
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: websocketheader → 切換成 raw bytes 雙向 pipe(POCproxyWebSocket) - 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=<tok>
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'+lastSeenAtlocal 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
// 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 端)
// 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 端)
// 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(雛形)
// 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 多節點時,必須決定:
- 當 proxy-A 剛好是 tunnel 所在節點 → 直接
OpenStream - 當收到的 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
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
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):
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_totaltunnel_stream_duration_secondshistogramtunnel_disconnect_total{reason}
Log 欄位:
token_prefix(前 8 字元,不記完整 token)user_iddevice_idproxy_node_idevent(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 參數。