依 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)。
464 lines
18 KiB
Markdown
464 lines
18 KiB
Markdown
# 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`):
|
||
|
||
```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=<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'` + `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 參數。
|