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

464 lines
18 KiB
Markdown
Raw Permalink 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 協定與 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`
```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 雙向 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 單 binary**api-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 frame`websocket.BinaryMessage`
- 無 compressionyamux 已是位元流,壓縮效益低且增加 CPU
- Origin check**remote-proxy 端接受任意 Origin**local 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 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()
// 覆蓋舊 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 端)
```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 存在
// 回傳 RemoteHandleOpenStream 會再呼叫 /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` 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`
```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」 |
| 瀏覽器訂閱 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`
- `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 參數。