From 90311535539efa0c5900a46a426869fbeac714c0 Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Thu, 30 Jul 2026 12:32:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(adr-019):=20=E5=BD=B1=E7=89=87/=E5=9C=96?= =?UTF-8?q?=E7=89=87/=E6=89=B9=E6=AC=A1=E4=B8=8A=E5=82=B3=E8=B5=B0?= =?UTF-8?q?=E5=90=8C=E6=A9=9F=20localhost=20=E7=9B=B4=E9=80=A3=20local-age?= =?UTF-8?q?nt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 實作 ADR-019 混合路徑:影片/圖片/批次的檔案上傳改由瀏覽器同機直連 local-agent localhost endpoint(繞過雲端 tunnel),控制面 + MJPEG 結果 + 推論 WS 仍走 tunnel。解決大檔頻寬雙倍 + nginx 100M + 300s timeout。 三條 stream(全數過 reviewer + security code-level 複審 APPROVED): local-agent(Go): - CORS 雲端 origin 完整精確比對 + Allow-Credentials:false + HostGuard(loopback) + PNA header(middleware.go) - 新 route /api/local/media/upload/*(一律要 token、不看 Origin,關 C1 後門) - one-time token store(crypto/rand、TTL 120s、綁 deviceId、single-flight consume、 上限 32→429;200 goroutine -race 綠) - GET /api/local/hello(回 salted SHA-256 serialHashes、最小揭露) + POST /api/local/issue-token(Host-based) - LocalUploadGuard(token+size 驗證放 FormFile 前);video≤500MB / batch 合計 80MB → 413;stopActivePipeline + batch 生命週期 temp 檔清理 cloud(visionA-backend): - POST /api/devices/:serial/local-upload-ticket(OIDC + 裝置歸屬 + 經 tunnel 轉發 issue-token;IDOR-safe、錯誤不洩漏) frontend(visionA-frontend): - lib/local-agent.ts(port 探測 3721-3740 並發+快取、Web Crypto serial hash 比對 同機判定、uploadToLocalAgent 通用函式) - validateBatchFiles 合計大小檢查(MAX_BATCH_TOTAL_BYTES=80MB,消 50×19MB 撞 413 地雷) 回歸:ADR-019 相關 270 測試全綠、既有 tunnel 路徑未被打斷、無 regression。 既有 tunnel(無 Origin)不要求 token(C1 route 分離相容性保證)。 Refs: ADR-019。WP-0(PNA 實機)/WP-4(影片分頁接線)下一批。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../internal/api/handlers/camera_handler.go | 9 + .../internal/api/handlers/local_handler.go | 167 +++++++ .../api/handlers/local_handler_test.go | 234 +++++++++ .../server/internal/api/local_middleware.go | 97 ++++ .../internal/api/local_middleware_test.go | 269 +++++++++++ local-agent/server/internal/api/middleware.go | 181 ++++++- .../server/internal/api/middleware_test.go | 251 ++++++++++ local-agent/server/internal/api/router.go | 36 +- .../server/internal/api/token_store.go | 173 +++++++ .../server/internal/api/token_store_test.go | 213 +++++++++ visionA-backend/internal/api/api.go | 6 + visionA-backend/internal/api/devices.go | 6 + .../internal/api/local_upload_ticket.go | 316 +++++++++++++ .../internal/api/local_upload_ticket_test.go | 204 ++++++++ visionA-frontend/src/lib/local-agent.test.ts | 363 ++++++++++++++ visionA-frontend/src/lib/local-agent.ts | 443 ++++++++++++++++++ visionA-frontend/src/lib/media.test.ts | 36 ++ visionA-frontend/src/lib/media.ts | 36 +- visionA-frontend/src/types/api.ts | 6 + 19 files changed, 3014 insertions(+), 32 deletions(-) create mode 100644 local-agent/server/internal/api/handlers/local_handler.go create mode 100644 local-agent/server/internal/api/handlers/local_handler_test.go create mode 100644 local-agent/server/internal/api/local_middleware.go create mode 100644 local-agent/server/internal/api/local_middleware_test.go create mode 100644 local-agent/server/internal/api/token_store.go create mode 100644 local-agent/server/internal/api/token_store_test.go create mode 100644 visionA-backend/internal/api/local_upload_ticket.go create mode 100644 visionA-backend/internal/api/local_upload_ticket_test.go create mode 100644 visionA-frontend/src/lib/local-agent.test.ts create mode 100644 visionA-frontend/src/lib/local-agent.ts diff --git a/local-agent/server/internal/api/handlers/camera_handler.go b/local-agent/server/internal/api/handlers/camera_handler.go index 32b6a11..8c386d6 100644 --- a/local-agent/server/internal/api/handlers/camera_handler.go +++ b/local-agent/server/internal/api/handlers/camera_handler.go @@ -530,6 +530,15 @@ func (h *CameraHandler) stopActivePipeline() { if h.sourceType == camera.SourceCamera { h.cameraMgr.Close() } + // ADR-019 §4.3.1 M1:補刪前一支影片的 temp 檔,防磁碟 DoS。 + // + // 為什麼要在這裡補:VideoSource.Close() 雖已 os.Remove(filePath),但 seek 流程用 + // CloseWithoutRemove() 保留檔案供重新 seek,之後 h.videoPath 仍指向 temp 檔而 + // activeSource 可能是不同的(或 nil)VideoSource。此處對 h.videoPath 明確補一次 + // os.Remove 作為 belt-and-suspenders——已被刪過時第二次 Remove 是無害 no-op。 + if h.videoPath != "" { + _ = os.Remove(h.videoPath) + } h.activeSource = nil h.sourceType = "" h.videoPath = "" diff --git a/local-agent/server/internal/api/handlers/local_handler.go b/local-agent/server/internal/api/handlers/local_handler.go new file mode 100644 index 0000000..86e2a1b --- /dev/null +++ b/local-agent/server/internal/api/handlers/local_handler.go @@ -0,0 +1,167 @@ +package handlers + +import ( + "crypto/sha256" + "encoding/hex" + "log" + "net/http" + "strings" + "time" + + "visiona-agent/server/internal/device" + + "github.com/gin-gonic/gin" +) + +// LocalSerialSalt 是 serial 雜湊的固定公開常數 salt(ADR-019 §2.3 / api-spec §6.3 議題 1 裁決)。 +// +// 刻意「公開、固定、前後端共用寫死」——非 server 私有隨機值。 +// 目的是讓前端能用 Web Crypto 獨立重算 SHA-256("visiona-local-v1" || serial) 比對, +// 而非「防暴力還原」(序號熵低、salt 公開時仍可枚舉回推,security 判定與威脅相稱)。 +// +// 明確不要做:不得用 crypto/rand 私有 salt(前端算不出);不得 per-request 隨機 salt。 +const LocalSerialSalt = "visiona-local-v1" + +// deviceLister 抽象 device.Manager 的 ListDevices,方便測試注入。 +type deviceLister interface { + ListDevices() []deviceInfoView +} + +// deviceInfoView 是 hello 需要的最小 device 視圖(只要 serial)。 +type deviceInfoView struct { + SerialNumber string +} + +// managerAdapter 把 *device.Manager 轉成 deviceLister。 +type managerAdapter struct { + mgr *device.Manager +} + +func (a managerAdapter) ListDevices() []deviceInfoView { + infos := a.mgr.ListDevices() + out := make([]deviceInfoView, 0, len(infos)) + for _, info := range infos { + out = append(out, deviceInfoView{SerialNumber: info.SerialNumber}) + } + return out +} + +// fakeSerialNumber 是 pyusb-fallback placeholder,代表「沒有真實序號」。 +// 與 device 套件保持一致(device.manager.go),不對它計 hash(無意義且會洩漏 placeholder)。 +const fakeSerialNumber = "0x00000000" + +// LocalHandler 提供 ADR-019 的本機直連支援 endpoint(hello / issue-token)。 +type LocalHandler struct { + devices deviceLister + store localTokenStore +} + +// localTokenStore 是 LocalHandler 依賴的 token store 介面(issue 用)。 +// 對應 api.TokenStore,用介面避免 handlers → api 的反向依賴。 +type localTokenStore interface { + Issue(deviceID string) (token string, expiresAt time.Time, err error) + IsLimitErr(err error) bool +} + +// NewLocalHandler 建立 LocalHandler。mgr 提供裝置序號、store 提供 token 發放。 +func NewLocalHandler(mgr *device.Manager, store localTokenStore) *LocalHandler { + return &LocalHandler{ + devices: managerAdapter{mgr: mgr}, + store: store, + } +} + +// hashSerial 計算 SHA-256(salt || fullSerial) 的 lowercase hex(ADR-019 §2.3)。 +func hashSerial(serial string) string { + sum := sha256.Sum256([]byte(LocalSerialSalt + serial)) + return hex.EncodeToString(sum[:]) +} + +// Hello 是 GET /api/local/hello — 同機偵測 + 身分驗證(bootstrap,無 token)。 +// +// 回傳(最小揭露,api-spec §6.3): +// - serialHashes:每個 = SHA-256("visiona-local-v1" || fullSerial) hex +// - supportsLocalUpload:布林 true +// +// 明確不回:agentVersion、完整 serial、deviceId、機器名、任何其他欄位。 +func (h *LocalHandler) Hello(c *gin.Context) { + infos := h.devices.ListDevices() + hashes := make([]string, 0, len(infos)) + for _, info := range infos { + serial := strings.TrimSpace(info.SerialNumber) + // 跳過空 / fake placeholder 序號——無真實身分、hash 它只會洩漏 placeholder。 + if serial == "" || strings.EqualFold(serial, fakeSerialNumber) { + continue + } + hashes = append(hashes, hashSerial(serial)) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "serialHashes": hashes, + "supportsLocalUpload": true, + }, + }) +} + +// issueTokenRequest 是 issue-token 的請求 body。 +type issueTokenRequest struct { + Serial string `json:"serial"` +} + +// IssueToken 是 POST /api/local/issue-token — 產 one-time upload token。 +// +// 取得路徑:僅經既有 tunnel 由 api-server 轉發呼叫(受 HostGuard 約束 = loopback)。 +// 產出的 token 綁 deviceId(此處 = serial)+ one-time + 120s TTL。 +// 達 32 上限 → 429 LOCAL_TOKEN_LIMIT。 +// +// 稽核 log:記 deviceId + 成功/失敗,絕不 log token 明文。 +func (h *LocalHandler) IssueToken(c *gin.Context) { + var req issueTokenRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{ + "code": "BAD_REQUEST", "message": "serial is required", + }}) + return + } + serial := strings.TrimSpace(req.Serial) + if serial == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{ + "code": "BAD_REQUEST", "message": "serial is required", + }}) + return + } + + token, expiresAt, err := h.store.Issue(serial) + if err != nil { + if h.store.IsLimitErr(err) { + // 稽核:達上限(不含 token)。 + log.Printf("[local-token] issue REJECTED (limit) deviceId=%s ts=%s", + serial, time.Now().UTC().Format(time.RFC3339)) + c.JSON(http.StatusTooManyRequests, gin.H{"success": false, "error": gin.H{ + "code": "LOCAL_TOKEN_LIMIT", "message": "too many unused upload tokens", + }}) + return + } + log.Printf("[local-token] issue ERROR deviceId=%s ts=%s err=%v", + serial, time.Now().UTC().Format(time.RFC3339), err) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": gin.H{ + "code": "INTERNAL_ERROR", "message": "failed to issue token", + }}) + return + } + + // 稽核:發放成功(deviceId + 時間,絕不記 token 明文)。 + log.Printf("[local-token] issue OK deviceId=%s ts=%s", + serial, time.Now().UTC().Format(time.RFC3339)) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "token": token, + "expiresAt": expiresAt.UnixMilli(), + "ttlSeconds": 120, + }, + }) +} diff --git a/local-agent/server/internal/api/handlers/local_handler_test.go b/local-agent/server/internal/api/handlers/local_handler_test.go new file mode 100644 index 0000000..e375a4d --- /dev/null +++ b/local-agent/server/internal/api/handlers/local_handler_test.go @@ -0,0 +1,234 @@ +package handlers + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// fakeDeviceLister 是 deviceLister 的測試替身。 +type fakeDeviceLister struct { + serials []string +} + +func (f fakeDeviceLister) ListDevices() []deviceInfoView { + out := make([]deviceInfoView, 0, len(f.serials)) + for _, s := range f.serials { + out = append(out, deviceInfoView{SerialNumber: s}) + } + return out +} + +// fakeStore 是 localTokenStore 的測試替身。 +type fakeStore struct { + token string + expiresAt time.Time + err error + isLimit bool + gotDevice string +} + +func (f *fakeStore) Issue(deviceID string) (string, time.Time, error) { + f.gotDevice = deviceID + return f.token, f.expiresAt, f.err +} + +func (f *fakeStore) IsLimitErr(err error) bool { return f.isLimit && err != nil } + +// expectedHash 用測試獨立的實作重算 SHA-256(salt||serial) hex, +// 避免直接呼叫被測函式(防同一個 bug 同時存在於實作與預期)。 +func expectedHash(serial string) string { + sum := sha256.Sum256([]byte("visiona-local-v1" + serial)) + return hex.EncodeToString(sum[:]) +} + +// TestHello_SerialHashes:hello 回 serialHashes(正確 hex)+ supportsLocalUpload, +// 跳過空 / fake 序號,且不回 agentVersion / 完整 serial。 +func TestHello_SerialHashes(t *testing.T) { + h := &LocalHandler{ + devices: fakeDeviceLister{serials: []string{ + "0x1A2B3C4D", + "", // 空 → 跳過 + "0x00000000", // fake placeholder → 跳過 + "0xDEADBEEF", + }}, + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/local/hello", nil) + h.Hello(c) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + + var resp struct { + Success bool `json:"success"` + Data struct { + SerialHashes []string `json:"serialHashes"` + SupportsLocalUpload bool `json:"supportsLocalUpload"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + + if !resp.Success { + t.Error("success 應為 true") + } + if !resp.Data.SupportsLocalUpload { + t.Error("supportsLocalUpload 應為 true") + } + // 只應有兩個真實序號的 hash + if len(resp.Data.SerialHashes) != 2 { + t.Fatalf("serialHashes 數量 = %d, want 2(空與 fake 應被跳過)", len(resp.Data.SerialHashes)) + } + wantSet := map[string]bool{ + expectedHash("0x1A2B3C4D"): true, + expectedHash("0xDEADBEEF"): true, + } + for _, got := range resp.Data.SerialHashes { + if !wantSet[got] { + t.Errorf("非預期的 hash: %q", got) + } + // hex 必須是 lowercase、長度 64(SHA-256 = 32 bytes → 64 hex chars) + if len(got) != 64 { + t.Errorf("hash 長度 = %d, want 64", len(got)) + } + if got != strings.ToLower(got) { + t.Errorf("hash 必須 lowercase hex,got %q", got) + } + } + + // 最小揭露:不得出現 agentVersion / 完整 serial 明文 + bodyStr := w.Body.String() + if strings.Contains(bodyStr, "agentVersion") { + t.Error("hello 不應回 agentVersion") + } + if strings.Contains(bodyStr, "0x1A2B3C4D") || strings.Contains(bodyStr, "0xDEADBEEF") { + t.Error("hello 不應回完整 serial 明文") + } +} + +// TestHello_EmptyDevices:無裝置 → serialHashes 為空陣列(非 null)。 +func TestHello_EmptyDevices(t *testing.T) { + h := &LocalHandler{devices: fakeDeviceLister{serials: nil}} + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/local/hello", nil) + h.Hello(c) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if !strings.Contains(w.Body.String(), `"serialHashes":[]`) { + t.Errorf("空裝置應回 serialHashes:[],got %s", w.Body.String()) + } +} + +// TestHashSerial_Contract 直接驗證被測 hashSerial 的字串拼接 / 編碼 / hex 大小寫 +// 與前端逐 byte 一致性複核需要的契約:SHA-256("visiona-local-v1"||serial) lowercase hex。 +func TestHashSerial_Contract(t *testing.T) { + serial := "0x1A2B3C4D" + got := hashSerial(serial) + want := expectedHash(serial) + if got != want { + t.Errorf("hashSerial(%q) = %q, want %q", serial, got, want) + } + // 明確固定一個已知向量,供前端對照(salt+serial 直接字串相接、UTF-8、SHA-256、lowercase hex) + // echo -n "visiona-local-v10x1A2B3C4D" | shasum -a 256 + if len(got) != 64 || got != strings.ToLower(got) { + t.Errorf("hex 格式不符:len=%d lower=%v", len(got), got == strings.ToLower(got)) + } + if LocalSerialSalt != "visiona-local-v1" { + t.Errorf("LocalSerialSalt = %q, want visiona-local-v1(前後端共用常數)", LocalSerialSalt) + } +} + +// TestIssueToken_Success:正常發放 → 200 + token/expiresAt/ttlSeconds,deviceId 綁 serial。 +func TestIssueToken_Success(t *testing.T) { + exp := time.UnixMilli(1_700_000_000_000) + store := &fakeStore{token: "tok-xyz", expiresAt: exp} + h := &LocalHandler{store: store} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/api/local/issue-token", + strings.NewReader(`{"serial":"0xAAAA0001"}`)) + c.Request.Header.Set("Content-Type", "application/json") + h.IssueToken(c) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String()) + } + var resp struct { + Data struct { + Token string `json:"token"` + ExpiresAt int64 `json:"expiresAt"` + TTLSeconds int `json:"ttlSeconds"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Data.Token != "tok-xyz" { + t.Errorf("token = %q, want tok-xyz", resp.Data.Token) + } + if resp.Data.ExpiresAt != exp.UnixMilli() { + t.Errorf("expiresAt = %d, want %d", resp.Data.ExpiresAt, exp.UnixMilli()) + } + if resp.Data.TTLSeconds != 120 { + t.Errorf("ttlSeconds = %d, want 120", resp.Data.TTLSeconds) + } + if store.gotDevice != "0xAAAA0001" { + t.Errorf("Issue deviceID = %q, want 0xAAAA0001(token 綁 serial)", store.gotDevice) + } +} + +// TestIssueToken_Limit:達上限 → 429 LOCAL_TOKEN_LIMIT。 +func TestIssueToken_Limit(t *testing.T) { + store := &fakeStore{err: errors.New("limit"), isLimit: true} + h := &LocalHandler{store: store} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/api/local/issue-token", + strings.NewReader(`{"serial":"0xAAAA0001"}`)) + c.Request.Header.Set("Content-Type", "application/json") + h.IssueToken(c) + + if w.Code != http.StatusTooManyRequests { + t.Fatalf("status = %d, want 429", w.Code) + } + if !strings.Contains(w.Body.String(), "LOCAL_TOKEN_LIMIT") { + t.Errorf("body 應含 LOCAL_TOKEN_LIMIT,got %s", w.Body.String()) + } +} + +// TestIssueToken_MissingSerial:缺 serial → 400。 +func TestIssueToken_MissingSerial(t *testing.T) { + h := &LocalHandler{store: &fakeStore{}} + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/api/local/issue-token", + strings.NewReader(`{}`)) + c.Request.Header.Set("Content-Type", "application/json") + h.IssueToken(c) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } +} diff --git a/local-agent/server/internal/api/local_middleware.go b/local-agent/server/internal/api/local_middleware.go new file mode 100644 index 0000000..ee609e3 --- /dev/null +++ b/local-agent/server/internal/api/local_middleware.go @@ -0,0 +1,97 @@ +package api + +import ( + "errors" + "log" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +// ADR-019 §2.4.1 + §4.3.1:本機直連 upload route 的 token 驗證 + size 上限中介。 +// +// size 上限(M1,各 route 自己的值)。video 硬牆 ≤ 500MB(前端正常上限 90MB, +// 但 server 端硬牆設 500MB 作為 DoS 上界——攻擊面不因走 loopback 而縮小)。 +// batch 合計 ≤ 80MB。image 沿用 batch 上界即可(單檔遠小於此)。 +const ( + maxVideoUploadBytes = 500 * 1024 * 1024 // 500MB + maxBatchUploadBytes = 80 * 1024 * 1024 // 80MB(合計) + maxImageUploadBytes = 80 * 1024 * 1024 // 80MB(單檔,寬鬆上界) +) + +// tokenConsumer 抽象 TokenStore.Consume,方便測試注入。 +type tokenConsumer interface { + Consume(token, deviceID string) error +} + +// LocalUploadGuard 是本機直連 upload route 的中介,順序如下(安全關鍵): +// +// 1. 先要求 X-Visiona-Local-Token header——缺失即 401(一律要 token、不看 Origin,C1)。 +// 2. 用 http.MaxBytesReader 把 request body 包上 maxBytes 硬牆—— +// 在讀取 multipart body 之前就限制總位元組,避免「未驗證就先收無上限大檔」(M1)。 +// 3. 解析出 deviceId(PostForm 觸發 multipart 解析,但已被 MaxBytesReader 上限保護)。 +// 若超過上限 → ParseMultipartForm 回 *http.MaxBytesError → 413 LOCAL_UPLOAD_TOO_LARGE。 +// 4. Consume(token, deviceId)——single-flight 持鎖(查存在+比對+刪除同一 Lock,防 race,m2); +// deviceId 綁定不符 / 過期 / 已用 / 不存在 → 401 LOCAL_TOKEN_INVALID。 +// 5. 通過 → c.Next() 進既有 handler(handler 業務邏輯零改動、直接 FormFile 讀已快取的表單)。 +// +// 稽核 log:consume 成功/失敗記 deviceId + 時間,絕不 log token 明文。 +// +// 為什麼 deviceId 取自表單而非 token:token 在 issue 時已綁 deviceId,Consume 會用 +// ConstantTimeCompare 驗證「表單 deviceId == token 綁定 deviceId」,兩者不符即 401。 +// 表單 deviceId 是既有 handler 本來就讀的欄位(api-spec §6.2 body 格式不變)。 +func LocalUploadGuard(store tokenConsumer, maxBytes int64) gin.HandlerFunc { + return func(c *gin.Context) { + token := c.GetHeader("X-Visiona-Local-Token") + if token == "" { + respondTokenInvalid(c) + return + } + + // M1:body 硬牆。放在解析 multipart 之前。 + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes) + + // 觸發 multipart 解析取 deviceId。body 已被 MaxBytesReader 上限保護。 + // 解析錯誤要區分「超過 size 上限(413)」與「其他 400」。 + if err := c.Request.ParseMultipartForm(32 << 20); err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"success": false, "error": gin.H{ + "code": "LOCAL_UPLOAD_TOO_LARGE", "message": "upload exceeds size limit", + }}) + c.Abort() + return + } + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{ + "code": "BAD_REQUEST", "message": "invalid multipart form", + }}) + c.Abort() + return + } + + deviceID := c.Request.FormValue("deviceId") + + if err := store.Consume(token, deviceID); err != nil { + // 稽核:consume 失敗(deviceId + 時間,不含 token)。 + log.Printf("[local-token] consume REJECTED deviceId=%s ts=%s", + deviceID, time.Now().UTC().Format(time.RFC3339)) + respondTokenInvalid(c) + return + } + + // 稽核:consume 成功。 + log.Printf("[local-token] consume OK deviceId=%s ts=%s", + deviceID, time.Now().UTC().Format(time.RFC3339)) + + c.Next() + } +} + +// respondTokenInvalid 統一回 401 LOCAL_TOKEN_INVALID 並中止。 +func respondTokenInvalid(c *gin.Context) { + c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": gin.H{ + "code": "LOCAL_TOKEN_INVALID", "message": "missing or invalid upload token", + }}) + c.Abort() +} diff --git a/local-agent/server/internal/api/local_middleware_test.go b/local-agent/server/internal/api/local_middleware_test.go new file mode 100644 index 0000000..0e1c865 --- /dev/null +++ b/local-agent/server/internal/api/local_middleware_test.go @@ -0,0 +1,269 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +// fakeConsumer 是 tokenConsumer 的測試替身,記錄 Consume 被呼叫的參數, +// 並可設定回傳的錯誤(模擬有效 / 已消費 / 過期 / deviceId 不符)。 +type fakeConsumer struct { + called bool + gotToken string + gotDeviceID string + returnErr error +} + +func (f *fakeConsumer) Consume(token, deviceID string) error { + f.called = true + f.gotToken = token + f.gotDeviceID = deviceID + return f.returnErr +} + +// buildMultipart 建一個含 deviceId + file 欄位的 multipart body,回傳 body 與 content-type。 +func buildMultipart(t *testing.T, deviceID string, fileContent []byte) (*bytes.Buffer, string) { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + if err := w.WriteField("deviceId", deviceID); err != nil { + t.Fatal(err) + } + fw, err := w.CreateFormFile("file", "test.mp4") + if err != nil { + t.Fatal(err) + } + if _, err := fw.Write(fileContent); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return &buf, w.FormDataContentType() +} + +// newGuardRouter 建一台掛 LocalUploadGuard 的 router,handler 記錄是否被呼叫並回讀 file。 +func newGuardRouter(store tokenConsumer, maxBytes int64, handlerCalled *bool) *gin.Engine { + r := gin.New() + r.POST("/api/local/media/upload/video", + LocalUploadGuard(store, maxBytes), + func(c *gin.Context) { + *handlerCalled = true + // 模擬既有 handler 讀 file(驗證 middleware 解析後 handler 仍可 FormFile) + _, _, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"formfile_err": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) + }) + return r +} + +// decodeErrCode 從 response body 取出 error.code。 +func decodeErrCode(t *testing.T, body []byte) string { + t.Helper() + var resp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("decode body %q: %v", string(body), err) + } + return resp.Error.Code +} + +// TestLocalUploadGuard_MissingToken:無 X-Visiona-Local-Token → 401 LOCAL_TOKEN_INVALID, +// 且 store.Consume 不被呼叫、handler 不被呼叫。 +func TestLocalUploadGuard_MissingToken(t *testing.T) { + store := &fakeConsumer{} + var handlerCalled bool + r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled) + + body, ct := buildMultipart(t, "dev-1", []byte("small")) + req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body) + req.Header.Set("Content-Type", ct) + // 刻意不帶 token + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", w.Code) + } + if code := decodeErrCode(t, w.Body.Bytes()); code != "LOCAL_TOKEN_INVALID" { + t.Errorf("error code = %q, want LOCAL_TOKEN_INVALID", code) + } + if store.called { + t.Error("無 token 不應呼叫 Consume") + } + if handlerCalled { + t.Error("無 token 不應進入 handler") + } +} + +// TestLocalUploadGuard_ValidToken:有效 token → 放行、Consume 被呼叫且帶正確 token+deviceId、 +// handler 被呼叫。 +func TestLocalUploadGuard_ValidToken(t *testing.T) { + store := &fakeConsumer{returnErr: nil} // Consume 成功 + var handlerCalled bool + r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled) + + body, ct := buildMultipart(t, "dev-42", []byte("video-bytes")) + req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("X-Visiona-Local-Token", "tok-abc") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String()) + } + if !store.called { + t.Fatal("有效 token 應呼叫 Consume") + } + if store.gotToken != "tok-abc" { + t.Errorf("Consume token = %q, want tok-abc", store.gotToken) + } + if store.gotDeviceID != "dev-42" { + t.Errorf("Consume deviceID = %q, want dev-42(取自表單)", store.gotDeviceID) + } + if !handlerCalled { + t.Error("有效 token 應進入 handler") + } +} + +// TestLocalUploadGuard_ConsumedOrExpiredToken:Consume 回 ErrTokenInvalid(已用/過期/deviceId不符) +// → 401 LOCAL_TOKEN_INVALID,handler 不被呼叫。 +func TestLocalUploadGuard_ConsumedOrExpiredToken(t *testing.T) { + store := &fakeConsumer{returnErr: ErrTokenInvalid} + var handlerCalled bool + r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled) + + body, ct := buildMultipart(t, "dev-1", []byte("x")) + req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("X-Visiona-Local-Token", "stale-tok") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", w.Code) + } + if code := decodeErrCode(t, w.Body.Bytes()); code != "LOCAL_TOKEN_INVALID" { + t.Errorf("error code = %q, want LOCAL_TOKEN_INVALID", code) + } + if handlerCalled { + t.Error("無效 token 不應進入 handler") + } +} + +// TestLocalUploadGuard_TokenCheckedBeforeHandler:token 驗證發生在 handler(FormFile 讀檔)之前。 +// 用「Consume 失敗時 handler 不被呼叫」+「Consume 成功時才進 handler」共同證明順序: +// 若 handler 先跑,無效 token 情境下 handlerCalled 會是 true。 +func TestLocalUploadGuard_TokenCheckedBeforeHandler(t *testing.T) { + store := &fakeConsumer{returnErr: ErrTokenInvalid} + var handlerCalled bool + r := newGuardRouter(store, maxVideoUploadBytes, &handlerCalled) + + body, ct := buildMultipart(t, "dev-1", bytes.Repeat([]byte("A"), 1024)) + req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("X-Visiona-Local-Token", "bad") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if handlerCalled { + t.Error("token 驗證失敗時 handler 不得被呼叫(證明 token 檢查在 handler 前)") + } + if !store.called { + t.Error("Consume 應在進 handler 前被呼叫") + } +} + +// TestLocalUploadGuard_TooLarge:body 超過 size 上限 → 413 LOCAL_UPLOAD_TOO_LARGE, +// handler 不被呼叫。用很小的 maxBytes 觸發。 +func TestLocalUploadGuard_TooLarge(t *testing.T) { + const tinyMax = 64 // 64 bytes,遠小於下方 body + store := &fakeConsumer{returnErr: nil} + var handlerCalled bool + r := newGuardRouter(store, tinyMax, &handlerCalled) + + body, ct := buildMultipart(t, "dev-1", bytes.Repeat([]byte("A"), 4096)) + req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("X-Visiona-Local-Token", "tok") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want 413 (body=%s)", w.Code, w.Body.String()) + } + if code := decodeErrCode(t, w.Body.Bytes()); code != "LOCAL_UPLOAD_TOO_LARGE" { + t.Errorf("error code = %q, want LOCAL_UPLOAD_TOO_LARGE", code) + } + if handlerCalled { + t.Error("超過 size 上限不應進入 handler") + } +} + +// TestLocalUploadGuard_TooLarge_BeforeTokenConsumed:超過上限時,即使帶了看似有效的 token, +// 也不應 consume 掉那個 token(size 檢查在 consume 之前,避免大檔攻擊順手燒掉 token)。 +func TestLocalUploadGuard_TooLarge_BeforeTokenConsumed(t *testing.T) { + const tinyMax = 64 + store := &fakeConsumer{returnErr: nil} + var handlerCalled bool + r := newGuardRouter(store, tinyMax, &handlerCalled) + + body, ct := buildMultipart(t, "dev-1", bytes.Repeat([]byte("A"), 4096)) + req := httptest.NewRequest(http.MethodPost, "/api/local/media/upload/video", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("X-Visiona-Local-Token", "tok") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if store.called { + t.Error("超過 size 上限時不應呼叫 Consume(size 檢查在 consume 前)") + } +} + +// TestLocalUploadGuard_ErrorCodeMatchesSpec:確保錯誤碼字串與 api-spec §6.5 完全一致。 +func TestLocalUploadGuard_ErrorCodeMatchesSpec(t *testing.T) { + // 直接驗證 respondTokenInvalid 的輸出格式。 + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + respondTokenInvalid(c) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", w.Code) + } + if !strings.Contains(w.Body.String(), "LOCAL_TOKEN_INVALID") { + t.Errorf("body 應含 LOCAL_TOKEN_INVALID,got %s", w.Body.String()) + } + var resp struct { + Success bool `json:"success"` + } + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp.Success { + t.Error("錯誤回應 success 應為 false") + } +} + +// 確認 ErrTokenInvalid / ErrTokenLimit 是 sentinel(errors.Is 可比對),供 handler/middleware 對應錯誤碼。 +func TestSentinelErrors(t *testing.T) { + if !errors.Is(ErrTokenInvalid, ErrTokenInvalid) { + t.Error("ErrTokenInvalid 應可自比對") + } + if errors.Is(ErrTokenInvalid, ErrTokenLimit) { + t.Error("ErrTokenInvalid 與 ErrTokenLimit 不應相等") + } +} diff --git a/local-agent/server/internal/api/middleware.go b/local-agent/server/internal/api/middleware.go index 5500f40..c437da1 100644 --- a/local-agent/server/internal/api/middleware.go +++ b/local-agent/server/internal/api/middleware.go @@ -1,19 +1,23 @@ package api import ( + "net" "net/http" "net/url" + "os" "strings" "github.com/gin-gonic/gin" ) -// allowedHosts 定義 CORS 白名單的 hostname。 +// allowedHosts 定義 loopback CORS 白名單的 hostname。 // 任何 port 都允許,scheme 只允許 http(本機不可能是 https)。 // // M8-8(TDD v2/cors-security.md §3.1): // v2 模式下 UI 改在使用者瀏覽器中跑,server 同時暴露給其他瀏覽器分頁, // 必須限定 cross-origin 來源在本機 loopback,避免惡意網站透過 CORS 攻擊。 +// +// ADR-019 §2.5:此 loopback 舊規則「保留不動」——不因開放雲端 origin 而變更。 var allowedHosts = map[string]bool{ "127.0.0.1": true, "localhost": true, @@ -21,7 +25,53 @@ var allowedHosts = map[string]bool{ "::1": true, } -// isAllowedOrigin 判斷 Origin header 是否屬於白名單。 +// loopbackHostnames 是 Host header 驗證(ADR-019 §2.5 M2)允許的 hostname 集合。 +// 與 allowedHosts 概念不同:allowedHosts 比對「Origin header 的 hostname」, +// 這裡比對「Host header 的 hostname」——DNS rebinding 防護的獨立第二道。 +var loopbackHostnames = map[string]bool{ + "127.0.0.1": true, + "localhost": true, + "::1": true, +} + +// cloudOrigins 是 ADR-019 §2.5 M3 的雲端 origin 白名單—— +// 存「完整 origin 字串」(scheme+host+port 全等),比對時逐字精確相等。 +// +// 刻意獨立於 loopback 的 isAllowedOrigin(hostname-only + 任意 port + 只收 http): +// - 若沿用 hostname-only,會變成「該網域任意 port 都放行」,攻擊面過大。 +// - 若放寬 scheme 檢查,會讓 http/https 混用可繞過。 +// +// 故雲端 origin 一律走「完整 origin 精確比對」,來源 env VISIONA_CLOUD_ORIGINS。 +// 於 init 時載入一次(server 生命週期內固定)。 +var cloudOrigins = loadCloudOrigins(os.Getenv("VISIONA_CLOUD_ORIGINS")) + +// loadCloudOrigins 解析逗號分隔的完整 origin 字串,回傳精確比對用的 set。 +// +// 每個項目做 TrimSpace,過濾空字串。不做任何 hostname/port 拆解—— +// 白名單存的就是完整 origin,比對時整串相等才通過(ADR-019 §2.5 M3)。 +func loadCloudOrigins(raw string) map[string]bool { + set := make(map[string]bool) + if raw == "" { + return set + } + for _, part := range strings.Split(raw, ",") { + origin := strings.TrimSpace(part) + if origin != "" { + set[origin] = true + } + } + return set +} + +// isAllowedCloudOrigin 判斷 Origin 是否為雲端白名單 origin(完整 origin 精確比對)。 +func isAllowedCloudOrigin(origin string) bool { + if origin == "" { + return false + } + return cloudOrigins[origin] +} + +// isAllowedOrigin 判斷 Origin header 是否屬於 loopback 白名單。 // // 合法例:http://127.0.0.1:3721 / http://localhost:3721 / http://[::1]:3721 // 不合法例:https://127.0.0.1:3721 / http://evil.com / null / http://192.168.1.5:3721 @@ -30,6 +80,8 @@ var allowedHosts = map[string]bool{ // - 空字串視為非白名單(呼叫端會自行決定 same-origin 路徑)。 // - "null"(local file、某些 sandboxed iframe)一律拒絕。 // - 只允許 http scheme,本機不會有 https。 +// +// ADR-019:此函式維持 loopback 舊邏輯不動;雲端 origin 走 isAllowedCloudOrigin。 func isAllowedOrigin(origin string) bool { if origin == "" || origin == "null" { return false @@ -45,14 +97,19 @@ func isAllowedOrigin(origin string) bool { return allowedHosts[host] } -// CORSMiddleware 僅允許 127.0.0.1/localhost/::1 任意 port 的跨來源請求。 +// CORSMiddleware 處理跨來源請求,區分 loopback 與雲端 origin 兩條路徑。 // -// 行為(M8-8 / TDD v2/cors-security.md §4.1): +// 行為(M8-8 / TDD v2/cors-security.md §4.1 + ADR-019 §2.5): // // 1. Origin header 為空 → same-origin(瀏覽器 same-origin 不送 Origin)→ 直接放行; // 若是 OPTIONS 預檢則回 204 即停(避免帶 ACA* 給沒人看的請求)。 -// 2. Origin 在白名單 → 回完整 ACA* headers;OPTIONS → 204;其他方法 → 繼續執行 handler。 -// 3. Origin 不在白名單: +// 2. Origin 在 loopback 白名單 → 回完整 ACA* headers(含 Allow-Credentials: true, +// 沿用 M8-8 既有行為);OPTIONS → 204;其他方法 → 繼續執行 handler。 +// 3. Origin 在雲端白名單(ADR-019)→ 回 ACA* headers,但 +// Allow-Credentials: false(本路徑用 X-Visiona-Local-Token header 帶 token、不需 cookie)、 +// Allow-Headers 含 X-Visiona-Local-Token、Max-Age: 600、 +// 並在 preflight 帶 PNA 請求時回 Access-Control-Allow-Private-Network: true。 +// 4. Origin 都不在白名單: // - state-changing 方法(POST/PUT/DELETE/PATCH/OPTIONS)→ 403 Forbidden,不回 ACA*。 // - 簡單讀取(GET/HEAD)→ 執行 handler 但不回 ACA*,瀏覽器 JS 讀不到 body。 // @@ -74,33 +131,109 @@ func CORSMiddleware() gin.HandlerFunc { return } - if !isAllowedOrigin(origin) { - // 非白名單 Origin - // - state-changing 方法 → 403(嚴格擋) - // - GET/HEAD → 執行但不回 ACA*(瀏覽器層擋) - if method == http.MethodOptions || - method == http.MethodPost || - method == http.MethodPut || - method == http.MethodDelete || - method == http.MethodPatch { - c.AbortWithStatus(http.StatusForbidden) + // 雲端白名單 origin(ADR-019):完整 origin 精確比對,獨立於 loopback。 + if isAllowedCloudOrigin(origin) { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type, X-Visiona-Local-Token") + // M3:雲端 origin 一律 false——用 header 帶 token、不需 cookie, + // 避免無謂讓瀏覽器願意帶 credential 而擴大 CSRF / 憑證面。 + c.Header("Access-Control-Allow-Credentials", "false") + c.Header("Access-Control-Max-Age", "600") + c.Header("Vary", "Origin") + + if method == http.MethodOptions { + // PNA(ADR-019 §2.5,必做):preflight 帶 + // Access-Control-Request-Private-Network: true 且通過白名單 → 回 PNA header。 + // 防未來 Chrome 把 PNA 從 warning 升為 blocking 時舊版 agent 無預警壞掉。 + if c.GetHeader("Access-Control-Request-Private-Network") == "true" { + c.Header("Access-Control-Allow-Private-Network", "true") + } + c.AbortWithStatus(http.StatusNoContent) return } c.Next() return } - // 白名單 Origin:回完整 ACA* headers - c.Header("Access-Control-Allow-Origin", origin) - c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") - c.Header("Access-Control-Allow-Credentials", "true") - c.Header("Vary", "Origin") + // loopback 白名單 origin:沿用 M8-8 既有行為(Allow-Credentials: true)。 + if isAllowedOrigin(origin) { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Visiona-Local-Token") + c.Header("Access-Control-Allow-Credentials", "true") + c.Header("Access-Control-Max-Age", "600") + c.Header("Vary", "Origin") - if method == http.MethodOptions { - c.AbortWithStatus(http.StatusNoContent) + if method == http.MethodOptions { + // loopback 直連也可能帶 PNA preflight(同機不同 port 屬 private network)。 + if c.GetHeader("Access-Control-Request-Private-Network") == "true" { + c.Header("Access-Control-Allow-Private-Network", "true") + } + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + return + } + + // 非白名單 Origin + // - state-changing 方法 → 403(嚴格擋) + // - GET/HEAD → 執行但不回 ACA*(瀏覽器層擋) + if method == http.MethodOptions || + method == http.MethodPost || + method == http.MethodPut || + method == http.MethodDelete || + method == http.MethodPatch { + c.AbortWithStatus(http.StatusForbidden) return } c.Next() } } + +// HostGuard 是 DNS rebinding 的獨立第二道防護(ADR-019 §2.5 M2,必做)。 +// +// 檢查 Host header(去 port 後)必須 ∈ {127.0.0.1, localhost, ::1}, +// 否則 400 Bad Request。與 CORS 正交:CORS 擋 Origin、HostGuard 擋 Host。 +// +// 套用範圍: +// - 所有 /api/local/*(含 WP-2 新增的 /api/local/media/upload/*) +// - 舊 tunnel-path media route(/api/media/upload/*)——關舊 route 的殘留面。 +// +// 為什麼 tunnel 轉發不受影響:tunnel client 轉發到本地 server 時 +// req.URL.Host = 127.0.0.1:(client.go),Host header 本就是 loopback,通過。 +// +// DNS rebinding 情境:攻擊者把 evil.com 重綁到 127.0.0.1, +// fetch('http://evil.com:/...') 實際打到本機、但 Host header 為 +// evil.com: ≠ loopback → 被 400 擋下。 +func HostGuard() gin.HandlerFunc { + return func(c *gin.Context) { + if !isLoopbackHost(c.Request.Host) { + c.AbortWithStatus(http.StatusBadRequest) + return + } + c.Next() + } +} + +// isLoopbackHost 判斷 Host header(可能含 port)的 hostname 是否為 loopback。 +// +// net.SplitHostPort 在無 port 時回 error,此時退回原字串當 hostname。 +// IPv6 的 "[::1]:port" 經 SplitHostPort 會得到 "::1"(去掉方括號), +// 故 loopbackHostnames 存的是 "::1" 而非 "[::1]"。 +func isLoopbackHost(host string) bool { + if host == "" { + return false + } + h, _, err := net.SplitHostPort(host) + if err != nil { + // 無 port(如 "localhost")或格式異常 → 退回原字串比對。 + h = host + } + h = strings.ToLower(strings.TrimSpace(h)) + // 去掉 IPv6 可能殘留的方括號(無 port 的 "[::1]" 這類邊界情況)。 + h = strings.TrimPrefix(h, "[") + h = strings.TrimSuffix(h, "]") + return loopbackHostnames[h] +} diff --git a/local-agent/server/internal/api/middleware_test.go b/local-agent/server/internal/api/middleware_test.go index 5561f95..8cbc424 100644 --- a/local-agent/server/internal/api/middleware_test.go +++ b/local-agent/server/internal/api/middleware_test.go @@ -3,6 +3,7 @@ package api import ( "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" @@ -199,3 +200,253 @@ func TestCORSMiddleware_SameOrigin(t *testing.T) { t.Errorf("same-origin 不應回 ACA-Origin,got %q", got) } } + +// ----- ADR-019 WP-1:雲端 origin 精確比對 + PNA + HostGuard ----- + +// TestLoadCloudOrigins 驗證 VISIONA_CLOUD_ORIGINS 解析(逗號分隔、TrimSpace、過濾空字串)。 +func TestLoadCloudOrigins(t *testing.T) { + cases := []struct { + name string + raw string + want map[string]bool + }{ + {"empty", "", map[string]bool{}}, + {"single", "https://stage-9527.innovedus.com:9527", + map[string]bool{"https://stage-9527.innovedus.com:9527": true}}, + {"multi with spaces", " https://a.com:443 , http://192.168.0.130:9527 ", + map[string]bool{"https://a.com:443": true, "http://192.168.0.130:9527": true}}, + {"trailing comma", "http://localhost:3000,,", + map[string]bool{"http://localhost:3000": true}}, + } + for _, tc := range cases { + got := loadCloudOrigins(tc.raw) + if len(got) != len(tc.want) { + t.Errorf("%s: len = %d, want %d (%v)", tc.name, len(got), len(tc.want), got) + continue + } + for k := range tc.want { + if !got[k] { + t.Errorf("%s: missing origin %q in %v", tc.name, k, got) + } + } + } +} + +// TestIsAllowedCloudOrigin_ExactMatch 驗證雲端 origin 必須 scheme+host+port 全等(M3)。 +// 關鍵:不可像 loopback 那樣 hostname-only + 任意 port。 +func TestIsAllowedCloudOrigin_ExactMatch(t *testing.T) { + // 直接注入測試白名單,避免依賴環境變數。 + saved := cloudOrigins + cloudOrigins = map[string]bool{ + "https://stage-9527.innovedus.com:9527": true, + "http://192.168.0.130:9527": true, + } + defer func() { cloudOrigins = saved }() + + cases := []struct { + origin string + want bool + }{ + // 完全相符 + {"https://stage-9527.innovedus.com:9527", true}, + {"http://192.168.0.130:9527", true}, + + // 同 host 不同 port → 不通過(證明不是 hostname-only) + {"https://stage-9527.innovedus.com:8080", false}, + {"https://stage-9527.innovedus.com", false}, + {"http://192.168.0.130:8080", false}, + + // 同 host 不同 scheme → 不通過(證明不放寬 scheme) + {"http://stage-9527.innovedus.com:9527", false}, + {"https://192.168.0.130:9527", false}, + + // 其他 + {"", false}, + {"null", false}, + {"https://evil.com:9527", false}, + {"https://stage-9527.innovedus.com:9527.evil.com", false}, + } + for _, tc := range cases { + if got := isAllowedCloudOrigin(tc.origin); got != tc.want { + t.Errorf("isAllowedCloudOrigin(%q) = %v, want %v", tc.origin, got, tc.want) + } + } +} + +// newCloudTestRouter 建一台掛 CORSMiddleware 的 router,並注入測試用雲端白名單。 +func newCloudTestRouter(t *testing.T) *gin.Engine { + t.Helper() + saved := cloudOrigins + cloudOrigins = map[string]bool{"https://cloud.example.com:9527": true} + t.Cleanup(func() { cloudOrigins = saved }) + return newTestRouter() +} + +// TestCORSMiddleware_CloudOriginPOST:雲端白名單 origin 的 POST 應放行 + Credentials:false。 +func TestCORSMiddleware_CloudOriginPOST(t *testing.T) { + r := newCloudTestRouter(t) + + req := httptest.NewRequest(http.MethodPost, "/api/do", nil) + req.Header.Set("Origin", "https://cloud.example.com:9527") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://cloud.example.com:9527" { + t.Errorf("ACA-Origin = %q, want cloud origin", got) + } + if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "false" { + t.Errorf("ACA-Credentials = %q, want false (M3)", got) + } + if got := w.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(got, "X-Visiona-Local-Token") { + t.Errorf("ACA-Headers = %q, 必須含 X-Visiona-Local-Token", got) + } +} + +// TestCORSMiddleware_CloudPreflightPNA:雲端 origin preflight 帶 PNA request → 回 PNA header + Max-Age。 +func TestCORSMiddleware_CloudPreflightPNA(t *testing.T) { + r := newCloudTestRouter(t) + + req := httptest.NewRequest(http.MethodOptions, "/api/do", nil) + req.Header.Set("Origin", "https://cloud.example.com:9527") + req.Header.Set("Access-Control-Request-Method", "POST") + req.Header.Set("Access-Control-Request-Private-Network", "true") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", w.Code) + } + if got := w.Header().Get("Access-Control-Allow-Private-Network"); got != "true" { + t.Errorf("ACA-Private-Network = %q, want true (PNA 必做)", got) + } + if got := w.Header().Get("Access-Control-Max-Age"); got != "600" { + t.Errorf("Max-Age = %q, want 600", got) + } + if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "false" { + t.Errorf("ACA-Credentials = %q, want false", got) + } +} + +// TestCORSMiddleware_CloudPreflightNoPNARequest:preflight 未帶 PNA request → 不回 PNA header。 +func TestCORSMiddleware_CloudPreflightNoPNARequest(t *testing.T) { + r := newCloudTestRouter(t) + + req := httptest.NewRequest(http.MethodOptions, "/api/do", nil) + req.Header.Set("Origin", "https://cloud.example.com:9527") + req.Header.Set("Access-Control-Request-Method", "POST") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", w.Code) + } + if got := w.Header().Get("Access-Control-Allow-Private-Network"); got != "" { + t.Errorf("未帶 PNA request 不應回 PNA header,got %q", got) + } +} + +// TestCORSMiddleware_NonWhitelistedCloudPortPOST:同 host 但不在白名單的 port → 403。 +func TestCORSMiddleware_NonWhitelistedCloudPortPOST(t *testing.T) { + r := newCloudTestRouter(t) + + req := httptest.NewRequest(http.MethodPost, "/api/do", nil) + req.Header.Set("Origin", "https://cloud.example.com:8080") // 不同 port + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403(不同 port 不應通過精確比對)", w.Code) + } + if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("不應回 ACA-Origin,got %q", got) + } +} + +// TestCORSMiddleware_LoopbackCredentialsUnchanged:loopback origin 仍回 Credentials:true(M8-8 保留不動)。 +func TestCORSMiddleware_LoopbackCredentialsUnchanged(t *testing.T) { + r := newTestRouter() + + req := httptest.NewRequest(http.MethodGet, "/api/ping", nil) + req.Header.Set("Origin", "http://127.0.0.1:3721") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" { + t.Errorf("loopback ACA-Credentials = %q, want true(ADR-019 保留 loopback 舊規則)", got) + } +} + +// ----- HostGuard ----- + +// newHostGuardRouter 建一台掛 HostGuard 的 router。 +func newHostGuardRouter() *gin.Engine { + r := gin.New() + r.POST("/api/media/upload/video", HostGuard(), func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + return r +} + +// TestHostGuard 驗證 Host header 必須 = loopback,否則 400。 +func TestHostGuard(t *testing.T) { + cases := []struct { + name string + host string + wantCode int + }{ + {"127.0.0.1 with port", "127.0.0.1:3721", http.StatusOK}, + {"localhost with port", "localhost:3721", http.StatusOK}, + {"localhost no port", "localhost", http.StatusOK}, + {"127.0.0.1 no port", "127.0.0.1", http.StatusOK}, + {"ipv6 loopback with port", "[::1]:3721", http.StatusOK}, + {"uppercase LOCALHOST", "LOCALHOST:3721", http.StatusOK}, + // DNS rebinding:Host 為攻擊者網域 → 400 + {"evil domain", "evil.com:3721", http.StatusBadRequest}, + {"evil domain no port", "evil.com", http.StatusBadRequest}, + {"lan ip", "192.168.0.130:9527", http.StatusBadRequest}, + {"public ip", "8.8.8.8:80", http.StatusBadRequest}, + // suffix 攻擊 + {"loopback suffix attack", "127.0.0.1.evil.com:3721", http.StatusBadRequest}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := newHostGuardRouter() + req := httptest.NewRequest(http.MethodPost, "/api/media/upload/video", nil) + req.Host = tc.host + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != tc.wantCode { + t.Errorf("Host=%q → %d, want %d", tc.host, w.Code, tc.wantCode) + } + }) + } +} + +// TestIsLoopbackHost 直接單元測試 host 判斷邏輯。 +func TestIsLoopbackHost(t *testing.T) { + cases := []struct { + host string + want bool + }{ + {"127.0.0.1:3721", true}, + {"127.0.0.1", true}, + {"localhost:8080", true}, + {"localhost", true}, + {"[::1]:3721", true}, + {"::1", true}, + {"", false}, + {"evil.com", false}, + {"evil.com:3721", false}, + {"192.168.0.130:9527", false}, + {"127.0.0.1.evil.com:80", false}, + } + for _, tc := range cases { + if got := isLoopbackHost(tc.host); got != tc.want { + t.Errorf("isLoopbackHost(%q) = %v, want %v", tc.host, got, tc.want) + } + } +} diff --git a/local-agent/server/internal/api/router.go b/local-agent/server/internal/api/router.go index d8f37c8..b57372b 100644 --- a/local-agent/server/internal/api/router.go +++ b/local-agent/server/internal/api/router.go @@ -49,6 +49,12 @@ func NewRouter( deviceHandler := handlers.NewDeviceHandler(deviceMgr, flashSvc, inferenceSvc, wsHub) cameraHandler := handlers.NewCameraHandler(cameraMgr, deviceMgr, inferenceSvc, wsHub) + // ADR-019:本機直連 one-time token store(記憶體、process 生命週期)。 + // 惰性清理 + 背景 goroutine 每 60s 掃過期 token。 + tokenStore := NewTokenStore() + tokenStore.StartCleanup() + localHandler := handlers.NewLocalHandler(deviceMgr, tokenStore) + api := r.Group("/api") { // System @@ -87,11 +93,30 @@ func NewRouter( api.GET("/camera/stream", cameraHandler.StreamMJPEG) // Media - api.POST("/media/upload/image", cameraHandler.UploadImage) - api.POST("/media/upload/video", cameraHandler.UploadVideo) - api.POST("/media/upload/batch-images", cameraHandler.UploadBatchImages) + // ADR-019 §2.5 M2:舊 tunnel-path media upload route 加 HostGuard, + // 關「同機直打舊 route」的殘留面(Host=loopback 才放行)。 + // tunnel 轉發的 Host 本就是 127.0.0.1: 故不受影響。 + api.POST("/media/upload/image", HostGuard(), cameraHandler.UploadImage) + api.POST("/media/upload/video", HostGuard(), cameraHandler.UploadVideo) + api.POST("/media/upload/batch-images", HostGuard(), cameraHandler.UploadBatchImages) api.GET("/media/batch-images/:index", cameraHandler.GetBatchImageFrame) api.POST("/media/seek", cameraHandler.SeekVideo) + + // ADR-019:本機直連支援 endpoint(/api/local/*)。 + // 整組套 HostGuard(M2,Host=loopback 才放行,DNS rebinding 第二道防護)。 + local := api.Group("/local", HostGuard()) + { + // 同機偵測 + 身分驗證(bootstrap,無 token)。 + local.GET("/hello", localHandler.Hello) + // 產 one-time upload token(僅經 tunnel 由 api-server 轉發呼叫;受 HostGuard 約束)。 + local.POST("/issue-token", localHandler.IssueToken) + + // 瀏覽器 localhost 直連 upload route:一律要 token(不看 Origin,C1)+ size 上限(M1)。 + // LocalUploadGuard 在 FormFile 前驗 token + size,通過後轉呼叫既有 handler(業務邏輯零改動)。 + local.POST("/media/upload/video", LocalUploadGuard(tokenStore, maxVideoUploadBytes), cameraHandler.UploadVideo) + local.POST("/media/upload/image", LocalUploadGuard(tokenStore, maxImageUploadBytes), cameraHandler.UploadImage) + local.POST("/media/upload/batch-images", LocalUploadGuard(tokenStore, maxBatchUploadBytes), cameraHandler.UploadBatchImages) + } } // WebSocket @@ -179,8 +204,9 @@ func broadcasterLogger(b *logger.Broadcaster) gin.HandlerFunc { // for Next.js static export client-side routing. // // Next.js static export with generateStaticParams creates: -// /models/index.html — static page -// /models/_/index.html — dynamic route shell (placeholder param '_') +// +// /models/index.html — static page +// /models/_/index.html — dynamic route shell (placeholder param '_') // // For a request like /models/yolov5-face-detection: // 1. Try exact file → not found diff --git a/local-agent/server/internal/api/token_store.go b/local-agent/server/internal/api/token_store.go new file mode 100644 index 0000000..2802c46 --- /dev/null +++ b/local-agent/server/internal/api/token_store.go @@ -0,0 +1,173 @@ +package api + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "sync" + "time" +) + +// ADR-019 §2.4:one-time upload token store。 +// +// 設計(已經 security review 議題 2 通過): +// - token = crypto/rand 32 bytes → base64url(禁 math/rand) +// - TTL 120s、one-time(consume 即刪)、綁 deviceId +// - 記憶體 store(map + 單一 sync.Mutex,不持久化) +// - 未使用上限 32 個(防記憶體 DoS) +// - 比對用 crypto/subtle.ConstantTimeCompare(防 timing attack) +// - consume single-flight:查存在 + 比對 + 刪除三步在同一 Lock 內完成(security m2,防 race) +const ( + tokenTTL = 120 * time.Second + maxUnusedTokens = 32 + tokenCleanupPeriod = 60 * time.Second + tokenRandBytes = 32 +) + +// 明確的錯誤,供 handler 對應到 api-spec §6.5 的錯誤碼。 +var ( + // ErrTokenLimit:未使用 token 達 32 上限(→ 429 LOCAL_TOKEN_LIMIT)。 + ErrTokenLimit = errors.New("local token limit reached") + // ErrTokenInvalid:token 不存在 / 過期 / 已使用 / deviceId 不符(→ 401 LOCAL_TOKEN_INVALID)。 + ErrTokenInvalid = errors.New("local token invalid") +) + +// tokenEntry 是一筆未消費的 token 記錄。 +type tokenEntry struct { + deviceID string + expiresAt time.Time +} + +// TokenStore 是執行緒安全的 one-time token 記憶體 store。 +// +// 併發正確性核心:所有讀寫都在單一 mu 內完成。 +// Consume 是 single-flight——「查存在 + ConstantTimeCompare + 刪除」在同一 Lock() +// 內原子完成,兩個併發 consume 同一 token 不可能都成功(防 one-time 失效)。 +type TokenStore struct { + mu sync.Mutex + tokens map[string]tokenEntry + now func() time.Time // 可注入,方便測試過期邏輯 +} + +// NewTokenStore 建立 store。now 預設為 time.Now。 +func NewTokenStore() *TokenStore { + return &TokenStore{ + tokens: make(map[string]tokenEntry), + now: time.Now, + } +} + +// Issue 產生一個新 token 綁定 deviceID,single-flight 持鎖完成 +// 「清過期 + 查 len < 32 + 插入」。達上限回 ErrTokenLimit。 +// +// token 值以 crypto/rand 產生(32 bytes → base64url RawURL)。 +func (s *TokenStore) Issue(deviceID string) (string, time.Time, error) { + // 先在鎖外產生亂數(crypto/rand 可能較慢,避免長時間持鎖)。 + buf := make([]byte, tokenRandBytes) + if _, err := rand.Read(buf); err != nil { + return "", time.Time{}, err + } + token := base64.RawURLEncoding.EncodeToString(buf) + + s.mu.Lock() + defer s.mu.Unlock() + + // 惰性清理過期 token,順便為上限計算釋放名額。 + s.pruneExpiredLocked() + + if len(s.tokens) >= maxUnusedTokens { + return "", time.Time{}, ErrTokenLimit + } + + expiresAt := s.now().Add(tokenTTL) + s.tokens[token] = tokenEntry{deviceID: deviceID, expiresAt: expiresAt} + return token, expiresAt, nil +} + +// Consume 驗證並消費一個 token(one-time)。single-flight 持鎖: +// 查存在 + 比對 deviceID + 未過期 + 刪除,全部在同一 Lock 內完成。 +// +// 成功 → 回 nil(token 已從 store 移除,不可再用)。 +// 失敗(不存在 / 過期 / deviceId 不符)→ 回 ErrTokenInvalid。 +// +// deviceID 比對用 ConstantTimeCompare(雖然 deviceID 非高機密,維持一致的常數時間比對紀律)。 +func (s *TokenStore) Consume(token, deviceID string) error { + if token == "" { + return ErrTokenInvalid + } + + s.mu.Lock() + defer s.mu.Unlock() + + entry, ok := s.tokens[token] + if !ok { + return ErrTokenInvalid + } + + // 不論後續成功與否,one-time 語意要求「命中即刪」——刪除放在最前面, + // 確保兩個併發 consume 只有第一個拿到 entry、第二個 map 查不到。 + delete(s.tokens, token) + + // 過期檢查(惰性)。 + if !s.now().Before(entry.expiresAt) { + return ErrTokenInvalid + } + + // deviceID 綁定檢查(常數時間比對)。 + if subtle.ConstantTimeCompare([]byte(entry.deviceID), []byte(deviceID)) != 1 { + return ErrTokenInvalid + } + + return nil +} + +// IsLimitErr 回報 err 是否為「達 token 上限」(給 handler 對應 429)。 +// 讓 handlers 套件不需 import sentinel error 即可判斷。 +func (s *TokenStore) IsLimitErr(err error) bool { + return errors.Is(err, ErrTokenLimit) +} + +// pruneExpiredLocked 移除所有已過期的 token。呼叫端必須已持有 mu。 +func (s *TokenStore) pruneExpiredLocked() { + now := s.now() + for tok, entry := range s.tokens { + if !now.Before(entry.expiresAt) { + delete(s.tokens, tok) + } + } +} + +// pruneExpired 是背景 goroutine 用的加鎖版本。 +func (s *TokenStore) pruneExpired() { + s.mu.Lock() + defer s.mu.Unlock() + s.pruneExpiredLocked() +} + +// len 回傳目前未使用 token 數(測試用)。 +func (s *TokenStore) len() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.tokens) +} + +// StartCleanup 啟動背景清理 goroutine,每 tokenCleanupPeriod 掃一次過期 token。 +// 惰性清理(Consume/Issue 時)+ 背景清理雙保險。 +// 回傳 stop 函式(給測試 / graceful shutdown 用)。 +func (s *TokenStore) StartCleanup() (stop func()) { + ticker := time.NewTicker(tokenCleanupPeriod) + done := make(chan struct{}) + go func() { + for { + select { + case <-ticker.C: + s.pruneExpired() + case <-done: + ticker.Stop() + return + } + } + }() + return func() { close(done) } +} diff --git a/local-agent/server/internal/api/token_store_test.go b/local-agent/server/internal/api/token_store_test.go new file mode 100644 index 0000000..648bdf7 --- /dev/null +++ b/local-agent/server/internal/api/token_store_test.go @@ -0,0 +1,213 @@ +package api + +import ( + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +// TestTokenStore_IssueConsume_HappyPath:發放後可消費一次,第二次消費失敗(one-time)。 +func TestTokenStore_IssueConsume_HappyPath(t *testing.T) { + s := NewTokenStore() + + token, expiresAt, err := s.Issue("dev-1") + if err != nil { + t.Fatalf("Issue error: %v", err) + } + if token == "" { + t.Fatal("token 不應為空") + } + if !expiresAt.After(time.Now()) { + t.Errorf("expiresAt %v 應在未來", expiresAt) + } + + // 第一次消費成功 + if err := s.Consume(token, "dev-1"); err != nil { + t.Fatalf("第一次 Consume 應成功,got %v", err) + } + // 第二次消費必失敗(one-time) + if err := s.Consume(token, "dev-1"); !errors.Is(err, ErrTokenInvalid) { + t.Errorf("第二次 Consume 應 ErrTokenInvalid,got %v", err) + } +} + +// TestTokenStore_Consume_DeviceMismatch:deviceId 不符 → ErrTokenInvalid,且 token 已被消費。 +func TestTokenStore_Consume_DeviceMismatch(t *testing.T) { + s := NewTokenStore() + token, _, _ := s.Issue("dev-1") + + // deviceId 不符 → 失敗 + if err := s.Consume(token, "dev-2"); !errors.Is(err, ErrTokenInvalid) { + t.Errorf("deviceId 不符應 ErrTokenInvalid,got %v", err) + } + // 即使不符,token 也應已被移除(命中即刪,防以正確 deviceId 重試) + if err := s.Consume(token, "dev-1"); !errors.Is(err, ErrTokenInvalid) { + t.Errorf("不符後 token 應已被消費,got %v", err) + } +} + +// TestTokenStore_Consume_MissingAndEmpty:不存在 / 空字串 token → ErrTokenInvalid。 +func TestTokenStore_Consume_MissingAndEmpty(t *testing.T) { + s := NewTokenStore() + if err := s.Consume("nonexistent", "dev-1"); !errors.Is(err, ErrTokenInvalid) { + t.Errorf("不存在 token 應 ErrTokenInvalid,got %v", err) + } + if err := s.Consume("", "dev-1"); !errors.Is(err, ErrTokenInvalid) { + t.Errorf("空 token 應 ErrTokenInvalid,got %v", err) + } +} + +// TestTokenStore_Expiry:過期 token 消費失敗。用可注入的 now 模擬時間流逝。 +func TestTokenStore_Expiry(t *testing.T) { + s := NewTokenStore() + base := time.Now() + current := base + s.now = func() time.Time { return current } + + token, _, _ := s.Issue("dev-1") + + // 前進超過 TTL + current = base.Add(tokenTTL + time.Second) + + if err := s.Consume(token, "dev-1"); !errors.Is(err, ErrTokenInvalid) { + t.Errorf("過期 token 應 ErrTokenInvalid,got %v", err) + } +} + +// TestTokenStore_Limit:未使用 token 達 32 上限 → ErrTokenLimit;消費一個後可再發。 +func TestTokenStore_Limit(t *testing.T) { + s := NewTokenStore() + + tokens := make([]string, 0, maxUnusedTokens) + for i := 0; i < maxUnusedTokens; i++ { + tok, _, err := s.Issue("dev-1") + if err != nil { + t.Fatalf("第 %d 個 Issue 不應失敗,got %v", i, err) + } + tokens = append(tokens, tok) + } + + // 第 33 個應被拒 + if _, _, err := s.Issue("dev-1"); !errors.Is(err, ErrTokenLimit) { + t.Errorf("達上限應 ErrTokenLimit,got %v", err) + } + if !s.IsLimitErr(ErrTokenLimit) { + t.Error("IsLimitErr(ErrTokenLimit) 應為 true") + } + + // 消費一個後釋放名額,可再發 + if err := s.Consume(tokens[0], "dev-1"); err != nil { + t.Fatalf("Consume 應成功,got %v", err) + } + if _, _, err := s.Issue("dev-1"); err != nil { + t.Errorf("釋放名額後 Issue 應成功,got %v", err) + } +} + +// TestTokenStore_Limit_ExpiredFreesSlot:過期 token 在 Issue 時被惰性清理,釋放上限名額。 +func TestTokenStore_Limit_ExpiredFreesSlot(t *testing.T) { + s := NewTokenStore() + base := time.Now() + current := base + s.now = func() time.Time { return current } + + for i := 0; i < maxUnusedTokens; i++ { + if _, _, err := s.Issue("dev-1"); err != nil { + t.Fatalf("第 %d 個 Issue 失敗: %v", i, err) + } + } + // 全部過期 + current = base.Add(tokenTTL + time.Second) + + // 再 Issue 應觸發惰性清理、成功 + if _, _, err := s.Issue("dev-1"); err != nil { + t.Errorf("過期清理後 Issue 應成功,got %v", err) + } +} + +// TestTokenStore_ConcurrentConsume_SingleFlight 是 security m2 的關鍵測試: +// 多個 goroutine 同時消費同一 token,必須「恰好一個成功」(防 one-time 失效 / 雙重消費)。 +func TestTokenStore_ConcurrentConsume_SingleFlight(t *testing.T) { + const goroutines = 200 + + // 跑多輪,提高抓到 race 的機率。 + for round := 0; round < 50; round++ { + s := NewTokenStore() + token, _, _ := s.Issue("dev-1") + + var successCount int32 + var wg sync.WaitGroup + start := make(chan struct{}) + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + <-start // 同時起跑,最大化競爭 + if err := s.Consume(token, "dev-1"); err == nil { + atomic.AddInt32(&successCount, 1) + } + }() + } + close(start) + wg.Wait() + + if successCount != 1 { + t.Fatalf("round %d: 併發消費同一 token 成功數 = %d,必須恰好 1(single-flight 失效)", + round, successCount) + } + } +} + +// TestTokenStore_ConcurrentIssue_LimitHeld 是 security m2 的另一半: +// 併發 Issue 時,未使用 token 數不得突破 32 上限。 +func TestTokenStore_ConcurrentIssue_LimitHeld(t *testing.T) { + const goroutines = 200 + + for round := 0; round < 30; round++ { + s := NewTokenStore() + + var wg sync.WaitGroup + start := make(chan struct{}) + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + <-start + _, _, _ = s.Issue("dev-1") + }() + } + close(start) + wg.Wait() + + if got := s.len(); got > maxUnusedTokens { + t.Fatalf("round %d: 併發 Issue 後 token 數 = %d,不得超過上限 %d(上限檢查非 atomic)", + round, got, maxUnusedTokens) + } + } +} + +// TestTokenStore_TokenUniqueness:連續發放的 token 值不重複。 +func TestTokenStore_TokenUniqueness(t *testing.T) { + s := NewTokenStore() + seen := make(map[string]bool) + for i := 0; i < maxUnusedTokens; i++ { + tok, _, err := s.Issue("dev-1") + if err != nil { + t.Fatalf("Issue 失敗: %v", err) + } + if seen[tok] { + t.Fatalf("token 重複: %q", tok) + } + seen[tok] = true + } +} + +// TestTokenStore_Cleanup_StopWorks:StartCleanup 回傳的 stop 可正常關閉 goroutine。 +func TestTokenStore_Cleanup_StopWorks(t *testing.T) { + s := NewTokenStore() + stop := s.StartCleanup() + // 立即停止不應 panic / deadlock + stop() +} diff --git a/visionA-backend/internal/api/api.go b/visionA-backend/internal/api/api.go index f96d6c9..0c0499b 100644 --- a/visionA-backend/internal/api/api.go +++ b/visionA-backend/internal/api/api.go @@ -92,6 +92,12 @@ type Deps struct { // fallback,不需真 tunnel。詳見 device_driver_status.go。 DriverStatusFetcher driverStatusFetcher + // LocalTokenIssuer 是 POST /api/devices/:id/local-upload-ticket(ADR-019 WP-5)的可選注入點。 + // 為 nil 時 handler 從 Forwarder + SessionStore 組 default(forwarderLocalTokenIssuer,經 + // 既有 tunnel 打 local-agent /api/local/issue-token)。unit test 注入 stub 驗成功 / 429 / + // tunnel 錯誤分支,不需真 tunnel。詳見 local_upload_ticket.go。 + LocalTokenIssuer localTokenIssuer + DeviceRepo device.Repository ModelRepo model.Repository diff --git a/visionA-backend/internal/api/devices.go b/visionA-backend/internal/api/devices.go index 0206afa..c8aabe6 100644 --- a/visionA-backend/internal/api/devices.go +++ b/visionA-backend/internal/api/devices.go @@ -39,6 +39,12 @@ func registerDeviceRoutes(g *gin.RouterGroup, deps Deps) { // Unpair(雛形實作:軟刪 DeviceRepo + CloseSession) g.POST("/devices/:id/unpair", devicesUnpairHandler(deps)) + + // ADR-019 WP-5:localhost 直連上傳的 one-time token 取得路徑(經既有 tunnel 打 + // local-agent issue-token)。契約 path 為 /api/devices/:serial/local-upload-ticket, + // 但 gin/httprouter 要求同層級同名,故沿用 :id 佔位(其值語意為裝置序號 serial, + // handler 用它走 GetBySerial 做歸屬檢查)。見 local_upload_ticket.go。 + g.POST("/devices/:id/local-upload-ticket", localUploadTicketHandler(deps)) } // DeviceListItem 是 GET /api/devices 回應中的單筆裝置。 diff --git a/visionA-backend/internal/api/local_upload_ticket.go b/visionA-backend/internal/api/local_upload_ticket.go new file mode 100644 index 0000000..013216b --- /dev/null +++ b/visionA-backend/internal/api/local_upload_ticket.go @@ -0,0 +1,316 @@ +// local_upload_ticket.go — POST /api/devices/:id/local-upload-ticket 的雲端 ticket handler(ADR-019 WP-5)。 +// +// 背景(ADR-019 §2.4 認證流程 [1]):影片 / 圖片 / 批次上傳改走「同機瀏覽器直連 local-agent +// 的 localhost endpoint」(繞過 tunnel,解決大檔頻寬雙倍 + nginx 100M + 300s timeout)。但開放 +// 雲端 origin 直連 local-agent 後,必須有認證作為第二道防線(CORS 白名單擋不住 XSS / DNS +// rebinding)。one-time token 的**取得路徑**刻意保留走既有已認證 tunnel(控制面): +// +// [1] 瀏覽器 → 雲端 api-server:POST /api/devices/:id/local-upload-ticket(本 handler) +// api-server 驗 OIDC session(AuthMiddleware)+ 裝置歸屬(GetBySerial) +// → 經既有 tunnel 轉發打 local-agent:POST /api/local/issue-token(body {"serial":...}) +// → 回傳 local-agent 產的 one-time token 給瀏覽器 +// [2..4] 瀏覽器拿 token 掃 localhost port → 帶 X-Visiona-Local-Token 直連上傳(非本 handler 範圍) +// +// 為什麼走既有 tunnel 而非新機制:token 取得屬控制面,資料量極小(~100 bytes),沿用 devices.go +// 的裝置歸屬檢查 + proxy.go / device_driver_status.go 的 tunnel forward 模式即可,零新基礎設施。 +// +// 契約來源:api-spec.md §6.3(POST /api/devices/:serial/local-upload-ticket + POST +// /api/local/issue-token 的 request / response 形狀)。local-agent 端 /api/local/issue-token +// 由 local-agent stream 另行實作,本 handler **對契約**打即可(path + body 依 api-spec §6.3)。 +// +// 可測性:把「經 tunnel 打 local-agent issue-token」抽成 localTokenIssuer 介面,default 實作 +// 包 session.Forwarder(走既有 proxy 基礎設施),unit test 注入 stub 驗成功 / 各種錯誤分支, +// 不需要真 tunnel。此模式對齊 device_driver_status.go 的 driverStatusFetcher。 + +package api + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "visiona-backend/internal/device" + "visiona-backend/internal/session" +) + +// issueTokenProxyTimeout 是「經 tunnel 打 local-agent issue-token」的整體 timeout。 +// +// 刻意設短(5s):issue-token 是控制面小請求(產一個記憶體 token 立即回),不像 media 上傳可能 +// 很久。不能讓 local-agent hang 住時把「取 ticket」拖到 defaultProxyRequestTimeout(300s) 那麼久 +// ——前端還要拿這個 token 去掃 port,整條互動應該是秒級。 +const issueTokenProxyTimeout = 5 * time.Second + +// localAgentIssueTokenPath 是 local-agent 上「產 one-time upload token」的 endpoint。 +// 對齊 api-spec.md §6.3 POST /api/local/issue-token(僅經既有 tunnel 由 api-server 轉發呼叫)。 +const localAgentIssueTokenPath = "/api/local/issue-token" + +// errCodeLocalTokenLimit 是 local-agent issue-token 回傳的「未使用 token 達上限」錯誤碼 +// (api-spec §6.3 LOCAL_TOKEN_LIMIT)。api-server 據此把 IssueToken 錯誤映射成 429(見 +// writeLocalTokenError)。此碼是 local-agent 產的、非 api-server 對外碼,故不放進 errors.go +// 的雲端錯誤碼常數,只在本檔內部用於解析 local-agent 回應。 +const errCodeLocalTokenLimit = "LOCAL_TOKEN_LIMIT" + +// LocalUploadTicket 是 POST /api/devices/:id/local-upload-ticket 回應的 data 欄位。 +// +// 直接對齊 api-spec.md §6.3:`{ "token", "expiresAt", "ttlSeconds": 120 }`。 +// api-server 透傳 local-agent issue-token 的產出,不改寫欄位語意。 +type LocalUploadTicket struct { + // Token 是 local-agent 產的 one-time upload token(瀏覽器帶 X-Visiona-Local-Token 直連上傳)。 + Token string `json:"token"` + // ExpiresAt 是 token 過期時間(unix milliseconds),對齊 api-spec §6.3。 + ExpiresAt int64 `json:"expiresAt"` + // TTLSeconds 是 token 存活秒數(契約固定 120,由 local-agent 決定;api-server 透傳)。 + TTLSeconds int `json:"ttlSeconds"` +} + +// localTokenIssuer 抽象「經 tunnel 向 local-agent 要一個 one-time upload token」。 +// +// 回傳的 LocalUploadTicket 是 local-agent issue-token 的產出(透傳)。error 語意: +// - errLocalTokenLimit:local-agent 回 429(未使用 token 達 32 上限)→ caller 透傳 429。 +// - session.ErrSessionNotFound / ErrSessionClosed:tunnel 離線 → caller 回 502 TUNNEL_DISCONNECTED。 +// - 其他:local-agent 不可達 / 非預期回應 → caller 回 502 TUNNEL_ERROR。 +// +// default 實作 forwarderLocalTokenIssuer 走既有 session.Forwarder proxy 基礎設施; +// unit test 注入 stub 驗各分支,不需要真 tunnel。 +type localTokenIssuer interface { + // IssueToken 經 tunnel 打 local-agent POST /api/local/issue-token(body {"serial":serial})。 + // userID 用來挑當前 user 的 active session token(與其他 proxy 端點同一套 posture)。 + IssueToken(ctx context.Context, userID, serial string) (LocalUploadTicket, error) +} + +// errLocalTokenLimit 表示 local-agent 回 429(未使用 token 達 32 上限,api-spec §6.3 +// LOCAL_TOKEN_LIMIT)。與傳輸層錯誤(tunnel 離線)語意區隔,讓 handler 能透傳 429 而非 502。 +var errLocalTokenLimit = errors.New("local agent: unused upload token limit reached") + +// issueTokenRequest 是打 local-agent /api/local/issue-token 的 request body(api-spec §6.3)。 +type issueTokenRequest struct { + Serial string `json:"serial"` +} + +// issueTokenEnvelope 是 local-agent /api/local/issue-token 的回應 envelope(api-spec §6.3): +// +// { "success": true, "data": { "token": "...", "expiresAt": , "ttlSeconds": 120 } } +// { "success": false, "error": { "code": "LOCAL_TOKEN_LIMIT" } } (429) +type issueTokenEnvelope struct { + Success bool `json:"success"` + Data LocalUploadTicket `json:"data"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +// forwarderLocalTokenIssuer 是 localTokenIssuer 的 production 實作: +// 透過 session.Forwarder 把 POST /api/local/issue-token 經 tunnel 送到 local-agent。 +type forwarderLocalTokenIssuer struct { + forwarder *session.Forwarder + sessionStore session.Store +} + +// newForwarderLocalTokenIssuer 從 Deps 組出 default issuer。 +// forwarder / sessionStore 任一為 nil 時回 nil(caller 據此回 501,代表 tunnel 未配置)。 +func newForwarderLocalTokenIssuer(deps Deps) localTokenIssuer { + if deps.Forwarder == nil || deps.SessionStore == nil { + return nil + } + return &forwarderLocalTokenIssuer{ + forwarder: deps.Forwarder, + sessionStore: deps.SessionStore, + } +} + +// IssueToken 實作 localTokenIssuer。 +// +// 流程(對齊 device_driver_status.go FetchDriverStatus,但目標是 POST issue-token): +// 1. 挑當前 user 的 active session token(pickActiveSessionToken) +// 2. 組 POST /api/local/issue-token(body {"serial":serial}),經 Forwarder.ForwardHTTP 送到 local-agent +// 3. 解 envelope:429 → errLocalTokenLimit;success + 有 token → 回 ticket;其他 → error +func (i *forwarderLocalTokenIssuer) IssueToken(ctx context.Context, userID, serial string) (LocalUploadTicket, error) { + ctx, cancel := context.WithTimeout(ctx, issueTokenProxyTimeout) + defer cancel() + + token, err := pickActiveSessionToken(ctx, i.sessionStore, userID, nil) + if err != nil { + // tunnel 離線 / 無 active session → 交由 caller 映射 502 TUNNEL_DISCONNECTED。 + return LocalUploadTicket{}, err + } + + body, err := json.Marshal(issueTokenRequest{Serial: serial}) + if err != nil { + return LocalUploadTicket{}, err + } + + outReq, err := http.NewRequestWithContext(ctx, http.MethodPost, localAgentIssueTokenPath, + strings.NewReader(string(body))) + if err != nil { + return LocalUploadTicket{}, err + } + outReq.Header.Set("Content-Type", "application/json") + outReq.ContentLength = int64(len(body)) + + resp, err := i.forwarder.ForwardHTTP(ctx, token, outReq) + if err != nil { + // local-agent 不可達 / dial 失敗 / timeout → caller 映射 502 TUNNEL_ERROR。 + return LocalUploadTicket{}, err + } + defer resp.Body.Close() + + // 限讀 body(issue-token 回應極小;防禦性 64KB 上界,避免異常 local-agent 撐爆記憶體)。 + raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + return LocalUploadTicket{}, err + } + + // 429:token 上限已滿 → 透傳 errLocalTokenLimit(不試著解析成功欄位)。 + if resp.StatusCode == http.StatusTooManyRequests { + return LocalUploadTicket{}, errLocalTokenLimit + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return LocalUploadTicket{}, errLocalTokenUnavailable + } + + var env issueTokenEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return LocalUploadTicket{}, err + } + // 契約允許 local-agent 在 200 body 內用 success:false 表達 LOCAL_TOKEN_LIMIT(雖然主契約走 + // 429,但兩者都映射到 token 上限,防禦性一併處理)。 + if !env.Success { + if env.Error != nil && env.Error.Code == errCodeLocalTokenLimit { + return LocalUploadTicket{}, errLocalTokenLimit + } + return LocalUploadTicket{}, errLocalTokenUnavailable + } + if env.Data.Token == "" { + return LocalUploadTicket{}, errLocalTokenUnavailable + } + return env.Data, nil +} + +// errLocalTokenUnavailable 表示 local-agent 回應存在但沒帶可用 token(非 2xx 且非 429 / +// success:false / 空 token)。與 tunnel 傳輸層錯誤語意區隔,caller 統一映射 502 TUNNEL_ERROR。 +var errLocalTokenUnavailable = errors.New("local agent: upload token unavailable") + +// resolveLocalTokenIssuer 決定要用哪個 issuer: +// - Deps.LocalTokenIssuer 非 nil(測試注入 stub)→ 用它 +// - 否則從 Forwarder + SessionStore 組 default(production) +// - 兩者皆缺 → 回 nil(handler 回 501,代表 tunnel 未配置) +func resolveLocalTokenIssuer(deps Deps) localTokenIssuer { + if deps.LocalTokenIssuer != nil { + return deps.LocalTokenIssuer + } + return newForwarderLocalTokenIssuer(deps) +} + +// localUploadTicketHandler 實作 POST /api/devices/:id/local-upload-ticket(ADR-019 WP-5)。 +// +// 註:route 參數名為 `:id`(gin/httprouter 要求同層級同名,devices.go 既有 /devices/:id/* +// 已佔用 :id),但語意上是**裝置序號(serial)**——契約 path 為 /api/devices/:serial/...。 +// 這裡的 :id 值即 serial,用它走 GetBySerial 做裝置歸屬檢查 + 傳給 local-agent。 +// +// 流程: +// 1. AuthMiddleware 已驗 OIDC session → 取 UserContext(拿不到 = 500,middleware 設定錯誤) +// 2. 裝置歸屬:DeviceRepo.GetBySerial(userID, serial)——查不到 = 該序號不屬於當前 user → 404 +// (沿用 devices.go 既有 owner 檢查慣例;GetBySerial 本身就綁 ownerUserID,天然阻擋 IDOR) +// 3. tunnel_online 檢查(R-3):無 active session → tunnel 離線 → 502 TUNNEL_DISCONNECTED, +// 明確告知前端「裝置離線、無法取得上傳 ticket」(issue token 本就需經 tunnel) +// 4. 經 tunnel 打 local-agent issue-token → 透傳 token;429 透傳;tunnel 錯誤映射 502 +func localUploadTicketHandler(deps Deps) gin.HandlerFunc { + return func(c *gin.Context) { + if deps.DeviceRepo == nil { + WriteNotImplemented(c, "device repo not configured") + return + } + + serial := c.Param("id") // :id 語意為 serial,見 handler 註解 + if serial == "" { + WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "device serial required", nil) + return + } + + // AuthMiddleware 已驗 OIDC session(見 api.go apiGroup);拿不到 UserContext 代表 + // middleware 設定錯誤,回 500 比 silent fallback 安全(對齊 devices.go C1 fix)。 + uc, ok := UserContextFrom(c) + if !ok || uc.UserID == "" { + WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, + "missing user context (auth middleware misconfigured?)", nil) + return + } + userID := uc.UserID + + ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) + defer cancel() + + // 裝置歸屬檢查(沿用 devices.go 慣例):GetBySerial 綁 ownerUserID,查不到即 + // 「該序號不屬於當前 user」或「不存在」,一律回 404(不洩漏「存在但非你的」以免 enumeration)。 + d, err := deps.DeviceRepo.GetBySerial(ctx, userID, serial) + if err != nil { + if errors.Is(err, device.ErrNotFound) { + WriteError(c, http.StatusNotFound, ErrCodeNotFound, + "device not found or not owned by current user", nil) + return + } + // DB 錯誤經 errors.go 映射(PG down → 503、其餘 → 500),不洩漏 raw DB error。 + WriteDBError(c, deps.Logger, "get device by serial", err) + return + } + + issuer := resolveLocalTokenIssuer(deps) + if issuer == nil { + // Forwarder / SessionStore 未配置 → 無法經 tunnel 取 token。回 501(非 500), + // 語意為「此部署未啟用 tunnel forward」,對齊 proxy.go 的 WriteNotImplemented 慣例。 + WriteNotImplemented(c, "tunnel forwarder not configured") + return + } + + // 經 tunnel 打 local-agent issue-token(issuer 內部用 d.SerialNumber 走 tunnel)。 + // 用 DB 記錄的 SerialNumber(已通過歸屬檢查)而非原始 path 值,確保傳給 local-agent 的 + // 序號與雲端 device 記錄一致。 + ticket, err := issuer.IssueToken(c.Request.Context(), userID, d.SerialNumber) + if err != nil { + writeLocalTokenError(c, deps, userID, d.SerialNumber, err) + return + } + + logOrDefault(deps.Logger).Info("local-upload-ticket: issued", + "user_id", userID, + "serial", d.SerialNumber, + "device_id", d.ID, + "ttl_seconds", ticket.TTLSeconds, + "request_id", RequestIDFrom(c)) + + WriteSuccess(c, http.StatusOK, ticket) + } +} + +// writeLocalTokenError 把 IssueToken 的 error 映射到統一 API 錯誤格式。 +// +// - errLocalTokenLimit → 429 RATE_LIMITED(透傳 local-agent 的 token 上限;api-spec §6.3 +// LOCAL_TOKEN_LIMIT 對應 429,這裡用雲端統一的 RATE_LIMITED 碼 + message 標明來源) +// - session.ErrSessionNotFound / ErrSessionClosed → 502 TUNNEL_DISCONNECTED(裝置離線,R-3) +// - 其他 → 502 TUNNEL_ERROR(local-agent 不可達 / 非預期回應) +func writeLocalTokenError(c *gin.Context, deps Deps, userID, serial string, err error) { + switch { + case errors.Is(err, errLocalTokenLimit): + logOrDefault(deps.Logger).Warn("local-upload-ticket: local agent token limit reached", + "user_id", userID, "serial", serial, "request_id", RequestIDFrom(c)) + WriteError(c, http.StatusTooManyRequests, ErrCodeRateLimited, + "上傳 token 已達上限,請稍後再試", nil) + case errors.Is(err, session.ErrSessionNotFound) || errors.Is(err, session.ErrSessionClosed): + // R-3:tunnel 離線時無法取得 token → 明確告知裝置離線(前端據此 disable 上傳)。 + WriteError(c, http.StatusBadGateway, ErrCodeTunnelDisconnect, + "裝置未連線,無法取得上傳 ticket", nil) + default: + logOrDefault(deps.Logger).Warn("local-upload-ticket: issue token failed", + "user_id", userID, "serial", serial, "error", err.Error(), + "request_id", RequestIDFrom(c)) + WriteError(c, http.StatusBadGateway, ErrCodeTunnelError, + "取得上傳 ticket 失敗", nil) + } +} diff --git a/visionA-backend/internal/api/local_upload_ticket_test.go b/visionA-backend/internal/api/local_upload_ticket_test.go new file mode 100644 index 0000000..7c6dbc1 --- /dev/null +++ b/visionA-backend/internal/api/local_upload_ticket_test.go @@ -0,0 +1,204 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/device" + "visiona-backend/internal/session" +) + +// stubLocalTokenIssuer 是 localTokenIssuer 的測試替身。 +// +// 可設定:回傳的 ticket / error,並記錄呼叫參數,用來驗證 handler 是否有嘗試取 token、 +// 以及傳的 serial / userID 正確。 +type stubLocalTokenIssuer struct { + ticket LocalUploadTicket + err error + + called bool + gotUserID string + gotSerial string +} + +func (s *stubLocalTokenIssuer) IssueToken(_ context.Context, userID, serial string) (LocalUploadTicket, error) { + s.called = true + s.gotUserID = userID + s.gotSerial = serial + return s.ticket, s.err +} + +// newLocalTicketFixture 建 router + 塞一顆 device,可注入自訂 Deps 欄位(issuer)。 +// loginUserID 是「已登入 user」(AuthMiddleware 塞的 UserContext),可與 device owner 不同以驗 IDOR。 +func newLocalTicketFixture(t *testing.T, d *device.Device, loginUserID string, mutate func(*Deps)) *gin.Engine { + t.Helper() + repo := device.NewInMemoryRepository() + require.NoError(t, repo.Save(context.Background(), d)) + + r := gin.New() + r.Use(RequestIDMiddleware()) + r.Use(injectStaticUserContext(loginUserID, "")) + g := r.Group("/api") + + deps := Deps{ + DeviceRepo: repo, + SessionStore: &fakeSessionStore{}, + } + if mutate != nil { + mutate(&deps) + } + registerDeviceRoutes(g, deps) + return r +} + +// postLocalTicket 打 POST /api/devices/:serial/local-upload-ticket 並回 (status, 解出的 body)。 +func postLocalTicket(t *testing.T, r *gin.Engine, serial string) (int, map[string]any) { + t.Helper() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/devices/"+serial+"/local-upload-ticket", strings.NewReader("{}")) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "body=%s", w.Body.String()) + return w.Code, body +} + +func ownedDevice() *device.Device { + return &device.Device{ + ID: "dev1", OwnerUserID: "demo-user", Name: "KL520", DeviceType: "kl520", + SerialNumber: "0xB906162C", + RemoteStatus: device.RemoteStatusOnline, + Status: device.USBStatusOnline, + CreatedAt: time.Now().UTC(), + } +} + +// TestLocalTicket_Success 驗證:裝置歸屬 + tunnel 正常時,透傳 local-agent 產的 token。 +func TestLocalTicket_Success(t *testing.T) { + issuer := &stubLocalTokenIssuer{ticket: LocalUploadTicket{ + Token: "tok_abc123", ExpiresAt: 1700000000000, TTLSeconds: 120, + }} + r := newLocalTicketFixture(t, ownedDevice(), "demo-user", + func(d *Deps) { d.LocalTokenIssuer = issuer }) + + code, body := postLocalTicket(t, r, "0xB906162C") + require.Equal(t, http.StatusOK, code, "body=%v", body) + require.Equal(t, true, body["success"]) + + data, ok := body["data"].(map[string]any) + require.True(t, ok, "data 應為物件,body=%v", body) + assert.Equal(t, "tok_abc123", data["token"]) + assert.Equal(t, float64(1700000000000), data["expiresAt"]) + assert.Equal(t, float64(120), data["ttlSeconds"]) + + assert.True(t, issuer.called, "應嘗試打 local agent issue-token") + assert.Equal(t, "0xB906162C", issuer.gotSerial, "應以 device 記錄的序號打 local agent") + assert.Equal(t, "demo-user", issuer.gotUserID, "應帶當前登入 user") +} + +// TestLocalTicket_DeviceNotOwned_404 驗證:serial 不屬於當前登入 user 時回 404, +// 且**完全不打 local agent**(歸屬檢查先於 issue-token)。這是 IDOR 防護的核心路徑。 +func TestLocalTicket_DeviceNotOwned_404(t *testing.T) { + issuer := &stubLocalTokenIssuer{ticket: LocalUploadTicket{Token: "should_not_be_returned"}} + // device owner = demo-user;但登入者是 attacker → GetBySerial(attacker, serial) 查不到。 + r := newLocalTicketFixture(t, ownedDevice(), "attacker", + func(d *Deps) { d.LocalTokenIssuer = issuer }) + + code, body := postLocalTicket(t, r, "0xB906162C") + require.Equal(t, http.StatusNotFound, code, "非 owner 應回 404") + errObj, ok := body["error"].(map[string]any) + require.True(t, ok, "應有 error 物件,body=%v", body) + assert.Equal(t, ErrCodeNotFound, errObj["code"]) + assert.False(t, issuer.called, "非 owner 不該打 local agent(歸屬檢查先擋)") +} + +// TestLocalTicket_UnknownSerial_404 驗證:序號不存在(連 owner 自己都沒這顆)→ 404。 +func TestLocalTicket_UnknownSerial_404(t *testing.T) { + issuer := &stubLocalTokenIssuer{} + r := newLocalTicketFixture(t, ownedDevice(), "demo-user", + func(d *Deps) { d.LocalTokenIssuer = issuer }) + + code, body := postLocalTicket(t, r, "0xDEADBEEF") // owner 有 dev1(0xB906162C) 但無此序號 + require.Equal(t, http.StatusNotFound, code) + errObj, _ := body["error"].(map[string]any) + assert.Equal(t, ErrCodeNotFound, errObj["code"]) + assert.False(t, issuer.called) +} + +// TestLocalTicket_TokenLimit_429 驗證:local-agent 回 token 上限(errLocalTokenLimit)→ +// 透傳 429 RATE_LIMITED,不當成 500 / 502。 +func TestLocalTicket_TokenLimit_429(t *testing.T) { + issuer := &stubLocalTokenIssuer{err: errLocalTokenLimit} + r := newLocalTicketFixture(t, ownedDevice(), "demo-user", + func(d *Deps) { d.LocalTokenIssuer = issuer }) + + code, body := postLocalTicket(t, r, "0xB906162C") + require.Equal(t, http.StatusTooManyRequests, code, "token 上限應透傳 429") + errObj, _ := body["error"].(map[string]any) + assert.Equal(t, ErrCodeRateLimited, errObj["code"]) + assert.True(t, issuer.called) +} + +// TestLocalTicket_TunnelDisconnected_502 驗證:tunnel 離線(session.ErrSessionNotFound)→ +// 502 TUNNEL_DISCONNECTED(R-3:裝置未連線無法取 token,前端據此 disable 上傳)。 +func TestLocalTicket_TunnelDisconnected_502(t *testing.T) { + issuer := &stubLocalTokenIssuer{err: session.ErrSessionNotFound} + r := newLocalTicketFixture(t, ownedDevice(), "demo-user", + func(d *Deps) { d.LocalTokenIssuer = issuer }) + + code, body := postLocalTicket(t, r, "0xB906162C") + require.Equal(t, http.StatusBadGateway, code) + errObj, _ := body["error"].(map[string]any) + assert.Equal(t, ErrCodeTunnelDisconnect, errObj["code"]) +} + +// TestLocalTicket_TunnelError_502 驗證:local-agent 不可達 / 非預期回應(errLocalTokenUnavailable) +// → 502 TUNNEL_ERROR。 +func TestLocalTicket_TunnelError_502(t *testing.T) { + issuer := &stubLocalTokenIssuer{err: errLocalTokenUnavailable} + r := newLocalTicketFixture(t, ownedDevice(), "demo-user", + func(d *Deps) { d.LocalTokenIssuer = issuer }) + + code, body := postLocalTicket(t, r, "0xB906162C") + require.Equal(t, http.StatusBadGateway, code) + errObj, _ := body["error"].(map[string]any) + assert.Equal(t, ErrCodeTunnelError, errObj["code"]) +} + +// TestLocalTicket_NoIssuer_501 驗證:Forwarder/SessionStore 未配置(resolveLocalTokenIssuer +// 回 nil)→ 501 NOT_IMPLEMENTED,而非 panic / 500。 +func TestLocalTicket_NoIssuer_501(t *testing.T) { + // 不注入 LocalTokenIssuer,且 Deps.Forwarder 為 nil → newForwarderLocalTokenIssuer 回 nil。 + // fixture 預設有 SessionStore 但無 Forwarder,故 default issuer 為 nil。 + r := newLocalTicketFixture(t, ownedDevice(), "demo-user", nil) + + code, body := postLocalTicket(t, r, "0xB906162C") + require.Equal(t, http.StatusNotImplemented, code) + errObj, _ := body["error"].(map[string]any) + assert.Equal(t, ErrCodeNotImplemented, errObj["code"]) +} + +// TestResolveLocalTokenIssuer_NilWhenNoForwarder 驗證:Forwarder 為 nil 時 default issuer 為 nil。 +func TestResolveLocalTokenIssuer_NilWhenNoForwarder(t *testing.T) { + assert.Nil(t, resolveLocalTokenIssuer(Deps{SessionStore: &fakeSessionStore{}}), + "Forwarder 為 nil 應回 nil issuer") + assert.Nil(t, resolveLocalTokenIssuer(Deps{}), + "Forwarder + SessionStore 皆 nil 應回 nil issuer") +} + +// TestResolveLocalTokenIssuer_InjectedWins 驗證:Deps.LocalTokenIssuer 非 nil 時優先用注入的 stub。 +func TestResolveLocalTokenIssuer_InjectedWins(t *testing.T) { + stub := &stubLocalTokenIssuer{} + got := resolveLocalTokenIssuer(Deps{LocalTokenIssuer: stub}) + assert.Same(t, stub, got) +} diff --git a/visionA-frontend/src/lib/local-agent.test.ts b/visionA-frontend/src/lib/local-agent.test.ts new file mode 100644 index 0000000..7530488 --- /dev/null +++ b/visionA-frontend/src/lib/local-agent.test.ts @@ -0,0 +1,363 @@ +/** + * local-agent.ts 單元測試(ADR-019 WP-3) + * + * 對契約 mock(不需實機瀏覽器 / 不需 local-agent): + * - computeSerialHash:SHA-256("visiona-local-v1" || serial) hex(用真 Web Crypto 驗算) + * - probeLocalAgentPort:快取 → 3721 → 3722–3740 並發、timeout、無回應 + * - resolveLocalAgent:OK / NOT_FOUND / MISMATCH 三狀態 + * - uploadToLocalAgent:打對 loopback URL、帶 token header、不帶 cookie、錯誤碼映射 + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + LOCAL_AGENT_PORT_END, + LOCAL_AGENT_PORT_START, + LOCAL_TOKEN_HEADER, + LOCAL_UPLOAD_VIDEO_PATH, + PORT_CACHE_KEY, + SERIAL_HASH_SALT, + computeSerialHash, + probeLocalAgentPort, + resolveLocalAgent, + uploadToLocalAgent, +} from "./local-agent"; + +/* -------------------------------------------------------------------------- */ +/* 工具:組 hello envelope、算 hash 的獨立參考實作 */ +/* -------------------------------------------------------------------------- */ + +function helloOk(serialHashes: string[]): Response { + return new Response( + JSON.stringify({ + success: true, + data: { serialHashes, supportsLocalUpload: true }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); +} + +/** 用 Node 內建 crypto 獨立算一次(避免「用 SUT 驗 SUT」)。 */ +async function refHash(serial: string): Promise { + const { createHash } = await import("node:crypto"); + return createHash("sha256") + .update(`${SERIAL_HASH_SALT}${serial}`) + .digest("hex"); +} + +/* -------------------------------------------------------------------------- */ +/* computeSerialHash */ +/* -------------------------------------------------------------------------- */ + +describe("computeSerialHash", () => { + it("SHA-256(salt || serial) 的 lowercase hex,與獨立 node:crypto 實作一致", async () => { + const serial = "KN12345678"; + const got = await computeSerialHash(serial); + const ref = await refHash(serial); + expect(got).toBe(ref); + expect(got).toMatch(/^[0-9a-f]{64}$/); // 64 hex chars, lowercase + }); + + it("salt 常數為 visiona-local-v1(契約校驗,前後端共用寫死)", () => { + expect(SERIAL_HASH_SALT).toBe("visiona-local-v1"); + }); + + it("不同 serial → 不同 hash", async () => { + expect(await computeSerialHash("KN-A")).not.toBe(await computeSerialHash("KN-B")); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* probeLocalAgentPort / resolveLocalAgent(mock fetch) */ +/* -------------------------------------------------------------------------- */ + +describe("port 探測 + 同機判定", () => { + const origFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.sessionStorage?.clear(); + }); + afterEach(() => { + globalThis.fetch = origFetch; + globalThis.sessionStorage?.clear(); + vi.restoreAllMocks(); + }); + + /** 只在指定 port 回 hello,其餘 reject(模擬無回應)。 */ + function mockFetchOnPort(targetPort: number, serialHashes: string[]) { + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes(`:${targetPort}/api/local/hello`)) { + return helloOk(serialHashes); + } + throw new TypeError("Failed to fetch"); // 其他 port 無回應 + }) as typeof fetch; + } + + it("3721 有回應 → 回傳該 port 並寫入快取", async () => { + mockFetchOnPort(3721, ["abc"]); + const hit = await probeLocalAgentPort(); + expect(hit.port).toBe(3721); + expect(globalThis.sessionStorage?.getItem(PORT_CACHE_KEY)).toBe("3721"); + }); + + it("3721 無回應、3730 有回應 → 並發掃描命中 3730", async () => { + mockFetchOnPort(3730, ["abc"]); + const hit = await probeLocalAgentPort(); + expect(hit.port).toBe(3730); + expect(hit.port).toBeGreaterThanOrEqual(LOCAL_AGENT_PORT_START); + expect(hit.port).toBeLessThanOrEqual(LOCAL_AGENT_PORT_END); + }); + + it("快取命中 → 只打快取 port(不重掃全範圍)", async () => { + globalThis.sessionStorage?.setItem(PORT_CACHE_KEY, "3735"); + const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { + if (String(input).includes(":3735/api/local/hello")) return helloOk(["abc"]); + throw new TypeError("Failed to fetch"); + }); + globalThis.fetch = fetchSpy as typeof fetch; + + const hit = await probeLocalAgentPort(); + expect(hit.port).toBe(3735); + // 快取命中應只打 1 次(不對 20 個 port 發請求) + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("快取失效 → 清快取並重掃到真實 port", async () => { + globalThis.sessionStorage?.setItem(PORT_CACHE_KEY, "3739"); // 舊 port 已無 agent + mockFetchOnPort(3721, ["abc"]); + const hit = await probeLocalAgentPort(); + expect(hit.port).toBe(3721); + expect(globalThis.sessionStorage?.getItem(PORT_CACHE_KEY)).toBe("3721"); + }); + + it("全範圍無回應 → probeLocalAgentPort reject", async () => { + globalThis.fetch = vi.fn(async () => { + throw new TypeError("Failed to fetch"); + }) as typeof fetch; + await expect(probeLocalAgentPort()).rejects.toThrow(); + }); + + it("resolveLocalAgent:serial 相符 → OK + port", async () => { + const serial = "KN-DEVICE-1"; + const hash = await refHash(serial); + mockFetchOnPort(3721, [hash, "other"]); + + const res = await resolveLocalAgent(serial); + expect(res.status).toBe("OK"); + expect(res.port).toBe(3721); + }); + + it("resolveLocalAgent:無回應 → NOT_FOUND", async () => { + globalThis.fetch = vi.fn(async () => { + throw new TypeError("Failed to fetch"); + }) as typeof fetch; + const res = await resolveLocalAgent("KN-DEVICE-1"); + expect(res.status).toBe("NOT_FOUND"); + expect(res.port).toBeUndefined(); + }); + + it("resolveLocalAgent:有回應但 serial 不符 → MISMATCH + 清快取", async () => { + const otherHash = await refHash("KN-OTHER-DEVICE"); + mockFetchOnPort(3721, [otherHash]); + + const res = await resolveLocalAgent("KN-DEVICE-1"); + expect(res.status).toBe("MISMATCH"); + expect(res.port).toBeUndefined(); + // MISMATCH 應清快取,避免下次又先撞別台 agent 的 port + expect(globalThis.sessionStorage?.getItem(PORT_CACHE_KEY)).toBeNull(); + }); + + it("hello 回應格式不符(缺 serialHashes)→ 視為該 port 無效", async () => { + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + if (String(input).includes(":3721/api/local/hello")) { + return new Response(JSON.stringify({ success: true, data: { foo: 1 } }), { + status: 200, + }); + } + throw new TypeError("Failed to fetch"); + }) as typeof fetch; + // 3721 格式不符、其他 port 無回應 → 整體 NOT_FOUND + const res = await resolveLocalAgent("KN-DEVICE-1"); + expect(res.status).toBe("NOT_FOUND"); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* uploadToLocalAgent(mock XMLHttpRequest) */ +/* -------------------------------------------------------------------------- */ + +interface FakeXHR { + method?: string; + url?: string; + withCredentials?: boolean; + timeout?: number; + sent?: FormData; + headers: Record; + status: number; + responseText: string; + upload: { onprogress: ((ev: ProgressEvent) => void) | null }; + onload: (() => void) | null; + onerror: (() => void) | null; + ontimeout: (() => void) | null; + open(method: string, url: string): void; + setRequestHeader(k: string, v: string): void; + send(body: FormData): void; + abort(): void; +} + +let lastXhr: FakeXHR | null = null; + +function installXhrMock(responder: (xhr: FakeXHR) => void) { + function XHRMock(this: unknown) { + const xhr: FakeXHR = { + withCredentials: true, // 預設 true,讓測試能驗證 SUT 主動設回 false + timeout: 0, + status: 200, + responseText: "", + headers: {}, + upload: { onprogress: null }, + onload: null, + onerror: null, + ontimeout: null, + open(method: string, url: string) { + xhr.method = method; + xhr.url = url; + }, + setRequestHeader(k: string, v: string) { + xhr.headers[k] = v; + }, + send(body: FormData) { + xhr.sent = body; + queueMicrotask(() => responder(xhr)); + }, + abort() {}, + }; + lastXhr = xhr; + return xhr; + } + (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = + XHRMock as unknown as typeof XMLHttpRequest; +} + +describe("uploadToLocalAgent(XHR mock)", () => { + const origXHR = globalThis.XMLHttpRequest; + beforeEach(() => { + lastXhr = null; + }); + afterEach(() => { + (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest = origXHR; + }); + + function makeForm(): FormData { + const form = new FormData(); + form.append("deviceId", "KN-DEVICE-1"); + form.append( + "file", + new File([new Blob([new Uint8Array(8)])], "v.mp4"), + ); + return form; + } + + it("POST 到 loopback URL、帶 token header、不帶 cookie、回傳 data", async () => { + installXhrMock((xhr) => { + xhr.status = 200; + xhr.responseText = JSON.stringify({ + success: true, + data: { streamUrl: "/api/camera/stream", sourceType: "video", totalFrames: 100 }, + }); + xhr.onload?.(); + }); + + const res = await uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), { + token: "tok-abc", + }); + expect(res.sourceType).toBe("video"); + expect(lastXhr?.method).toBe("POST"); + expect(lastXhr?.url).toBe("http://127.0.0.1:3721/api/local/media/upload/video"); + // 直連 loopback:不帶 cookie + expect(lastXhr?.withCredentials).toBe(false); + // token 走 header、非 URL + expect(lastXhr?.headers[LOCAL_TOKEN_HEADER]).toBe("tok-abc"); + expect(lastXhr?.url).not.toContain("tok-abc"); + expect(lastXhr?.sent?.get("deviceId")).toBe("KN-DEVICE-1"); + }); + + it("401 → 映射 LOCAL_TOKEN_INVALID(envelope 有 code 時保留 code)", async () => { + installXhrMock((xhr) => { + xhr.status = 401; + xhr.responseText = JSON.stringify({ + success: false, + error: { code: "LOCAL_TOKEN_INVALID", message: "token invalid" }, + }); + xhr.onload?.(); + }); + + await expect( + uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), { token: "bad" }), + ).rejects.toMatchObject({ code: "LOCAL_TOKEN_INVALID", status: 401 }); + }); + + it("413 → 映射 LOCAL_UPLOAD_TOO_LARGE", async () => { + installXhrMock((xhr) => { + xhr.status = 413; + xhr.responseText = JSON.stringify({ + success: false, + error: { code: "LOCAL_UPLOAD_TOO_LARGE", message: "too large" }, + }); + xhr.onload?.(); + }); + + await expect( + uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), { token: "t" }), + ).rejects.toMatchObject({ code: "LOCAL_UPLOAD_TOO_LARGE", status: 413 }); + }); + + it("401 無 error envelope → fallback 為 LOCAL_TOKEN_INVALID", async () => { + installXhrMock((xhr) => { + xhr.status = 401; + xhr.responseText = ""; + xhr.onload?.(); + }); + + await expect( + uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), { token: "t" }), + ).rejects.toMatchObject({ code: "LOCAL_TOKEN_INVALID", status: 401 }); + }); + + it("回報上傳進度", async () => { + installXhrMock((xhr) => { + xhr.upload.onprogress?.({ + lengthComputable: true, + loaded: 25, + total: 100, + } as ProgressEvent); + xhr.status = 200; + xhr.responseText = JSON.stringify({ + success: true, + data: { streamUrl: "/s", sourceType: "video" }, + }); + xhr.onload?.(); + }); + + const onProgress = vi.fn(); + await uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), { + token: "t", + onProgress, + }); + expect(onProgress).toHaveBeenCalledWith(25); + }); + + it("已 abort 的 signal → 立即拋 AbortError(不送出)", async () => { + installXhrMock(() => { + /* 不該被呼叫 */ + }); + const ctrl = new AbortController(); + ctrl.abort(); + await expect( + uploadToLocalAgent(3721, LOCAL_UPLOAD_VIDEO_PATH, makeForm(), { + token: "t", + signal: ctrl.signal, + }), + ).rejects.toMatchObject({ code: "ABORTED" }); + }); +}); diff --git a/visionA-frontend/src/lib/local-agent.ts b/visionA-frontend/src/lib/local-agent.ts new file mode 100644 index 0000000..3d17e70 --- /dev/null +++ b/visionA-frontend/src/lib/local-agent.ts @@ -0,0 +1,443 @@ +/** + * Local-agent localhost 直連工具 — visionA Cloud 前端(ADR-019 WP-3) + * + * 職責(純 client util、endpoint 無關): + * - `probeLocalAgentPort()`:探測本機 local-agent 的動態 port + * (sessionStorage 快取 → 3721 → 3722–3740 並發,各 timeout 500ms,打 GET /api/local/hello)。 + * - `resolveLocalAgent(serial)`:探測 + 同機判定 + serial 身分驗證,回傳 { port } 或 + * LOCAL_AGENT_NOT_FOUND(無回應)/ LOCAL_AGENT_MISMATCH(有回應但 serial 不符)。 + * - `uploadToLocalAgent(port, path, form, options)`:endpoint 無關的通用上傳函式 + * (三個 caller 換 path 即可),打 http://127.0.0.1:,帶 X-Visiona-Local-Token。 + * + * 為什麼是獨立檔(不併進 media.ts): + * media.ts 的上傳走雲端 same-origin cookie session(BFF);local-agent 直連走的是 + * loopback 絕對 URL + one-time token header,認證模型完全不同(ADR-019 §2.1 混合路徑)。 + * 拆開避免兩套認證邏輯混淆。 + * + * 契約來源(不自行更改,見 api-spec §6.2–6.5 / ADR-019 §2.3): + * - port 範圍 3721–3740(local-agent pickPort 動態) + * - GET /api/local/hello 回 { serialHashes: string[], supportsLocalUpload: boolean } + * - serialHashes[i] = SHA-256("visiona-local-v1" || fullSerial) 的 lowercase hex + * - salt 常數 "visiona-local-v1" 前後端共用寫死 + * - 上傳路徑 /api/local/media/upload/{video|image|batch-images},Header X-Visiona-Local-Token + */ + +import { + AbortError, + ApiError, + NetworkError, + TimeoutError, +} from "@/lib/api"; +import type { ApiErrorShape } from "@/types/api"; +import type { MediaUploadResponse } from "@/types/camera"; +import type { UploadMediaOptions } from "@/lib/media"; + +/* -------------------------------------------------------------------------- */ +/* 契約常數 */ +/* -------------------------------------------------------------------------- */ + +/** local-agent loopback host(強制綁 127.0.0.1,見 local-agent server/config.go)。 */ +export const LOCAL_AGENT_HOST = "127.0.0.1"; + +/** port 探測範圍(local-agent pickPort 3721 → 3740 fallback,ADR-019 §2.3)。 */ +export const LOCAL_AGENT_PORT_START = 3721; +export const LOCAL_AGENT_PORT_END = 3740; + +/** 每次探測單一 port 的 timeout(毫秒,ADR-019 §2.3)。 */ +export const PROBE_TIMEOUT_MS = 500; + +/** sessionStorage 快取「上次探到的 port」的 key(同分頁 session 內免重掃)。 */ +export const PORT_CACHE_KEY = "visiona.localAgent.port"; + +/** 同機偵測 / 身分驗證用的固定公開 salt(前後端共用寫死,api-spec §6.3;**不可改**)。 */ +export const SERIAL_HASH_SALT = "visiona-local-v1"; + +/** 探測 endpoint 路徑(專用、非 /api/system/health,ADR-019 §2.3)。 */ +export const LOCAL_HELLO_PATH = "/api/local/hello"; + +/** 上傳 token 的 request header 名(api-spec §6.2)。 */ +export const LOCAL_TOKEN_HEADER = "X-Visiona-Local-Token"; + +/** 直連上傳 route 路徑(endpoint 無關函式的 caller 換這三個之一)。 */ +export const LOCAL_UPLOAD_IMAGE_PATH = "/api/local/media/upload/image"; +export const LOCAL_UPLOAD_VIDEO_PATH = "/api/local/media/upload/video"; +export const LOCAL_UPLOAD_BATCH_PATH = "/api/local/media/upload/batch-images"; + +/* -------------------------------------------------------------------------- */ +/* 型別 */ +/* -------------------------------------------------------------------------- */ + +/** GET /api/local/hello 回傳的 data(api-spec §6.3,最小揭露)。 */ +export interface LocalHelloData { + /** SHA-256("visiona-local-v1" || fullSerial) 的 lowercase hex 陣列。 */ + serialHashes: string[]; + /** 是否支援 local upload(布林,取代原 agentVersion)。 */ + supportsLocalUpload: boolean; +} + +/** 探測到的單一候選(某 port 有回應且回了 hello data)。 */ +interface ProbeHit { + port: number; + data: LocalHelloData; +} + +/** + * resolveLocalAgent 的結果碼(對齊 api-spec §6.5 前端內部狀態): + * - OK 找到同機且 serial 相符的 agent + * - NOT_FOUND 掃描無任何回應(非同機 / agent 沒跑)→ LOCAL_AGENT_NOT_FOUND + * - MISMATCH 有回應但沒有任一 serial 相符 → LOCAL_AGENT_MISMATCH + */ +export type LocalAgentResolveStatus = "OK" | "NOT_FOUND" | "MISMATCH"; + +export interface LocalAgentResolveResult { + status: LocalAgentResolveStatus; + /** status === "OK" 時為探到的 port;否則 undefined。 */ + port?: number; +} + +/* -------------------------------------------------------------------------- */ +/* SHA-256 serial hash(Web Crypto,前端獨立重算比對) */ +/* -------------------------------------------------------------------------- */ + +/** + * 算 `SHA-256("visiona-local-v1" || serial)` 的 lowercase hex。 + * + * 用 Web Crypto `crypto.subtle.digest`(jsdom / Node 20+ / Chrome / Edge 皆有)。 + * 與後端 salted SHA-256 契約一致(api-spec §6.3),供同機身分比對。 + */ +export async function computeSerialHash(serial: string): Promise { + const bytes = new TextEncoder().encode(`${SERIAL_HASH_SALT}${serial}`); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return bytesToHex(new Uint8Array(digest)); +} + +/** Uint8Array → lowercase hex 字串(與後端 hex.EncodeToString 對齊)。 */ +function bytesToHex(bytes: Uint8Array): string { + let hex = ""; + for (const b of bytes) { + hex += b.toString(16).padStart(2, "0"); + } + return hex; +} + +/* -------------------------------------------------------------------------- */ +/* sessionStorage 快取 */ +/* -------------------------------------------------------------------------- */ + +/** 讀 sessionStorage 快取的 port(無效 / 不存在 / 超出範圍 → null)。 */ +function readCachedPort(): number | null { + try { + const raw = globalThis.sessionStorage?.getItem(PORT_CACHE_KEY); + if (!raw) return null; + const port = Number.parseInt(raw, 10); + if ( + Number.isInteger(port) && + port >= LOCAL_AGENT_PORT_START && + port <= LOCAL_AGENT_PORT_END + ) { + return port; + } + return null; + } catch { + // sessionStorage 不可用(SSR / 隱私模式)→ 當作無快取 + return null; + } +} + +/** 寫 sessionStorage 快取(失敗靜默——快取只是最佳化,不可用不影響功能)。 */ +function writeCachedPort(port: number): void { + try { + globalThis.sessionStorage?.setItem(PORT_CACHE_KEY, String(port)); + } catch { + // ignore:快取寫入失敗不影響探測結果 + } +} + +/** 清掉快取(快取的 port 探測失敗時呼叫,避免下次又先撞舊 port)。 */ +function clearCachedPort(): void { + try { + globalThis.sessionStorage?.removeItem(PORT_CACHE_KEY); + } catch { + // ignore + } +} + +/* -------------------------------------------------------------------------- */ +/* 單一 port 探測 */ +/* -------------------------------------------------------------------------- */ + +/** + * 打單一 port 的 GET /api/local/hello,timeout 500ms。 + * + * 回傳 ProbeHit(成功且拿到合法 hello data)或 reject(無回應 / 非預期格式 / timeout)。 + * 這裡**只判斷「有沒有 local-agent 在這個 port」**,不做 serial 比對(比對在 resolveLocalAgent)。 + */ +async function probeSinglePort(port: number): Promise { + const url = `http://${LOCAL_AGENT_HOST}:${port}${LOCAL_HELLO_PATH}`; + const ctrl = new AbortController(); + const timeoutId = setTimeout(() => ctrl.abort("probe-timeout"), PROBE_TIMEOUT_MS); + + try { + const res = await fetch(url, { + method: "GET", + signal: ctrl.signal, + // 直連 local-agent 用 header token、不需 cookie(api-spec §6.4 Allow-Credentials: false) + credentials: "omit", + }); + if (!res.ok) { + throw new Error(`hello returned HTTP ${res.status}`); + } + const parsed: unknown = await res.json(); + const data = extractHelloData(parsed); + if (!data) { + throw new Error("hello response shape invalid"); + } + return { port, data }; + } finally { + clearTimeout(timeoutId); + } +} + +/** 從 hello 回應解 envelope 取 data,並驗證 serialHashes 型別。回傳 null 表格式不符。 */ +function extractHelloData(parsed: unknown): LocalHelloData | null { + if ( + !parsed || + typeof parsed !== "object" || + !("success" in parsed) || + (parsed as { success: unknown }).success !== true || + !("data" in parsed) + ) { + return null; + } + const data = (parsed as { data: unknown }).data; + if (!data || typeof data !== "object" || !("serialHashes" in data)) { + return null; + } + const hashes = (data as { serialHashes: unknown }).serialHashes; + if (!Array.isArray(hashes) || !hashes.every((h) => typeof h === "string")) { + return null; + } + const supports = + "supportsLocalUpload" in data + ? Boolean((data as { supportsLocalUpload: unknown }).supportsLocalUpload) + : false; + return { serialHashes: hashes as string[], supportsLocalUpload: supports }; +} + +/* -------------------------------------------------------------------------- */ +/* port 探測(快取 → 3721 → 3722–3740 並發) */ +/* -------------------------------------------------------------------------- */ + +/** + * 探測本機 local-agent 的 port,回傳第一個有回應者的 ProbeHit。 + * + * 策略(ADR-019 §2.3): + * 1. sessionStorage 快取的 port(同分頁 session 內免重掃) + * 2. 3721(最常見的預設 port) + * 3. 3722–3740 並發(Promise.any 取第一個成功者) + * 每次 timeout 500ms。全部失敗 → reject(呼叫端視為 NOT_FOUND)。 + * + * 為什麼分三段而非一次全並發: + * 快取 / 3721 命中率最高,先試可避免每次都對 20 個 port 發 preflight(成本 + 觸發防火牆告警)。 + */ +export async function probeLocalAgentPort(): Promise { + // 1) 快取 + const cached = readCachedPort(); + if (cached !== null) { + try { + const hit = await probeSinglePort(cached); + writeCachedPort(hit.port); + return hit; + } catch { + // 快取失效(agent 換 port / 沒跑)→ 清掉,往下重掃 + clearCachedPort(); + } + } + + // 2) 3721 預設 port(避開重複試快取剛失敗的那個) + if (cached !== LOCAL_AGENT_PORT_START) { + try { + const hit = await probeSinglePort(LOCAL_AGENT_PORT_START); + writeCachedPort(hit.port); + return hit; + } catch { + // 往下並發掃剩餘範圍 + } + } + + // 3) 3722–3740 並發,取第一個成功者 + const rest: number[] = []; + for (let p = LOCAL_AGENT_PORT_START + 1; p <= LOCAL_AGENT_PORT_END; p++) { + if (p !== cached) rest.push(p); + } + if (rest.length === 0) { + throw new NetworkError("No local-agent found on any candidate port"); + } + try { + const hit = await Promise.any(rest.map((p) => probeSinglePort(p))); + writeCachedPort(hit.port); + return hit; + } catch { + // Promise.any 全 reject → AggregateError + throw new NetworkError("No local-agent found on any candidate port"); + } +} + +/* -------------------------------------------------------------------------- */ +/* 同機判定 + serial 身分驗證 */ +/* -------------------------------------------------------------------------- */ + +/** + * 探測 + 同機判定 + serial 身分驗證。 + * + * @param serial 目前選定裝置的 serialNumber(kn_number;ADR-018 serial 路由) + * @returns + * - { status: "OK", port } 找到同機 agent 且其 serialHashes 含此 serial 的雜湊 + * - { status: "NOT_FOUND" } 掃描無回應(非同機 / agent 沒跑)→ LOCAL_AGENT_NOT_FOUND + * - { status: "MISMATCH" } 有回應但無任一 serial 相符(同機跑著別台 agent)→ LOCAL_AGENT_MISMATCH + * + * 為什麼比對 serial:防「同機跑著另一台 agent、影片被送到錯的裝置」的靜默錯誤(ADR-019 §2.3 / R-4)。 + */ +export async function resolveLocalAgent( + serial: string, +): Promise { + let hit: ProbeHit; + try { + hit = await probeLocalAgentPort(); + } catch { + return { status: "NOT_FOUND" }; + } + + const expected = await computeSerialHash(serial); + if (hit.data.serialHashes.includes(expected)) { + return { status: "OK", port: hit.port }; + } + // 有回應但 serial 不符 → 快取的 port 可能是別台 agent,清掉避免誤導下次 + clearCachedPort(); + return { status: "MISMATCH" }; +} + +/* -------------------------------------------------------------------------- */ +/* 通用上傳(endpoint 無關,帶 token header) */ +/* -------------------------------------------------------------------------- */ + +/** uploadToLocalAgent 的選項:沿用 media 的進度 / 取消 / timeout,加 token。 */ +export interface LocalUploadOptions extends UploadMediaOptions { + /** one-time upload token(經雲端 ticket 取得,放 X-Visiona-Local-Token header)。 */ + token: string; +} + +/** + * 以 XHR + FormData 直連 local-agent 上傳 multipart(endpoint 無關)。 + * + * 三個 caller(image / video / batch)只需換 `path`: + * LOCAL_UPLOAD_IMAGE_PATH | LOCAL_UPLOAD_VIDEO_PATH | LOCAL_UPLOAD_BATCH_PATH + * + * @param port probeLocalAgentPort / resolveLocalAgent 探到的 port + * @param path 直連上傳 route 相對路徑(/api/local/media/upload/*) + * @param form 已組好的 FormData(含 deviceId + file/files,格式與雲端 route 相同) + * @param options token + 進度 / 取消 / timeout + * @returns 解開 envelope 的 MediaUploadResponse(與雲端 route 回傳格式相同) + * @throws ApiError | NetworkError | TimeoutError | AbortError(與 api.ts 一致的錯誤體系) + * + * 認證與雲端 route 不同: + * - 走絕對 loopback URL(http://127.0.0.1:),非 same-origin + * - credentials 不帶 cookie(withCredentials = false;api-spec §6.4 Allow-Credentials: false) + * - token 放 header X-Visiona-Local-Token(不放 URL,避免洩漏到 log / referrer) + */ +export function uploadToLocalAgent( + port: number, + path: string, + form: FormData, + options: LocalUploadOptions, +): Promise { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const url = `http://${LOCAL_AGENT_HOST}:${port}${normalizedPath}`; + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", url, true); + // 直連 loopback:不帶 cookie(token 走 header) + xhr.withCredentials = false; + // 不設 Content-Type,讓瀏覽器帶 multipart boundary + xhr.setRequestHeader(LOCAL_TOKEN_HEADER, options.token); + + if (options.timeoutMs && options.timeoutMs > 0) { + xhr.timeout = options.timeoutMs; + } + + if (options.onProgress) { + xhr.upload.onprogress = (ev) => { + if (ev.lengthComputable) { + options.onProgress!( + Math.min(100, Math.round((ev.loaded / ev.total) * 100)), + ); + } + }; + } + + xhr.onload = () => { + let parsed: unknown = null; + try { + parsed = xhr.responseText ? JSON.parse(xhr.responseText) : null; + } catch { + // 非 JSON body + } + + if (xhr.status >= 200 && xhr.status < 300) { + if ( + parsed && + typeof parsed === "object" && + "success" in parsed && + (parsed as { success: boolean }).success === true && + "data" in parsed + ) { + resolve((parsed as { data: MediaUploadResponse }).data); + return; + } + reject( + new ApiError(xhr.status, { + code: "PARSE_ERROR", + message: "Unexpected upload response shape", + }), + ); + return; + } + + // non-2xx:盡量取 envelope 的 error(LOCAL_TOKEN_INVALID / LOCAL_UPLOAD_TOO_LARGE 等) + let errShape: ApiErrorShape = { + code: xhr.status === 401 ? "LOCAL_TOKEN_INVALID" : "INTERNAL_ERROR", + message: `Local upload failed: HTTP ${xhr.status}`, + }; + if ( + parsed && + typeof parsed === "object" && + "error" in parsed && + (parsed as { error?: unknown }).error + ) { + errShape = (parsed as { error: ApiErrorShape }).error; + } + reject(new ApiError(xhr.status, errShape)); + }; + + xhr.onerror = () => reject(new NetworkError(`Local upload to ${url} failed`)); + xhr.ontimeout = () => reject(new TimeoutError(`Local upload to ${url} timed out`)); + + if (options.signal) { + if (options.signal.aborted) { + reject(new AbortError()); + return; + } + options.signal.addEventListener( + "abort", + () => { + xhr.abort(); + reject(new AbortError()); + }, + { once: true }, + ); + } + + xhr.send(form); + }); +} diff --git a/visionA-frontend/src/lib/media.test.ts b/visionA-frontend/src/lib/media.test.ts index f5c32f8..5644c99 100644 --- a/visionA-frontend/src/lib/media.test.ts +++ b/visionA-frontend/src/lib/media.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MAX_BATCH_IMAGES, + MAX_BATCH_TOTAL_BYTES, MAX_VIDEO_BYTES, VIDEO_FALLBACK_FPS, buildBatchImageUrl, @@ -85,6 +86,41 @@ describe("media validation", () => { expect(validateBatchFiles([makeFile("a.jpg"), makeFile("b.png")])).toBeNull(); }); + + it("批次合計上限:常數為 80 MB(校驗)", () => { + expect(MAX_BATCH_TOTAL_BYTES).toBe(80 * 1024 * 1024); + }); + + it("批次合計上限:50 張各 19MB(合計 950MB)→ TOTAL_SIZE(原地雷)", () => { + const mb = 1024 * 1024; + const files = Array.from({ length: 50 }, (_, i) => + makeSizedFile(`img${i}.jpg`, 19 * mb), + ); + // 逐張都 ≤20MB 會通過單檔檢查,但合計 950MB 應被合計上限擋下 + expect(validateBatchFiles(files)?.code).toBe("TOTAL_SIZE"); + }); + + it("批次合計上限:剛好 80MB 通過、超過 1 byte 擋 TOTAL_SIZE(邊界)", () => { + const mb = 1024 * 1024; + // 8 張各 10MB = 80MB,剛好等於上限(用 > 判斷 → 通過) + const exactly = Array.from({ length: 8 }, (_, i) => + makeSizedFile(`e${i}.jpg`, 10 * mb), + ); + expect(validateBatchFiles(exactly)).toBeNull(); + + // 在 80MB 基礎上多 1 byte → 超過上限 + const over = [ + ...Array.from({ length: 8 }, (_, i) => makeSizedFile(`o${i}.jpg`, 10 * mb)), + makeSizedFile("extra.jpg", 1), + ]; + expect(validateBatchFiles(over)?.code).toBe("TOTAL_SIZE"); + }); + + it("批次合計上限:單檔超限(SIZE)優先於合計檢查", () => { + // 一張 21MB(單檔超 20MB 上限)→ 應回 SIZE 而非 TOTAL_SIZE + const files = [makeSizedFile("big.jpg", 21 * 1024 * 1024)]; + expect(validateBatchFiles(files)?.code).toBe("SIZE"); + }); }); describe("frameToSeekSeconds", () => { diff --git a/visionA-frontend/src/lib/media.ts b/visionA-frontend/src/lib/media.ts index 9ae4a5a..0db3658 100644 --- a/visionA-frontend/src/lib/media.ts +++ b/visionA-frontend/src/lib/media.ts @@ -93,6 +93,16 @@ export const VIDEO_ACCEPT = ".mp4,.avi,.mov,.mpeg,.mpg"; export const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB export const MAX_VIDEO_BYTES = 90 * 1024 * 1024; // 90 MB(過渡值:對齊 nginx client_max_body_size 100M,留 10 MB buffer 給 multipart overhead,避免 HTTP 413。未來影片走 localhost 直連後可放寬) +/** + * 批次上傳「合計大小」上限(對齊 ADR-019 §2.2 / 後端 batch 合計 80MB)。 + * + * 為什麼要加:`validateBatchFiles` 原本只逐張檢查 ≤ MAX_IMAGE_BYTES(20MB)、**不加總**—— + * 50 張各 19MB(合計 950MB)會通過前端驗證後撞後端 size 上限(LOCAL_UPLOAD_TOO_LARGE / 413)。 + * 補此合計上限把地雷擋在前端,給使用者明確錯誤而非到後端才 413。 + * 值 = 80MB(留 nginx 100M 的 20% 餘裕,對 localhost 直連仍保留為資源上界,見 ADR-019 §4.3.1)。 + */ +export const MAX_BATCH_TOTAL_BYTES = 80 * 1024 * 1024; // 80 MB + export interface UploadMediaOptions { /** 上傳進度 callback(0~100) */ onProgress?: (percent: number) => void; @@ -269,9 +279,18 @@ export function buildBatchImageUrl(index: number, cacheBust?: string): string { return `${full}?_t=${encodeURIComponent(cacheBust)}`; } -/** 前端檔案驗證結果(給 UI 顯示錯誤用;不信任副檔名,也擋大小)。 */ +/** + * 前端檔案驗證結果(給 UI 顯示錯誤用;不信任副檔名,也擋大小)。 + * + * code 語意: + * - `TYPE` 副檔名不在白名單 + * - `SIZE` 單檔超過上限(image 20MB / video 90MB) + * - `COUNT` 批次張數超過 MAX_BATCH_IMAGES + * - `EMPTY` 批次為空 + * - `TOTAL_SIZE` 批次「合計」超過 MAX_BATCH_TOTAL_BYTES(ADR-019 §2.2;無 filename) + */ export interface FileValidationError { - code: "TYPE" | "SIZE" | "COUNT" | "EMPTY"; + code: "TYPE" | "SIZE" | "COUNT" | "EMPTY" | "TOTAL_SIZE"; filename?: string; } @@ -297,13 +316,24 @@ export function validateVideoFile(file: File): FileValidationError | null { return null; } -/** 驗證整批圖片(數量 + 每張型別 / 大小)。回傳 null 表通過。 */ +/** + * 驗證整批圖片(數量 + 每張型別 / 大小 + 合計大小)。回傳 null 表通過。 + * + * 檢查順序(先擋明確錯、再累加): + * 1. 空陣列 → EMPTY + * 2. 張數超限 → COUNT + * 3. 逐張型別 / 單檔大小 → TYPE / SIZE(沿用 validateImageFile) + * 4. 合計大小超過 MAX_BATCH_TOTAL_BYTES → TOTAL_SIZE(ADR-019 §2.2,補逐張檢查漏掉的加總地雷) + */ export function validateBatchFiles(files: File[]): FileValidationError | null { if (files.length === 0) return { code: "EMPTY" }; if (files.length > MAX_BATCH_IMAGES) return { code: "COUNT" }; + let totalBytes = 0; for (const f of files) { const err = validateImageFile(f); if (err) return err; + totalBytes += f.size; } + if (totalBytes > MAX_BATCH_TOTAL_BYTES) return { code: "TOTAL_SIZE" }; return null; } diff --git a/visionA-frontend/src/types/api.ts b/visionA-frontend/src/types/api.ts index 3a63094..1c226e8 100644 --- a/visionA-frontend/src/types/api.ts +++ b/visionA-frontend/src/types/api.ts @@ -41,6 +41,12 @@ export type KnownErrorCode = | "NOT_IMPLEMENTED" | "RATE_LIMITED" | "INTERNAL_ERROR" + // ADR-019 local-agent 直連(api-spec §6.5) + | "LOCAL_AGENT_NOT_FOUND" // 前端內部狀態:掃描無回應(非同機 / agent 沒跑) + | "LOCAL_AGENT_MISMATCH" // 前端內部狀態:有回應但 serial 不符 + | "LOCAL_TOKEN_INVALID" // 401:token 不存在 / 過期 / 已使用 / 缺失 + | "LOCAL_TOKEN_LIMIT" // 429:未使用 token 達 32 上限 + | "LOCAL_UPLOAD_TOO_LARGE" // 413:上傳超過 size 上限 // 前端自建,表示「非後端回傳」的錯誤 | "NETWORK_ERROR" | "TIMEOUT"