feat(ws): flash-progress WS tunnel proxy(後端,載入模型進度回顯)

flash「載入模型到裝置」後端缺口:flash-progress WS 從 501 stub 換真
tunnel proxy,複用 inference WS 的 path-agnostic handler。

- camera.go registerWebSocketRoutes:加 /ws/devices/:id/flash-progress
  (複用 newWebSocketProxyHandler、掛 wsAuthGroup same-origin cookie)
- stubs.go:刪 flash-progress 501 stub(成對防 radix panic)+ 更新註解
- api.go:更新 wsAuthGroup 註解(現含 inference + flash-progress 兩條裝置級 WS)
- camera_ws_test.go:+3 test(NoForwarder/TunnelDisconnected/CoexistsWithInference)
- all_endpoints_require_auth_test.go:收窄白名單,flash-progress 從 skipped→
  covered 納入「無 cookie 應 401」回歸檢查(守得住證明:移除 auth 即 FAIL)

契約:WS /ws/devices/:id/flash-progress、payload raw {percent,stage,message?,error?}
透明轉發。Reviewer 0C/0M/1Mi 通過(Mi 註解已修)。build/vet/全回歸綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-07-09 06:30:36 +08:00
parent 051994ed54
commit 4f50ad7350
5 changed files with 76 additions and 19 deletions

View File

@ -45,8 +45,9 @@ import (
//
// /ws/* stub endpoints仍是 501、尚未實作 WebSocket proxy、註冊在 r 而非 wsAuthGroup
// 故不走 AuthMiddleware也逐條列在這裡。**逐條精確列出**、而非用 /ws/ prefix 一次放行,
// 是為了讓「已升級成 authed 的 WS route」如 GET /ws/devices/:id/inference掛在
// wsAuthGroup、走 same-origin cookie AuthMiddleware自動落入主測試的「必須 401」檢查。
// 是為了讓「已升級成 authed 的 WS route」如 GET /ws/devices/:id/inference 與
// GET /ws/devices/:id/flash-progress皆掛在 wsAuthGroup、走 same-origin cookie
// AuthMiddleware自動落入主測試的「必須 401」檢查。
// 未來任一條 stub 補實作並套 auth 後,把它從這份清單移除即可納入回歸檢查。
//
// 任何往這份清單裡新加 endpoint 的 PR 都該特別 review — 你正在繞過 OIDC 保護。
@ -57,10 +58,10 @@ var publicPaths = map[string]bool{
"POST /api/pairing/exchange": true,
// /ws/* 仍為 501 stub 的 endpoint見 internal/api/stubs.go registerWebSocketStubs
// 注意GET /ws/devices/:id/inference 已升級為 authedwsAuthGroup**刻意不在此清單**
// 注意GET /ws/devices/:id/inference 與 GET /ws/devices/:id/flash-progress 已升級為
// authed掛 wsAuthGroup走 same-origin cookie AuthMiddleware**刻意不在此清單**
// 因此會被主測試納入「無 cookie 應回 401」的檢查。
"GET /ws/devices/events": true,
"GET /ws/devices/:id/flash-progress": true,
"GET /ws/server-logs": true,
"GET /ws/system": true,
"GET /ws/clusters/:id/inference": true,

View File

@ -233,12 +233,13 @@ func NewRouter(deps Deps) *gin.Engine {
// Pairing Token 本身就是這個 endpoint 的憑證。詳見 security.md §1.2。
registerPairingPublicRoutes(r, deps)
// /ws/* 雛形大多仍 501已實作的 WS tunnel proxy/ws/devices/:id/inference
// 改掛在下方 wsAuthGroupAuthMiddleware group
// /ws/* 雛形大多仍 501已實作的 WS tunnel proxy/ws/devices/:id/inference
// 與 /ws/devices/:id/flash-progress改掛在下方 wsAuthGroupAuthMiddleware group
registerWebSocketStubs(r)
// WS tunnel proxy group走 same-origin cookie AuthMiddlewaresecurity 定案,
// 不放 token 到 URL。目前只有推論結果 WS/ws/devices/:id/inference
// 不放 token 到 URL。目前有兩條裝置級 WS推論結果/ws/devices/:id/inference
// 與 flash 進度回顯(/ws/devices/:id/flash-progress兩者都走透明 tunnel proxy。
// 刻意獨立成 group 而非掛 /apiWS endpoint 對外路徑就是 /ws/*(對齊前端與
// api-spec但認證邏輯與 /api 共用 AuthMiddleware。
wsAuthGroup := r.Group("/ws")

View File

@ -66,14 +66,25 @@ func registerCameraRoutes(g *gin.RouterGroup, deps Deps) {
// registerWebSocketRoutes 註冊需要 WS tunnel proxy 的 /ws/* endpoint。
//
// 目前只有 /ws/devices/:id/inference推論結果即時推播 — camera overlay + media 結果
// 顯示的共用資料通道)。掛在 wsGroup已套 AuthMiddleware走 same-origin cookie
// 認證,不放 token 到 URLsecurity 定案)。
// 目前有兩條:
// - /ws/devices/:id/inference — 推論結果即時推播camera overlay + media 結果
// 顯示的共用資料通道)。
// - /ws/devices/:id/flash-progress — flash載入模型到裝置進度回顯percent /
// stage / message / errorraw JSON 透明轉發)。
//
// 其餘 /ws/* 仍是 registerWebSocketStubs 的 501events / flash-progress / server-logs /
// system / clusters / pairing非本次範圍。
// 兩條共用同一個 newWebSocketProxyHandler該 handler 是 path-agnostic 的
// outPath 取自 c.Request.URL.Path原樣轉發到 local agent不綁定任何 path
// 所以 inference 與 flash-progress 只差路由掛載handler 零改動。local agent 端
// 兩條 path 都已實作router.go /ws/devices/:id/flash-progress → flash_ws.go
//
// 掛在 wsGroup已套 AuthMiddleware走 same-origin cookie 認證,不放 token 到
// URLsecurity 定案)。
//
// 其餘 /ws/* 仍是 registerWebSocketStubs 的 501events / server-logs / system /
// clusters / pairing非本次範圍。
//
// wsGroup 必須是「path 前綴為 /ws 且套了 AuthMiddleware」的 group見 api.go NewRouter
func registerWebSocketRoutes(wsGroup *gin.RouterGroup, deps Deps) {
wsGroup.GET("/devices/:id/inference", newWebSocketProxyHandler(deps))
wsGroup.GET("/devices/:id/flash-progress", newWebSocketProxyHandler(deps))
}

View File

@ -46,6 +46,51 @@ func TestWSInference_TunnelDisconnected(t *testing.T) {
assert.Contains(t, w.Body.String(), ErrCodeTunnelDisconnect)
}
// TestWSFlashProgress_NoForwarder 驗證 flash-progress route 已掛上(非 404/501-stub
// 且與 inference 共用同一個 path-agnostic handler缺 Forwarder 時回 501handler 內部
// 的「forwarder/session store not configured」非舊的 registerWebSocketStubs 501
//
// 這條與 TestWSInference_NoForwarder 對照,證明 flash-progress 走的是
// registerWebSocketRouteswsAuthGroup而非 registerWebSocketStubs。
func TestWSFlashProgress_NoForwarder(t *testing.T) {
r := newWSFixture(Deps{})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ws/devices/dev1/flash-progress", nil))
assert.Equal(t, http.StatusNotImplemented, w.Code)
}
// TestWSFlashProgress_TunnelDisconnected 驗證 flash-progress 無 active session 時回
// 502 TUNNEL_DISCONNECTED與 inference 同一套 pickActiveSessionToken 路徑)。
func TestWSFlashProgress_TunnelDisconnected(t *testing.T) {
r := newWSFixture(Deps{
SessionStore: &fakeSessionStore{}, // List 回空
Forwarder: session.NewForwarder("http://localhost:0", nil),
})
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ws/devices/dev1/flash-progress", nil))
assert.Equal(t, http.StatusBadGateway, w.Code)
assert.Contains(t, w.Body.String(), ErrCodeTunnelDisconnect)
}
// TestWSFlashProgress_CoexistsWithInference 驗證 radix tree 共存:
// /ws/devices/events靜態 vs /ws/devices/:id/inferenceparam vs
// /ws/devices/:id/flash-progressparam三者掛在同一 group 不 panic且 flash-progress
// 與 inference 都能各自路由到 handler而非彼此蓋掉。newWSFixture 建構本身若 panic
// 就會 fail這裡再各打一發確認兩條 param route 都 match 得到(回 501 = 命中 handler
func TestWSFlashProgress_CoexistsWithInference(t *testing.T) {
r := newWSFixture(Deps{}) // 建構不 panic 即代表 radix tree 共存 OK
for _, path := range []string{
"/ws/devices/dev1/inference",
"/ws/devices/dev1/flash-progress",
} {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
// 命中 handler缺 Forwarder → 501而非 404route 沒掛上)。
assert.Equal(t, http.StatusNotImplemented, w.Code, "path %s 應命中 WS proxy handler", path)
}
}
// TestWSInference_HappyPath_BidirectionalPipe 是端到端的 WS forward 測試:
//
// browser(test client) ──► api-server handler(hijack) ──► Forwarder.OpenStream

View File

@ -57,9 +57,9 @@ func registerStubRoutes(g *gin.RouterGroup, _ Deps) {
//
// WS tunnel proxy 的基礎設施Forwarder.ForwardWebSocket、api-server 端 Hijack + 雙向
// io.Copy已實作見 forwarder.go §ForwardWebSocket、proxy.go newWebSocketProxyHandler
// 且 /ws/devices/:id/inference 已由 registerWebSocketRoutes 換成真正的 WS tunnel proxy。
// 這裡只剩尚未接上 proxy 的其餘 /ws/* 端點events / flash-progress / server-logs /
// system / clusters / pairing
// 且 /ws/devices/:id/inference 與 /ws/devices/:id/flash-progress 已由
// registerWebSocketRoutes 換成真正的 WS tunnel proxy。這裡只剩尚未接上 proxy 的其餘
// /ws/* 端點events / server-logs / system / clusters / pairing
//
// 注意ws endpoint 在 /ws 而非 /api/ws所以由 NewRouter 直接註冊而非 apiGroup。
func registerWebSocketStubs(r *gin.Engine) {
@ -70,10 +70,9 @@ func registerWebSocketStubs(r *gin.Engine) {
}
// 用 GETWebSocket upgrade 的初始 HTTP request
r.GET("/ws/devices/events", stub("ws.devices.events — pending B7"))
r.GET("/ws/devices/:id/flash-progress", stub("ws.flash-progress — pending B7"))
// /ws/devices/:id/inference 已由 registerWebSocketRoutes 換成真正的 WS tunnel proxy
// (掛在 AuthMiddleware group 內,走 same-origin cookie 認證)。不在此註冊 stub
// 避免 gin radix tree 同路徑重複註冊 panic。
// /ws/devices/:id/inference 與 /ws/devices/:id/flash-progress 已由
// registerWebSocketRoutes 換成真正的 WS tunnel proxy掛在 AuthMiddleware group 內,
// 走 same-origin cookie 認證)。不在此註冊 stub避免 gin radix tree 同路徑重複註冊 panic。
r.GET("/ws/server-logs", stub("ws.server-logs — pending B7"))
r.GET("/ws/system", stub("ws.system — pending B7"))
r.GET("/ws/clusters/:id/inference", stub("ws.clusters.inference — pending B7"))