jim800121chen dc6ca211ae feat(ws): tunnel WS forward — 推論結果經 tunnel 推回雲端(後端塊2)
推論工作區後端塊2:實作 WS forward,讓 local agent 的推論結果 WS
(inference:<deviceId>)經 tunnel 推回雲端瀏覽器 canvas overlay。照
edge-ai-platform POC relay/server.go proxyWebSocket 移植(唯讀參考)。

- forwarder.go ForwardWebSocket:OpenStream→寫 upgrade→讀 101→WebSocketConn
  (新增 WebSocketConn/wsUpgradeError/AsWSUpgradeError)
- proxy.go newWebSocketProxyHandler:hijack browser→回寫 101→雙向 io.Copy
  cross-close(無 goroutine leak)+ copyWebSocketUpgradeHeaders(保留
  Sec-WebSocket-*、剝 Authorization/Origin)
- camera.go registerWebSocketRoutes:GET /ws/devices/:id/inference
- api.go wsAuthGroup(/ws + AuthMiddleware):same-origin cookie 認證,
  無 token-in-URL(security 定案)
- stubs.go:移除 WS inference 501 stub、更新過時 doc comment(Mi-1/2)

架構差異:POC 單 binary,visionA api-server(auth)+remote-proxy 雙 binary,
remote-proxy raw byte pipe 透明穿過 WS upgrade bytes。local agent 端未動。

認證/授權(security 定案 + S1/S2):same-origin cookie、剝 Auth/Origin、
WS 不套 300s timeout、走 pickActiveSessionToken(帶別人 deviceId 也只打到
自己 tunnel;多租戶嚴格綁定屬 Phase 1 M2 debt)。

Reviewer 0C/1M/3Mi 通過(修後)。Major-1 已修:收窄 all_endpoints_require_auth_test
的 /ws/ 白名單,讓 authed inference WS 納入「無 cookie 應 401」回歸檢查
(附守得住證明:移除 auth 測試即 FAIL)。+forwarder 5 test + camera_ws
端到端四層真連線雙向 pipe。build/vet/全回歸綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 04:56:54 +08:00

220 lines
8.1 KiB
Go
Raw 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.

package session
import (
"bufio"
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestForwarder_OpenStream_NoProxyHost 驗證 baseURL 為空時直接拒絕。
func TestForwarder_OpenStream_NoProxyHost(t *testing.T) {
f := NewForwarder("", nil)
_, err := f.OpenStream(context.Background(), "vAc_x")
assert.Error(t, err)
}
// TestForwarder_OpenStream_EmptyToken 驗證空 token 拒絕。
func TestForwarder_OpenStream_EmptyToken(t *testing.T) {
f := NewForwarder("http://localhost:9999", nil)
_, err := f.OpenStream(context.Background(), "")
assert.Error(t, err)
}
// TestForwarder_ForwardWebSocket_NilReq 驗證 nil req 直接拒絕。
func TestForwarder_ForwardWebSocket_NilReq(t *testing.T) {
f := NewForwarder("http://localhost:9999", nil)
_, err := f.ForwardWebSocket(context.Background(), "vAc_x", nil)
assert.Error(t, err)
}
// TestForwarder_ForwardWebSocket_NoProxyHost 驗證 baseURL 為空時 OpenStream 就失敗。
func TestForwarder_ForwardWebSocket_NoProxyHost(t *testing.T) {
f := NewForwarder("", nil)
req, _ := http.NewRequest(http.MethodGet, "/ws/devices/x/inference", nil)
_, err := f.ForwardWebSocket(context.Background(), "vAc_x", req)
assert.Error(t, err)
}
// fakeRawProxy 起一個假的 remote-proxy /internal/forward/raw
// hijack 後回 "200 Connected",接著 handoff 給 onStream 模擬 local agent 端行為。
//
// 回傳 baseURL 供 NewForwarder。
func fakeRawProxy(t *testing.T, onStream func(conn net.Conn)) string {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hj, ok := w.(http.Hijacker)
require.True(t, ok, "test server must support hijack")
conn, _, err := hj.Hijack()
require.NoError(t, err)
// 回 raw forward 握手
if _, err := conn.Write([]byte("HTTP/1.1 200 Connected\r\n\r\n")); err != nil {
_ = conn.Close()
return
}
onStream(conn) // 模擬 tunnel 另一端local agent
}))
t.Cleanup(ts.Close)
return ts.URL
}
// TestForwarder_ForwardWebSocket_101_Success 驗證 happy path
// forwarder 寫 upgrade request → 假 local agent 回 101 → forwarder 回 WebSocketConn。
func TestForwarder_ForwardWebSocket_101_Success(t *testing.T) {
const framePayload = "hello-ws-frame"
baseURL := fakeRawProxy(t, func(conn net.Conn) {
defer conn.Close()
// 讀 upgrade request
req, err := http.ReadRequest(bufio.NewReader(conn))
if err != nil {
return
}
// 驗證 upgrade header 有被帶過來
assert.True(t, strings.EqualFold(req.Header.Get("Upgrade"), "websocket"),
"local agent 應收到 Upgrade: websocket")
// 回 101 + 一段 frame bytes模擬升級後 local agent 主動推的資料)
_, _ = conn.Write([]byte(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Accept: fake-accept\r\n\r\n" +
framePayload,
))
})
f := NewForwarder(baseURL, nil)
req, _ := http.NewRequest(http.MethodGet, "/ws/devices/dev1/inference", nil)
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
req.Header.Set("Sec-WebSocket-Version", "13")
wsConn, err := f.ForwardWebSocket(context.Background(), "vAc_x", req)
require.NoError(t, err)
require.NotNil(t, wsConn)
defer wsConn.Conn.Close()
assert.Equal(t, http.StatusSwitchingProtocols, wsConn.Resp.StatusCode)
assert.Equal(t, "fake-accept", wsConn.Resp.Header.Get("Sec-WebSocket-Accept"))
// 驗證 101 後緊跟的 frame bytes 沒有被 bufio 預讀吞掉prefixConn 接回)。
buf := make([]byte, len(framePayload))
n, err := io.ReadFull(wsConn.Conn, buf)
require.NoError(t, err)
assert.Equal(t, framePayload, string(buf[:n]),
"101 後預讀的 frame bytes 應完整保留")
}
// TestForwarder_ForwardWebSocket_Non101_Rejected 驗證 local agent 回非 101 時,
// 回傳 wsUpgradeError 且能透過 AsWSUpgradeError 取出原 response。
func TestForwarder_ForwardWebSocket_Non101_Rejected(t *testing.T) {
baseURL := fakeRawProxy(t, func(conn net.Conn) {
defer conn.Close()
_, _ = http.ReadRequest(bufio.NewReader(conn))
// local agent 拒絕 upgrade回 403
_, _ = conn.Write([]byte(
"HTTP/1.1 403 Forbidden\r\n" +
"Content-Type: text/plain\r\n" +
"Content-Length: 7\r\n\r\n" +
"denied!",
))
})
f := NewForwarder(baseURL, nil)
req, _ := http.NewRequest(http.MethodGet, "/ws/devices/dev1/inference", nil)
req.Header.Set("Upgrade", "websocket")
_, err := f.ForwardWebSocket(context.Background(), "vAc_x", req)
require.Error(t, err)
resp, ok := AsWSUpgradeError(err)
require.True(t, ok, "應為 wsUpgradeError")
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
assert.Equal(t, "denied!", string(body))
}
// TestForwarder_ForwardWebSocket_NoSession 驗證無 sessionremote-proxy 回 502
// 映射成 ErrSessionNotFound沿用 OpenStream 的錯誤映射)。
func TestForwarder_ForwardWebSocket_NoSession(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"error":{"code":"TUNNEL_DISCONNECTED"}}`))
}))
defer ts.Close()
f := NewForwarder(ts.URL, nil)
req, _ := http.NewRequest(http.MethodGet, "/ws/devices/dev1/inference", nil)
req.Header.Set("Upgrade", "websocket")
_, err := f.ForwardWebSocket(context.Background(), "vAc_dead", req)
assert.ErrorIs(t, err, ErrSessionNotFound)
}
// TestForwarder_OpenStream_502_TreatedAsNotFound 驗證當 remote-proxy 回 502
// session 不存在時的雛形行為)→ 包裝成 ErrSessionNotFound。
//
// 用 httptest 起一個假的 internal endpoint回 502 JSON。
func TestForwarder_OpenStream_502_TreatedAsNotFound(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"error":{"code":"TUNNEL_DISCONNECTED","message":"session not connected"}}`))
}))
defer ts.Close()
f := NewForwarder(ts.URL, nil)
_, err := f.OpenStream(context.Background(), "vAc_dead")
if !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("expected ErrSessionNotFound, got %v", err)
}
}
// TestForwarder_OpenStream_HandshakeRead 驗證能正確讀「HTTP/1.1 200 Connected\r\n\r\n」
// 握手;用一個假 server 回正確握手後立刻 close — 期望我們的 OpenStream 成功,
// 後續 Read 拿 EOF這對 forwarder 而言是合法情境,由 caller 處理)。
//
// 此 case 直接驗證 happy-path 握手解析;真正的端對端轉發由 integration test 涵蓋。
func TestForwarder_OpenStream_HandshakeRead(t *testing.T) {
// 為了保證 server 端在 200 Connected 後不再寫 body讓 forwarder 結束 header 讀
// 不被預讀干擾),用一個 raw TCP listener 而非 httptest.NewServer。
// 但 raw listener 會增加測試複雜度;在 unit test 用 httptest 已足以驗證
// 「能 parse 200 Connected + 兩個 \r\n」的路徑——讀 body 結束會回 EOF
// 後續 caller 用該 conn 才會發現問題,這裡僅驗證 OpenStream 不 error。
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 不能直接寫 raw "HTTP/1.1 200 Connected\r\n\r\n" — httptest 會額外加
// content-length 等 header。改用 hijack 模擬真實 raw forward 行為。
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "no hijacker", 500)
return
}
conn, _, err := hj.Hijack()
if err != nil {
return
}
defer conn.Close()
_, _ = conn.Write([]byte("HTTP/1.1 200 Connected\r\n\r\n"))
// 不再寫;讓 forwarder 拿到 conn 後若 read 會 EOF
}))
defer ts.Close()
f := NewForwarder(ts.URL, nil)
conn, err := f.OpenStream(context.Background(), "vAc_x")
if err != nil {
t.Fatalf("OpenStream should succeed: %v", err)
}
defer conn.Close()
// 不再做 read 驗證(行為由 integration test 涵蓋)
}