jim800121chen fb7da5d180 chore(autoflow): migrate .autoflow/ 共享層文件至 docs/autoflow/
依 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)。
2026-05-04 16:55:55 +08:00

18 KiB
Raw Blame History

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 tokenconfig / 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 水平擴展到多 instancemulti-tab 的 fan-out 需要額外機制pub/sub 或 sticky LB屆時另寫 ADR

結論

  • Q5 = tunnel 層「一個 device 只允許一條 active tunnel」
  • Multi-tab 觀看 = 應用層 fan-out與 Q5 互不干涉
  • 雛形兩者皆支援tunnel 後連覆蓋 + 單 instance fan-outPhase 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: websocket header → 切換成 raw bytes 雙向 pipePOC proxyWebSocket
  • Local agent 端同步切換 → raw TCP to localhost + 雙向 pipePOC 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
             寫回 callerapi-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 單 binaryapi-server 與 remote-proxy 之間多一次 internal HTTP hoplocalhost 時 ~0.1ms,跨機 LAN ~1-5ms。此 hop 是 雛形就存在,不是 Phase 1 才引入 —— 這讓 api-server 保持無狀態,未來 Phase 1 只需評估多 proxy 節點間如何共享 session metadata見 ADR-006


4. 連線參數

4.1 WebSocket

  • Binary framewebsocket.BinaryMessage
  • 無 compressionyamux 已是位元流,壓縮效益低且增加 CPU
  • Origin checkremote-proxy 端接受任意 Originlocal agent 不跑在瀏覽器,不需 origin 防護Phase 1 加 token 驗證即可)
  • Ping/Pong使用 yamux 內建 keepalive,統一心跳週期(見 §4.2

4.2 yamux Config2026-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 contextcontext.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()
    // 覆蓋舊 sessionPOC 行為 / 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 存在
    // 回傳 RemoteHandleOpenStream 會再呼叫 /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 instanceapi-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可接受。

不採用的方案RPCgRPC— 多一個 proto 定義,收益不大。 不採用的方案Redis pub/sub 轉發 request body — 非串流語義MJPEG 無法。


7. 雛形實作細節(給 Backend Agent

雛形即雙 binaryQ1 裁決,交付物)。本機開發便利工具 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 同時啟動兩個 binaryforeman / 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」
瀏覽器訂閱 WStunnel 斷 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
  • eventconnect / disconnect / stream_open / stream_error

雛形實作Q1 裁決):雙 binaryremote-proxyInMemoryStore + internal HTTP endpointsapi-serverProxyClientStore不引入 Redis(見 ADR-006未來擴展Phase 1 多 proxy 節點時再評估 session metadata 共享機制§5.4 候選方案比較);壓測 yamux 參數。