Compare commits

..

No commits in common. "3eaf3dceb09c2e037f92d1ffb33216305b51ff68" and "c595bb8b911f47b2fbec090aedcca3abed65bfae" have entirely different histories.

22 changed files with 35 additions and 3371 deletions

View File

@ -1,240 +0,0 @@
# ADR-019: 影片 / 圖片 / 批次上傳走同機 localhost 直連 local-agent混合路徑
## 狀態
Proposed尚未實作。**已依 security review`.autoflow/05-implementation/review/adr-019-security-review.md`修正契約C1 / M1 / M2 / M3 / 議題1待 security confirm-only 複審 + 實作完成後轉 Accepted。** C1 定案採方案 Aroute 分離),見 §2.4。
## 日期
2026-07-24
## 作者
Architect Agent
---
## 1. 背景與範圍 (Context)
### 1.1 觸發問題
visionA 的 media 上傳(影片 / 圖片 / 批次)目前**全部經雲端 tunnel forward**(見 `api/api-spec.md §6`「Camera / Media — 與 local-tool 相同,全部透過 tunnel forward」。對影片這種大檔此路徑有三個實質問題
| 問題 | 說明 | 證據 |
|------|------|------|
| **頻寬雙倍佔用** | 500MB 影片經 tunnel = 上傳本機→雲端500MB + 結果下行雲端→本機500MB ≈ 1000MB 總傳輸,兩次都佔用使用者本地頻寬 | 第一輪資料流調查 §5.3 |
| **撞 nginx 上限** | stage nginx 兩處 `client_max_body_size 100M`500MB 影片直接 413 | `docker/nginx.stage.conf:97, 417` |
| **api-server 300s timeout + stage 磁碟落地** | 即使調大 nginx10 Mbps 上行需 400s > 300s timeout`proxy_request_buffering` 預設 on每次上傳在 stage 落地 500MB 暫存檔 | `visionA-backend/internal/api/proxy.go:32, 89-94``camera.go:47` |
### 1.2 被忽略的邏輯漏洞(本 ADR 修補的核心)
`design-doc.md §5.3`(模型上傳)早已建立原則:
> **「模型檔可達百 MB走 tunnel = 占用使用者本地頻寬兩次(上傳→雲端→下載)」**,故模型上傳**不走 tunnel**(改 presigned URL 瀏覽器直連)。
**這個「大檔不走 tunnel」的原則從未套用到 media 上傳**——影片同樣是大檔、同樣佔用雙倍頻寬,卻仍走 tunnel。本 ADR 把同一原則延伸到 media修補此文件內部邏輯漏洞。
### 1.3 有利前提(讀 code 後確認)
- local-agent 已有完整的 loopback HTTP serverGin強制綁 `127.0.0.1:<port>``local-agent/server/main.go:298-305``config.go:34-35,45`)。
- `POST /api/media/upload/video`(及 image / batch**route 已存在於 local-agent路徑與雲端完全一致**`local-agent/server/internal/api/router.go:90-94`。handler`camera_handler.go:232-336`)與 tunnel **完全解耦**tunnel client 只做透明轉發,`internal/tunnel/client.go:337-371`)——瀏覽器直連走的是同一個 handler、同一 code path**handler 零改動**。
### 1.4 使用者已拍板的前提
- **跨機不存在**:使用者確認「都是同機操作」,故非同機情境採「停用該分頁 + 明確提示」,不做 tunnel fallback。這是**產品定位**,不是能力缺口。
- **瀏覽器範圍**:僅需支援 Chrome / EdgeChromium 系。Safari 不在支援範圍。
- **認證必做**:本機 endpoint 經既有 tunnel 下發 one-time token。
---
## 2. 決策 (Decision)
我們將讓瀏覽器在**同機情境**下,直接 POST 影片 / 圖片 / 批次到 local-agent 的 **localhost HTTP endpoint**,繞過雲端 tunnel。**控制面token 取得與結果面MJPEG 串流、推論 WS仍走雲端 tunnel**——這是「上傳走本機、結果走雲端」的**混合路徑**,不是把整條資料流搬到本機。
具體包含五塊決策:
### 2.1 混合路徑(只搬「上行大檔」,控制面 / 結果面不動)
- **改走 localhost**:影片 / 圖片 / 批次的**檔案上傳** POST。
- **仍走 tunnel刻意保留**token 取得控制面、MJPEG 結果串流(`streamUrl` 是相對路徑 `/api/camera/stream`,前端用 `getApiBaseUrl()` 組雲端絕對路徑,`camera_handler.go:325``visionA-frontend/src/lib/camera.ts:35-42`)、推論結果 WS`/ws/devices/:id/inference`)。
- 理由MJPEG 結果資料量遠小於原始影片15fps down-scaled JPEG走 tunnel 成本可接受;強行把結果也搬本機需在前端維護兩套連線來源,複雜度大增。**保持「控制面 + 結果面走雲端、只有大檔上傳走 localhost」是風險最低的最小切口。**
### 2.2 範圍:影片 + 圖片 + 批次全部改走 localhost
- 三個分頁全部改走 localhost 直連(不只影片)。前端上傳抽象設計為 **endpoint 無關**`uploadToLocalAgent(path, form, options)`),三個 caller 換 path 即可,不需重構。
- **批次額外補「合計大小檢查」**:現況 `validateBatchFiles``visionA-frontend/src/lib/media.ts:301-309`)只逐張檢查 ≤20MB、**不加總**——50 張各 19MB合計 950MB會通過前端驗證後撞 413。這是已上膛的地雷須補合計上限建議 `MAX_BATCH_TOTAL_BYTES = 80MB`,留 nginx 100M 的 20% 餘裕)。
### 2.3 同機偵測 + port 探測(前端掃描候選 port + 身分驗證)
- local-agent 的 port 是**動態**的(`pickPort` 從 3721 fallback 到 3740`app.go:46-47, 1450-1468`),前端**不可寫死 3721**。
- 前端探測策略:`sessionStorage 快取``3721``37223740 並發``Promise.any` 取第一個成功者),每次 timeout 500ms。
- **探測動作本身就是同機偵測**:任一 port 回應且身分相符 = 同機;無回應 = 非同機。
- 新增專用探測 endpoint `GET /api/local/hello`(不用既有 `/api/system/health`——後者對非白名單 origin 的 GET 不回 ACA*,瀏覽器 JS 讀不到內容)。
- **身分驗證必做**`/api/local/hello` 回傳 agent 管理的裝置 serial 的 **salted SHA-256 雜湊**`SHA-256("visiona-local-v1" || serial)`,已裁決見 §5 / api-spec §6.3),前端重算比對 `selectedDevice.serialNumber` 是否相符。防止「同機跑著另一台 agent、影片被送到錯的裝置」這種靜默錯誤資料流。沿用 ADR-018 的 serial 路由慣例,不新造識別體系。
### 2.4 認證:經既有 tunnel 下發 one-time token縱深防禦
開放雲端 origin 打 local-agent 後,威脅模型從「只有本機 loopback 網頁能打」變成「同機任何網頁只要通過 origin 檢查就可能控制 Kneron 裝置」。**CORS 白名單擋不住 XSS合法 origin與 DNS rebinding繞過 DNS 解析層)**,故必須有認證作為第二道防線。
流程(契約):
```
[1] 前端進影片分頁 → 向雲端要 token經既有 OIDC 認證 + 既有 tunnel
POST /api/devices/:serial/local-upload-ticket新增雲端 endpoint
→ api-server 驗 OIDC session + 裝置歸屬 → 經 tunnel 打 local-agent
POST /api/local/issue-token → 產 token 回傳
[2] 前端掃描找到 localhost port§2.3
[3] 前端 POST 影片 → http://127.0.0.1:<port>/api/local/media/upload/video ← 新的、一律要 token 的 route
Header: X-Visiona-Local-Token: <token>
[4] local-agent 驗 token存在 / 未過期 / 未用過 / deviceId 相符)+ 驗 Host = loopback§2.5
→ 消耗 token → 內部轉呼叫既有 handlerhandler 零改動)
```
Token 設計方向(已經 security agent 審,核心設計通過,見 review 議題 2
- 生成 `crypto/rand` 32 bytes → base64url**不准 `math/rand`**TTL 120sone-time驗證後立即刪綁 deviceId記憶體 store`map + sync.Mutex`,不持久化);上限同時 32 個未使用 token防 DoS比對用 `crypto/subtle.ConstantTimeCompare`(防 timing attack
- **實作時注意項security review m2 / m3不改契約但工程師必守**consume 與 issue 各自在**單一** `Lock()` 內完成consume = 查存在 + ConstantTimeCompare + 刪除三步一次持鎖issue = 查 len<32 + 插入一次持鎖避免併發雙重消費 / 上限突破token 驗證放在讀 multipart body`FormFile`**之前** middleware 更佳避免未驗證就先收大檔放大 DoS
### 2.4.1 [C1 — 已定案:方案 A route 分離] 認證判準不得依賴攻擊者可控的 Origin
> **背景security review C1Critical blocker**:原契約訂「來自 tunnel 的請求(無 Origin header**不得**要求 token」把「有無 Origin」當成「是不是可信 tunnel 來源」的唯一判準。但 Origin header 由請求發送方完全控制,**同機任何程序curl / 惡意 App / 被入侵的其他本機服務)都能發出不帶 Origin 的 HTTP 請求到 `127.0.0.1:<port>`**local-agent 綁 loopback、同機所有程序皆可連等於「不帶 Origin 就免 token」的後門整層 token 認證被繞過。
**查證結論(讀 `local-agent/visiona-agent/internal/tunnel/client.go`**tunnel client 的 `handleStream` 把收到的 HTTP request 直接 `http.DefaultTransport.RoundTrip(req)``req.URL.Host = c.localAddr`= `127.0.0.1:<port>`,普通 HTTP over loopback見 client.go:337-355、73。tunnel **未在轉發時注入任何可辨識標記**,且 tunnel clientWails app shell module `visiona-agent`)與 HTTP server獨立子行程 module `server`)是**兩個獨立行程**。結論:**同機程序可完全偽裝成「tunnel 來的請求」**server 端無法用 request 本身區分。
**定案:採方案 Aroute 分離)**,不採方案 Btunnel 注入 process-local secret。理由
1. tunnel client 與 HTTP server 是兩個獨立行程 / 獨立 Go module見 client.go 檔頂註解),方案 B 的「process-local 共享 secret」實際上需要跨行程 IPC 傳遞 nonce脆弱且增加耦合方案 A 不需任何跨行程協調。
2. 方案 A 把「要不要 token」交給 **server 端固定的 route** 決定,不由攻擊者可控的 Origin 決定。
**契約鐵則(取代原「無 Origin 免 token」硬規則**
- **媒體上傳 endpoint 絕不能有「無任何憑證即放行」的 code path。** 刪除原「來自 tunnel 的請求(無 Origin不得要求 token」的無條件放行語意。
- **瀏覽器 localhost 直連**走**新的、一律要求 token 的 route**`POST /api/local/media/upload/{video|image|batch-images}`。此 route **不看 Origin、一律驗 token**token 無效 / 缺失 → 401並經 §2.5 Host 驗證。內部轉呼叫既有 handlerhandler 零改動)。
- **既有 tunnel 轉發路徑**維持既有 `POST /api/media/upload/{video|image|batch-images}`tunnel client 內部轉發至此,路徑不變、行為不變)。此路徑**不因本 ADR 新開放給任何雲端 origin**CORS 白名單擴充只作用於直連情境的 `/api/local/*` 行為)。
**誠實揭露的殘留風險**:因 loopback 對同機程序無隔離,同機惡意程序仍可**直接**打舊 tunnel-path route`/api/media/upload/*`)——但這是 **ADR-019 之前就存在的既有 loopback 攻擊面**(非本 ADR 新增),且舊 route 未被本 ADR 新開放給雲端 origin。緩解舊 tunnel-path route 也套用 §2.5 Host 驗證Host=loopback 才放行),把「同機直打舊 route」限制在同機、且不影響 tunnel 轉發tunnel 轉發的 Host 本就是 `127.0.0.1:<port>`)。完整消除同機程序攻擊面超出本 ADR 範圍(需 OS 層 socket 權限 / peer credential 驗證),列為 backlog。
### 2.5 CORS 白名單擴充 + Host 驗證 + PNA 緩解(全部必做)
- Origin 白名單改為可設定env `VISIONA_CLOUD_ORIGINS`,逗號分隔完整 origin**stage 需同時允許雙入口**
- `https://stage-9527.innovedus.com:9527`(公網 HTTPS`nginx.stage.conf:86-89`
- `http://192.168.0.130:9527`**內網純 HTTP** 入口,`nginx.stage.conf:411-414`
- dev`http://localhost:3000`
- loopback 舊規則(`middleware.go:17-22`)保留不動。
- **[M3 — 契約必做] 雲端 origin 採「完整 origin 精確比對」**scheme+host+port **全等**逐字比對),**不可**沿用既有 `isAllowedOrigin` 的 hostname-only + 任意 port + 放寬 scheme 邏輯(`middleware.go:41-45`)。白名單裡存的是完整 origin 字串(如 `https://stage-9527.innovedus.com:9527`),比對時整串相等才通過。原因:既有 `allowedHosts` 只比 hostname、放任任意 port若直接把雲端網域塞進去會變成「該網域任意 port 都放行」,攻擊面比預期大;放寬 scheme 若寫成「不檢查 scheme」則 `https://127.0.0.1`、`http://` 混用可繞過。故雲端 origin 必須**獨立於既有 loopback 邏輯**,走完整字串精確比對。
- **[M3 — 契約必做] `Access-Control-Allow-Credentials: false`(對雲端 origin**:本路徑用 header`X-Visiona-Local-Token`)帶 token、不需 cookie。**不可**沿用既有 middleware 對雲端 origin 回 `Allow-Credentials: true``middleware.go:97`)——那會無謂讓瀏覽器願意帶 credential、擴大 CSRF / 憑證面。雲端 origin 一律回 `false`api-spec §6.4 已列)。
- **[M2 — 契約必做,從「待審 / optional」升為「必做」] Host header 驗證 = loopback**:對所有 `/api/local/*`(含新的 `/api/local/media/upload/*`)與舊 tunnel-path media route加 Host 驗證 middleware——`Host` header去掉 port 後)必須 ∈ `{127.0.0.1, localhost, ::1}`,否則 `400 Bad Request`。這是**獨立於 CORS 的第二道**、擋 DNS rebindingrebinding 後 `Host` 為攻擊者網域 ≠ loopback → 400成本低、對本機服務屬 baseline 而非 optional。tunnel 轉發的 Host 本就是 `127.0.0.1:<port>`(見 client.go:347故不受影響。驗證邏輯契約不寫進 production code
```
host := c.Request.Host // 含 port
h, _, _ := net.SplitHostPort(host) // 去 port無 port 時退回原值)
if h ∉ {"127.0.0.1", "localhost", "::1"} → 400 Bad Request
```
- **PNA 緩解(必做,非 optional**:對帶 `Access-Control-Request-Private-Network: true` 且通過白名單的 preflight`Access-Control-Allow-Private-Network: true`。原因見 §4.3 R-1——即使只支援 Chrome/EdgeChrome 未來可能把 PNA 從 warning 升為 blocking而已安裝的舊版 local-agent 不會自動升級,此 header 現在補、成本 <30 分鐘省未來一顆不定時炸彈
- **preflight 帶 `Access-Control-Max-Age: 600`**api-spec §6.4,緩解 port 掃描的大量 preflight
---
## 3. 考慮過的替代方案 (Alternatives Considered)
| 方案 | 優點 | 缺點 | 排除原因 |
|------|------|------|---------|
| **維持 tunnel + 調大 nginx `client_max_body_size`** | 改動最小、不動前端架構 | 不解決頻寬雙倍批次分頁仍是地雷stage 每次落地 500MB 暫存檔;還需 IT 改公司邊界 nginx`STAGE-DEPLOY.md:397-401` 待辦未完成api-server 300s timeout 仍會炸 | 治標不治本,且引入「改 nginx→改 timeout→關 request buffering→改公司邊界 nginx」的連鎖與「最小改動」背道而馳 |
| **傳檔案路徑給 local-agent不傳檔案內容** | 傳輸量 ~100 bytes | 瀏覽器基於安全**拿不到真實路徑**`File.path` 已被所有現代瀏覽器移除);需在 local-agent 加業務 UI違反 `server_control.go:1023-1033`「使用者不感知 server 存在」的既有決策path traversal 風險 | 技術不可行(拿不到路徑)+ 違反既有架構方向 |
| **前端壓縮 / 抽 frame 後上傳(縮小檔案再走 tunnel** | 沿用現有 tunnel 路徑 | 失真影響推論準確度;瀏覽器端影片處理相容性差、耗時;不解決架構問題(只是把大檔變小檔) | 犧牲推論品質,且沒有真正解決頻寬 / 落地問題 |
| **localhost 直連(採用)** | 傳輸量 1000MB→loopback0.12.5s,改善約 1001000 倍);不受 nginx / timeout 約束handler 零改動 | 需同機操作local-agent 新增對外攻擊面需認證PNA 未來風險 | ✅ 採用 |
---
## 4. 後果 (Consequences)
### 4.1 正面影響
- **頻寬**500MB 影片從「1000MB 經雲端 tunnel」降為「loopback 本機傳輸」(現代 SSD 筆電 0.10.5s、較舊機器 12.5s)。
- **不受基礎設施約束**:繞過 nginx 100M 上限、api-server 300s timeout、stage 磁碟落地。
- **handler 零改動**local-agent 的 `UploadVideo` handler 一行不用改route 已存在、與 tunnel 解耦)。
- **修補文件邏輯漏洞**:把 `design-doc.md §5.3` 的「大檔不走 tunnel」原則正確延伸到 media。
- **順帶修批次地雷**:補合計大小檢查,消除 50×19MB 撞 413 的隱患。
### 4.2 負面影響與限制(接受的取捨)
- **影片 / 圖片 / 批次上傳需同機操作**:必須在執行 visionA Agent 的那台電腦上操作。非同機時該分頁停用 + 提示。**Camera即時攝影機仍支援遠端檢視**——只有「上行大檔」需同機限制。
- 依使用者拍板「跨機不存在」,此限制不影響實際使用;且現況跨機使用者本來就傳不了 >100MB413此限制是「把隱性限制變成明確提示」非新增限制。
- **不動 PRD**:使用者已明確跨機不存在,此限制在 ADR 記錄即可,不需 PM 改 PRD。
- **local-agent 新增對外攻擊面**:首次開放雲端 origin 直連 local-agent需維護 CORS 白名單 + token 認證 + PNA header 的安全性。
- **僅支援 Chromium 系Chrome / Edge**Safari 不支援(使用者已確認不需要)。
- **狀態分裂風險**:上傳走 localhost、結果走雲端 tunnel兩條路徑可能不同步見 §4.3 R-3
### 4.3 風險
| # | 風險 | 等級 | 緩解 |
|---|------|------|------|
| **R-1** | PNA 未來變硬阻擋,已安裝的舊版 local-agent 無法自動升級 → 影片功能無預警壞掉 | **高** | 第一版就加 `Access-Control-Allow-Private-Network: true`§2.5,成本 <30min |
| **R-3** | 狀態分裂:上傳走 localhost 成功,但結果 tunnel 剛好斷線 → 上傳成功卻看不到結果 | 中 | 上傳前檢查既有 `tunnel_online` 欄位(`visionA-backend/internal/api/devices.go:66-67`),離線時 disable 上傳 |
| **R-4** | 同機多個 visionA agent多裝置場景掃描命中錯的 agent | 中 | §2.3 serial 身分驗證;掃描收集所有回應再挑 serial 相符者 |
| **R-6** | 影片 / 圖片 / 批次上傳**完全無 size 上限** + temp 檔洩漏(`stopActivePipeline` 切 pipeline 時把 `videoPath=""` 但**不** `os.Remove`batch 成功路徑後也未刪),反覆上傳可磁碟 DoS配合 C1 未修時為 High | **中→高** | **[M1 — 契約必做,非查證] 見 §4.3.1** |
### 4.3.1 [M1 — 契約必做] media 上傳 size 上限 + temp 檔清理
> security review M1temp 檔洩漏**已確認為實錘**(非待查證)——`camera_handler.go``stopActivePipeline`line 535`h.videoPath = ""` 但從未對前一支影片 temp 檔 `os.Remove`batch 路徑在 `NewMultiImageSource` 成功後也未追蹤刪除。結合無 size 上限 → 反覆上傳磁碟必爆。
**契約必做項WP-2非「查證」**
- **size 上限(各自值,必做)**video / image / batch-images 各自的上傳 size 上限納入契約。
- **video 上限:與前端 90MB 對齊,硬上限不超過 500MB**security 明確要求「不要因為走 loopback 就給 1GB」——攻擊面不因 loopback 縮小)。建議實作值 = 前端 90MB正常上限server 端 `http.MaxBytesReader` 硬牆設 ≤ 500MB 作為 DoS 上界。
- image / batch 單張沿用既有逐張限制;**batch 合計上限** = `MAX_BATCH_TOTAL_BYTES = 80MB`§2.2,補 nginx 100M 的 20% 餘裕;此上限對 localhost 直連仍保留,作為資源上界)。
- 驗證時機size 檢查與 token 驗證都放在讀 multipart body`FormFile`**之前**review m3
- **temp 檔清理(必做)**`stopActivePipeline` 切換 / 停止 pipeline 時對前一支 `videoPath``os.Remove`batch 的 `MultiImageSource` 在生命週期結束時刪除其 `filePaths`。反覆上傳不得殘留 temp 檔testing 寫回歸測試驗證)。
| **R-5** | 企業防火牆 / EDR 攔截 localhost HTTP | 低 | 明確錯誤訊息讓使用者知道要檢查什麼 |
> 註:第一輪風險表中的 R-2Safari 相容性)因使用者確認僅支援 Chromium已移除。
---
## 5. security agent 裁決事項(已裁決)
依 Architect CLAUDE.md §14.3 邊界表token / secret 管理與隱私洩露屬 security agent 審查範圍。以下兩點已經 security agent 審查裁決(`.autoflow/05-implementation/review/adr-019-security-review.md` 議題 1 / 議題 2契約內容已依裁決更新
| # | 議題 | 裁決結果(已落實於本 ADR / api-spec |
|---|------|------------------|
| 1 | **`GET /api/local/hello` 回傳完整 serial 還是雜湊 serial** | **已裁決回「salted SHA-256 雜湊」,不回完整 serial。** `serialHashes[i] = SHA-256(公開固定 salt \|\| fullSerial)` 的 hexsalt 常數 = `visiona-local-v1`(前後端共用、非 server 私有,讓前端可獨立重算比對);`agentVersion` 粗化或移除(改回布林 `supportsLocalUpload: true` 已足夠hello 不回任何其他欄位(最小揭露)。契約見 api-spec §6.3。殘留風險(序號熵低 + salt 公開 → 有心攻擊者可枚舉回推security 判定「與威脅相稱、不需過度設計」(序號非 credential、控制裝置仍需 token+OIDC。 |
| 2 | **one-time token 設計是否足夠 + DNS rebinding 完整緩解** | **已裁決token 核心設計通過。** 附加要求已落實a**C1 route 分離**§2.4.1token 認證不得被無 Origin 繞過,是 token 設計的前置 blockerb**Host 驗證必做**§2.5rebinding 主防護,非 optionalc**CORS 完整 origin 精確比對 + Allow-Credentials: false**§2.5dtoken race 用 single-flight 持鎖(實作注意項 m2。upload handler **不**需在內部再綁 Origin 二次驗證CORS 已做、且避免與 C1 方案衝突)。 |
---
## 6. 本 ADR 修正 / supersede 的內容
| 文件 | 章節 | 原內容 | 修正為 |
|------|------|--------|--------|
| `api/api-spec.md` | §6 Camera / Media | 「與 local-tool 相同,**全部透過 tunnel forward**」 | 影片 / 圖片 / 批次上傳走 localhost 直連Camera 與推論結果串流仍走 tunnel見本 ADR |
| `design-doc.md` | §5.3 之後 | 「大檔不走 tunnel」原則只涵蓋模型上傳 | 新增一節說明 media 上傳套用同原則localhost 直連),修補邏輯漏洞 |
> 註:本 ADR 不 supersede 任何既有 ADR僅修正上述兩份設計文件的敘述。ADR-018serial 路由)的識別慣例被本 ADR **沿用**§2.3),無衝突。
---
## 7. 合規性
- [x] 與使用者確認方向(同機 localhost 直連 + 混合路徑 + 範圍含影片/圖片/批次 + 非同機停用 + 僅 Chromium + 認證必做)— ✅ 已裁決
- [x] **security agent 審 token / 隱私設計**§5 兩點)— ✅ 已審verdict: REQUEST CHANGES → 契約已依 C1/M1/M2/M3/議題1 修正,待 confirm-only 複審)
- [ ] backend agent 落地 local-agent CORS/PNA/token/endpoints + 雲端 ticket endpoint
- [ ] frontend agent 落地 port 探測抽象 + 影片分頁接線 + 批次合計檢查
- [ ] testing agent 回歸:既有 tunnel 路徑(無 Origin不受 token 影響跨瀏覽器Chrome/Edge驗證
- [ ] 成本影響:**無新雲端資源**(沿用現有 DB / tunnel主要是三 module 開發工時,粗估 3.35.4 人天(見 §8 WP 清單);反而降低 stage 磁碟與頻寬壓力
---
## 8. WP 工作包清單(給 Orchestrator 排程)
> 完整依賴圖 / 關鍵路徑 / work-stream 同步點見 `.autoflow/04-architecture/video-localhost-wp-breakdown.md`(個人層)。此處為摘要。
> **本清單已依 security review 更新**C1 route 分離、M1 size 上限 + temp 清理必做、M2 Host 驗證必做、M3 CORS 完整 origin 精確比對)。
| WP | 內容 | 負責 agent | 依賴 | 人時 |
|----|------|-----------|------|------|
| WP-0 | SpikeChrome/Edge 實測 `https/http 入口 → http://127.0.0.1:3721` + PNA console warning | frontend | 無 | 24 |
| WP-1 | local-agent CORS / Host / PNA雲端 origin **完整 origin 精確比對**(獨立於既有 hostname-only 邏輯,**不放寬既有 scheme 檢查**+ **`Allow-Credentials: false`(雲端 origin** + **Host header 驗證 = loopbackM2 必做,套用 `/api/local/*` 與舊 media route** + PNA header + `Access-Control-Max-Age: 600` | backendGo | 契約 §2.5 | 57 |
| WP-2 | local-agent**新 route `/api/local/media/upload/{video\|image\|batch-images}`(一律要 token、不看 Origin、內部轉呼叫既有 handler** + `/api/local/hello`(回 salted SHA-256 serialHashes + `/api/local/issue-token` + token storesingle-flight 持鎖)+ token 驗證中介(放 `FormFile` 前)+ **media size 上限video ≤500MB 硬牆 / batch 合計 80MBM1 必做)** + **temp 檔清理(`stopActivePipeline` 補 `os.Remove` + batch 生命週期刪檔M1 必做)** | backendGo | WP-1 | 913 |
| WP-3 | 前端:`lib/local-agent.ts`port 探測並發+快取+timeout、同機判定用 salted SHA-256 比對 serialHashes、`uploadToLocalAgent()` endpoint 無關通用函式;上傳目標改新 route `/api/local/media/upload/*` | frontend | 契約 §2.3/§6.3 | 610 |
| WP-4 | 前端:影片分頁接線(取 token、改上傳目標為 `/api/local/media/upload/video`、三種錯誤訊息 i18n、tunnel 離線檢查 R-3 | frontend | WP-3 | 46 |
| WP-5 | 雲端:`POST /api/devices/:serial/local-upload-ticket`(經 tunnel 轉發 issue-token | backendGo | 契約 C-1 | 24 |
| WP-6可選 | 圖片 + 批次接上 localhost 路徑 | frontend | WP-4 驗證通過 | 35 |
| WP-7獨立建議先做 | `validateBatchFiles` 加合計大小檢查 | frontend | 無 | 0.51 |
**推薦範圍WP-0..WP-5 + WP-7總計26.543 小時 ≈ 3.35.4 人天。**
**關鍵路徑(串行、決定最短工期)**:契約定稿 → WP-0 → WP-1 → WP-2 → 整合 → E2E ≈ 1422 小時。
**可平行**WP-3前端對契約 mock與 WP-1/WP-2backend完全平行WP-5雲端 ticket與 WP-1/WP-2 平行WP-7 完全獨立。建議開 23 條 work-streamfrontend / local-agent backend / cloud backend不宜再切。
---
## 9. 一句話總結
影片 / 圖片 / 批次上傳從「經雲端 tunnel頻寬雙倍 + 撞 nginx 100M + 300s timeout + stage 落地)」改為**同機瀏覽器直連 local-agent 的 localhost endpoint**handler 零改動、route 已存在),修補 `design-doc.md §5.3`「大檔不走 tunnel」原則未套用到 media 的邏輯漏洞;採**混合路徑**(上傳走 localhost、控制面 + MJPEG 結果 + 推論 WS 仍走 tunnel動態 port 用前端掃描 37213740 + serial 身分驗證偵測同機;認證用經既有 tunnel 下發的 one-time token縱深防禦擋 XSS / DNS rebindingCORS 擴充雙入口白名單 + PNA header防未來 Chrome 升級 blocking非同機停用該分頁產品定位「跨機不存在」不做 fallback。token / 隱私設計待 security agent 審。

View File

@ -217,105 +217,17 @@
## 6. Camera / Media
**路徑分流2026-07 起,見 ADR-019**Camera 與推論**結果**串流走雲端 tunnel forward影片 / 圖片 / 批次的**檔案上傳**改走**同機 localhost 直連 local-agent**(繞過 tunnel解決大檔頻寬雙倍 + nginx 100M + 300s timeout
### 6.1 走 tunnel forward與 local-tool 相同,不變)
與 local-tool 相同,全部透過 tunnel forward
### GET `/api/camera/list`
### POST `/api/camera/start`
### POST `/api/camera/stop`
### GET `/api/camera/stream` — MJPEG透過 tunnel streaming
### GET `/api/media/batch-images/:index` — 讀取批次結果(走 tunnel
### POST `/api/media/seek` — 影片 seek走 tunnel
> Camera 支援遠端(跨機)檢視,這是產品核心價值,不受同機限制。
### 6.2 走 localhost 直連 local-agent影片 / 圖片 / 批次上傳,見 ADR-019
以下**上傳** endpoint 由瀏覽器在**同機情境**下直接 POST 到 local-agent 的 loopback server`http://127.0.0.1:<port>`port 動態 ∈ 37213740**不經雲端 tunnel**。
> **[C1 — security review 定案route 分離]** 瀏覽器 localhost 直連走**新的、一律要求 token 的** route`/api/local/media/upload/*`**不看 Origin**;既有 tunnel 轉發路徑維持 `/api/media/upload/*` 不變。認證判準由 server 端固定 route 決定,**不**由攻擊者可控的 Origin 決定(原「無 Origin 免 token」規則已刪除——見 ADR-019 §2.4.1)。
#### 瀏覽器 localhost 直連 route新增、一律要 token
### POST `/api/local/media/upload/image` — 直連 local-agent
### POST `/api/local/media/upload/video` — 直連 local-agent大檔主要受益者
### POST `/api/local/media/upload/batch-images` — 直連 local-agent
**上傳請求契約(瀏覽器 → local-agent**
- Target`http://127.0.0.1:<port>/api/local/media/upload/{video|image|batch-images}`port 由前端探測取得,見下方 §6.3
- Header`X-Visiona-Local-Token: <token>`**一律要求,不看 Origin**;缺失 / 無效 → 401
- Host 驗證:`Host` header去 port必須 ∈ `{127.0.0.1, localhost, ::1}`,否則 `400`DNS rebinding 防護,見 §6.4
- Body既有 `multipart/form-data`(含 `deviceId` 欄位 + `file`),格式不變。此 route 內部轉呼叫既有 upload handlerhandler 零改動)。
- **size 上限必做ADR-019 §4.3.1**video 硬牆 ≤ 500MB前端正常上限 90MBbatch 合計 ≤ 80MB。size 與 token 驗證都在讀 body`FormFile`**之前**。
- 驗證失敗 → `401 { "success": false, "error": { "code": "LOCAL_TOKEN_INVALID" } }`
- 檔案過大 → `413 { "success": false, "error": { "code": "LOCAL_UPLOAD_TOO_LARGE" } }`
#### 既有 tunnel 轉發 route保留不變供雲端路徑
### POST `/api/media/upload/image`
### POST `/api/media/upload/video`
### POST `/api/media/upload/batch-images`
> tunnel client 內部轉發至此,路徑 / 行為不變。此路徑**不因本 ADR 新開放給雲端 origin**;同機直打舊 route 仍受 §6.4 Host 驗證約束Host=loopback
> 推論**結果**MJPEG 串流 `streamUrl` / 推論 WS `/ws/devices/:id/inference`)仍走雲端 tunnel。這是「上傳走 localhost、結果走雲端」的混合路徑ADR-019 §2.1)。
### 6.3 localhost 直連的支援 endpointlocal-agent 新增,見 ADR-019
以下 endpoint 為 localhost 直連機制所需,**僅存在於 local-agent 本機 server**,不對應雲端路由:
#### GET `/api/local/hello` — 同機偵測 + 身分驗證
- Authbootstrap 需要——拿 token 前需先知道 portserial 以雜湊揭露、最小化 bootstrap 洩露,見下方裁決)
- Response 200
```json
{
"success": true,
"data": {
"serialHashes": ["<hex>", "..."],
"supportsLocalUpload": true
}
}
```
- **serialHashes 契約security review 議題 1 裁決,必守)**
- 每個元素 = `SHA-256(salt || fullSerial)`**hex** 字串lowercase hex。演算法固定 **SHA-256**(非 MD5 / SHA-1非 bcrypt/argon2——目的是「前端可重算比對」而非「防暴力還原」
- **salt = 固定公開常數 `visiona-local-v1`**(前後端共用寫死的 app-level 常數、**非** server 私有隨機值)。前端用 Web Crypto `crypto.subtle.digest('SHA-256', utf8("visiona-local-v1" + fullSerial))` 獨立重算比對。
- **明確不要做**:不得用 server 啟動時 `crypto/rand` 生成的私有 salt前端算不出、破壞比對不得 per-request 隨機 salt。
- 用途:前端掃描 37213740比對 `SHA-256("visiona-local-v1" || selectedDevice.serialNumber) ∈ serialHashes` 判定同機(沿用 ADR-018 serial 路由慣例)。
- 誠實揭露殘留風險:序號熵低 + salt 公開 → 雜湊**不能**真正隱藏序號可枚舉回推它擋的是「順手抓完整序號」的被動洩露非「防定向還原」。security 判定與威脅相稱(序號非 credential、控制裝置仍需 token+OIDC不需過度設計。
- **`agentVersion` 已移除**security review 議題 1完整 build 版本 = 給攻擊者精準 CVE 對照A06。前端只需判斷「支不支援 local upload」→ `supportsLocalUpload: true` 布林已足夠。
- **最小揭露**hello **不回**任何其他欄位deviceId 明文 / 裝置型號 / driver 版本 / 機器名一律不回)。
- CORS需回完整 ACA* + PNA header`Access-Control-Allow-Private-Network: true`Host 驗證 = loopback§6.4)。
#### POST `/api/local/issue-token` — 產 one-time upload token
- 取得路徑:僅經既有 tunnel 由 api-server 轉發呼叫api-server 已驗 OIDC session + 裝置歸屬,見下方 `/api/devices/:serial/local-upload-ticket`)。**不對瀏覽器 CORS 白名單開放**(非 `/api/local/media/upload/*` 直連 route受 §6.4 Host 驗證約束。
- 註C1 一致性):不再用「無 Origin 即放行」作判準(該反模式已於 ADR-019 §2.4.1 刪除。token 只是縱深防禦一層——同機惡意程序的攻擊面由 C1 route 分離 + Host 驗證處理issue-token 產出的 token 仍綁 deviceId + one-time + 120s TTL即使被同機程序取得也僅能對已歸屬裝置發一次短期上傳。
- Request`{ "serial": "string" }`
- Response 200`{ "success": true, "data": { "token": "string", "expiresAt": <unix_ms>, "ttlSeconds": 120 } }`
- Response 429token 上限32 個未使用)已滿 → `{ "error": { "code": "LOCAL_TOKEN_LIMIT" } }`
- Token 設計見 ADR-019 §2.4,核心設計已經 security agent 審通過review 議題 2
#### POST `/api/devices/:serial/local-upload-ticket` — 雲端 ticket新增雲端 endpoint
- 位置:**api-server雲端**,非 local-agent
- 驗 OIDC session + 裝置歸屬 → 經既有 tunnel 打 local-agent 的 `/api/local/issue-token` → 回傳 token 給瀏覽器
- 這是 token 的取得路徑,走既有已認證的 tunnel控制面
### 6.4 CORS / Host 驗證 / PNA 要求local-agent見 ADR-019 §2.5
- Origin 白名單來源env `VISIONA_CLOUD_ORIGINS`(逗號分隔完整 originstage 預設含雙入口 `https://stage-9527.innovedus.com:9527` + `http://192.168.0.130:9527`(純 HTTP 內網入口)+ dev `http://localhost:3000`
- loopback 舊白名單(`middleware.go:17-22`)保留不動
- **[M3 — 必做] 雲端 origin 採「完整 origin 精確比對」**scheme+host+port **全等**逐字比對),**不可**沿用既有 `isAllowedOrigin` 的 hostname-only + 任意 port + 放寬 scheme 邏輯。白名單存完整 origin 字串、整串相等才通過(否則會變成「該網域任意 port / 任意 scheme 都放行」)。
- Response雲端 origin 通過):**`Access-Control-Allow-Credentials: false`M3 必做,不可對雲端 origin 回 true**、`Access-Control-Allow-Headers: Content-Type, X-Visiona-Local-Token``Access-Control-Max-Age: 600``Vary: Origin`
- **[M2 — 必做] Host header 驗證 = loopback**:對所有 `/api/local/*`(含 `/api/local/media/upload/*`)與舊 tunnel-path media route`Host`(去 port必須 ∈ `{127.0.0.1, localhost, ::1}`,否則 `400 Bad Request`DNS rebinding 獨立第二道防護tunnel 轉發的 Host 本就是 `127.0.0.1:<port>` 故不受影響)。
- **PNA必做**preflight 帶 `Access-Control-Request-Private-Network: true` 且通過白名單 → 回 `Access-Control-Allow-Private-Network: true`
### 6.5 錯誤碼(跨模組統一)
| 錯誤碼 | HTTP | 情境 | 誰產生 |
|--------|------|------|--------|
| `LOCAL_AGENT_NOT_FOUND` | —(前端內部狀態) | 掃描無回應(非同機 / agent 沒跑) | frontend |
| `LOCAL_AGENT_MISMATCH` | —(前端內部狀態) | 掃描有回應但 serial 不符 | frontend |
| `LOCAL_TOKEN_INVALID` | 401 | token 不存在 / 過期 / 已使用 / 缺失(直連 route 一律要 token | local-agent |
| `LOCAL_TOKEN_LIMIT` | 429 | 未使用 token 達 32 上限 | local-agent |
| `LOCAL_UPLOAD_TOO_LARGE` | 413 | 上傳超過 size 上限video ≤500MB / batch 合計 ≤80MBM1 | local-agent |
| Host 驗證失敗) | 400 | `Host` header 非 loopbackDNS rebinding 防護M2 | local-agent |
### GET `/api/media/batch-images/:index`
### POST `/api/media/seek`
---

View File

@ -312,32 +312,6 @@ POC 已完整實作此流程visionA 直接沿用。
- S3 presigned URL 讓瀏覽器直連scalable
- 需要時 api-server 再叫 local agent「下載這個 model 的 URL」由 local agent 用自己網路下載
### 5.3.1 media 上傳(影片 / 圖片 / 批次,**同機 localhost 直連、不走 tunnel**,見 ADR-019
> **此節修補 §5.3 的邏輯漏洞**§5.3 建立了「大檔不走 tunnel因為走 tunnel = 占用使用者本地頻寬兩次」的原則,但此原則過去**只套用到模型上傳,未套用到 media**。影片同樣是大檔(可達 500MB卻仍走 tunnel頻寬雙倍 + 撞 nginx 100M + 300s timeout + stage 落地。ADR-019 把同一原則延伸到 media。
```
[瀏覽器] 進影片分頁 → 掃描 127.0.0.1:3721..3740 找 local agent同機偵測 + serial 身分驗證)
↓ 經既有 tunnel 向雲端要 one-time upload token控制面走 tunnel
[api-server] POST /api/devices/:serial/local-upload-ticket → 驗 OIDC + 歸屬 → 經 tunnel 打 local agent issue-token
↓ token 回瀏覽器
[瀏覽器] 直接 POST 影片到 http://127.0.0.1:<port>/api/local/media/upload/video不走 tunnel
Header: X-Visiona-Local-Token新的、一律要 token 的直連 route見 ADR-019 §2.4.1 C1 route 分離)
[local HTTP server] 驗 token + 驗 Host=loopback → 內部轉呼叫既有 UploadVideo handler零改動→ 啟動推論 pipeline
↓ MJPEG 結果串流 / 推論 WS 結果 → 仍走雲端 tunnel結果面走 tunnel
[瀏覽器] 從雲端 tunnel 收到推論結果
```
**混合路徑(關鍵)**:只有「上行大檔」走 localhost控制面token與結果面MJPEG / 推論 WS仍走雲端 tunnel。這是繼「§5.1 REST via tunnel」「§5.2 WS via tunnel」「§5.3 模型上傳 presigned 直連」之後的**第四種資料路徑****同機 localhost 直連**。
**為何走 localhost 而非 tunnel延續 §5.3 原則)?**
- 500MB 影片經 tunnel = 上傳 500MB + 結果下行 ≈ 1000MB兩次都佔使用者本地頻寬改 loopback 後上傳成本趨近零0.12.5s
- 繞過 nginx `client_max_body_size 100M`、api-server 300s timeout、stage 磁碟落地
- local agent 端 media route 已存在、handler 與 tunnel 解耦,瀏覽器直連零改動
**限制(產品定位)**:需在執行 visionA Agent 的同一台電腦操作(非同機時該分頁停用 + 提示);僅支援 Chromium 系Chrome/Edge。Camera即時攝影機不受此限、仍支援遠端檢視——只有上行大檔需同機。認證與隱私設計細節見 ADR-019 §2.4 / §5token / serial 隱私已經 security agent 審並依 C1/M1/M2/M3/議題1 修正契約)。
### 5.4 轉檔呼叫Phase 1
```

View File

@ -530,15 +530,6 @@ 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 可能是不同的(或 nilVideoSource。此處對 h.videoPath 明確補一次
// os.Remove 作為 belt-and-suspenders——已被刪過時第二次 Remove 是無害 no-op。
if h.videoPath != "" {
_ = os.Remove(h.videoPath)
}
h.activeSource = nil
h.sourceType = ""
h.videoPath = ""

View File

@ -1,167 +0,0 @@
package handlers
import (
"crypto/sha256"
"encoding/hex"
"log"
"net/http"
"strings"
"time"
"visiona-agent/server/internal/device"
"github.com/gin-gonic/gin"
)
// LocalSerialSalt 是 serial 雜湊的固定公開常數 saltADR-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 的本機直連支援 endpointhello / 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 hexADR-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,
},
})
}

View File

@ -1,234 +0,0 @@
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_SerialHasheshello 回 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、長度 64SHA-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 hexgot %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/ttlSecondsdeviceId 綁 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 0xAAAA0001token 綁 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_LIMITgot %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)
}
}

View File

@ -1,97 +0,0 @@
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、不看 OriginC1
// 2. 用 http.MaxBytesReader 把 request body 包上 maxBytes 硬牆——
// 在讀取 multipart body 之前就限制總位元組避免「未驗證就先收無上限大檔」M1
// 3. 解析出 deviceIdPostForm 觸發 multipart 解析,但已被 MaxBytesReader 上限保護)。
// 若超過上限 → ParseMultipartForm 回 *http.MaxBytesError → 413 LOCAL_UPLOAD_TOO_LARGE。
// 4. Consume(token, deviceId)——single-flight 持鎖(查存在+比對+刪除同一 Lock防 racem2
// deviceId 綁定不符 / 過期 / 已用 / 不存在 → 401 LOCAL_TOKEN_INVALID。
// 5. 通過 → c.Next() 進既有 handlerhandler 業務邏輯零改動、直接 FormFile 讀已快取的表單)。
//
// 稽核 logconsume 成功/失敗記 deviceId + 時間,絕不 log token 明文。
//
// 為什麼 deviceId 取自表單而非 tokentoken 在 issue 時已綁 deviceIdConsume 會用
// 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
}
// M1body 硬牆。放在解析 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()
}

View File

@ -1,269 +0,0 @@
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 的 routerhandler 記錄是否被呼叫並回讀 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_ConsumedOrExpiredTokenConsume 回 ErrTokenInvalid已用/過期/deviceId不符
// → 401 LOCAL_TOKEN_INVALIDhandler 不被呼叫。
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_TokenCheckedBeforeHandlertoken 驗證發生在 handlerFormFile 讀檔)之前。
// 用「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_TooLargebody 超過 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 掉那個 tokensize 檢查在 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 上限時不應呼叫 Consumesize 檢查在 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_INVALIDgot %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 是 sentinelerrors.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 不應相等")
}
}

View File

@ -1,23 +1,19 @@
package api
import (
"net"
"net/http"
"net/url"
"os"
"strings"
"github.com/gin-gonic/gin"
)
// allowedHosts 定義 loopback CORS 白名單的 hostname。
// allowedHosts 定義 CORS 白名單的 hostname。
// 任何 port 都允許scheme 只允許 http本機不可能是 https
//
// M8-8TDD 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,
@ -25,53 +21,7 @@ var allowedHosts = map[string]bool{
"::1": true,
}
// 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 的 isAllowedOriginhostname-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 白名單。
// isAllowedOrigin 判斷 Origin header 是否屬於白名單。
//
// 合法例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
@ -80,8 +30,6 @@ func isAllowedCloudOrigin(origin 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
@ -97,19 +45,14 @@ func isAllowedOrigin(origin string) bool {
return allowedHosts[host]
}
// CORSMiddleware 處理跨來源請求,區分 loopback 與雲端 origin 兩條路徑
// CORSMiddleware 僅允許 127.0.0.1/localhost/::1 任意 port 的跨來源請求
//
// 行為M8-8 / TDD v2/cors-security.md §4.1 + ADR-019 §2.5
// 行為M8-8 / TDD v2/cors-security.md §4.1
//
// 1. Origin header 為空 → same-origin瀏覽器 same-origin 不送 Origin→ 直接放行;
// 若是 OPTIONS 預檢則回 204 即停(避免帶 ACA* 給沒人看的請求)。
// 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 都不在白名單:
// 2. Origin 在白名單 → 回完整 ACA* headersOPTIONS → 204其他方法 → 繼續執行 handler。
// 3. Origin 不在白名單:
// - state-changing 方法POST/PUT/DELETE/PATCH/OPTIONS→ 403 Forbidden不回 ACA*。
// - 簡單讀取GET/HEAD→ 執行 handler 但不回 ACA*,瀏覽器 JS 讀不到 body。
//
@ -131,52 +74,7 @@ func CORSMiddleware() gin.HandlerFunc {
return
}
// 雲端白名單 originADR-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 {
// PNAADR-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
}
// 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 {
// 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
}
if !isAllowedOrigin(origin) {
// 非白名單 Origin
// - state-changing 方法 → 403嚴格擋
// - GET/HEAD → 執行但不回 ACA*(瀏覽器層擋)
@ -189,51 +87,20 @@ func CORSMiddleware() gin.HandlerFunc {
return
}
c.Next()
}
return
}
// 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:<port>client.goHost header 本就是 loopback通過。
//
// DNS rebinding 情境:攻擊者把 evil.com 重綁到 127.0.0.1
// fetch('http://evil.com:<port>/...') 實際打到本機、但 Host header 為
// evil.com:<port> ≠ loopback → 被 400 擋下。
func HostGuard() gin.HandlerFunc {
return func(c *gin.Context) {
if !isLoopbackHost(c.Request.Host) {
c.AbortWithStatus(http.StatusBadRequest)
// 白名單 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")
if method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
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]
}

View File

@ -3,7 +3,6 @@ package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
@ -200,253 +199,3 @@ func TestCORSMiddleware_SameOrigin(t *testing.T) {
t.Errorf("same-origin 不應回 ACA-Origingot %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_CloudPreflightNoPNARequestpreflight 未帶 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 headergot %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-Origingot %q", got)
}
}
// TestCORSMiddleware_LoopbackCredentialsUnchangedloopback origin 仍回 Credentials:trueM8-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 trueADR-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 rebindingHost 為攻擊者網域 → 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)
}
}
}

View File

@ -49,12 +49,6 @@ 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
@ -93,30 +87,11 @@ func NewRouter(
api.GET("/camera/stream", cameraHandler.StreamMJPEG)
// Media
// ADR-019 §2.5 M2舊 tunnel-path media upload route 加 HostGuard
// 關「同機直打舊 route」的殘留面Host=loopback 才放行)。
// tunnel 轉發的 Host 本就是 127.0.0.1:<port> 故不受影響。
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.POST("/media/upload/image", cameraHandler.UploadImage)
api.POST("/media/upload/video", cameraHandler.UploadVideo)
api.POST("/media/upload/batch-images", cameraHandler.UploadBatchImages)
api.GET("/media/batch-images/:index", cameraHandler.GetBatchImageFrame)
api.POST("/media/seek", cameraHandler.SeekVideo)
// ADR-019本機直連支援 endpoint/api/local/*)。
// 整組套 HostGuardM2Host=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不看 OriginC1+ 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
@ -204,7 +179,6 @@ 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 '_')
//

View File

@ -1,173 +0,0 @@
package api
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"sync"
"time"
)
// ADR-019 §2.4one-time upload token store。
//
// 設計(已經 security review 議題 2 通過):
// - token = crypto/rand 32 bytes → base64url禁 math/rand
// - TTL 120s、one-timeconsume 即刪)、綁 deviceId
// - 記憶體 storemap + 單一 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")
// ErrTokenInvalidtoken 不存在 / 過期 / 已使用 / 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 綁定 deviceIDsingle-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 驗證並消費一個 tokenone-time。single-flight 持鎖:
// 查存在 + 比對 deviceID + 未過期 + 刪除,全部在同一 Lock 內完成。
//
// 成功 → 回 niltoken 已從 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) }
}

View File

@ -1,213 +0,0 @@
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 應 ErrTokenInvalidgot %v", err)
}
}
// TestTokenStore_Consume_DeviceMismatchdeviceId 不符 → 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 不符應 ErrTokenInvalidgot %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 應 ErrTokenInvalidgot %v", err)
}
if err := s.Consume("", "dev-1"); !errors.Is(err, ErrTokenInvalid) {
t.Errorf("空 token 應 ErrTokenInvalidgot %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 應 ErrTokenInvalidgot %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("達上限應 ErrTokenLimitgot %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必須恰好 1single-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_StopWorksStartCleanup 回傳的 stop 可正常關閉 goroutine。
func TestTokenStore_Cleanup_StopWorks(t *testing.T) {
s := NewTokenStore()
stop := s.StartCleanup()
// 立即停止不應 panic / deadlock
stop()
}

View File

@ -92,12 +92,6 @@ type Deps struct {
// fallback不需真 tunnel。詳見 device_driver_status.go。
DriverStatusFetcher driverStatusFetcher
// LocalTokenIssuer 是 POST /api/devices/:id/local-upload-ticketADR-019 WP-5的可選注入點。
// 為 nil 時 handler 從 Forwarder + SessionStore 組 defaultforwarderLocalTokenIssuer
// 既有 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

View File

@ -39,12 +39,6 @@ func registerDeviceRoutes(g *gin.RouterGroup, deps Deps) {
// Unpair雛形實作軟刪 DeviceRepo + CloseSession
g.POST("/devices/:id/unpair", devicesUnpairHandler(deps))
// ADR-019 WP-5localhost 直連上傳的 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 回應中的單筆裝置。

View File

@ -1,316 +0,0 @@
// local_upload_ticket.go — POST /api/devices/:id/local-upload-ticket 的雲端 ticket handlerADR-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-serverPOST /api/devices/:id/local-upload-ticket本 handler
// api-server 驗 OIDC sessionAuthMiddleware+ 裝置歸屬GetBySerial
// → 經既有 tunnel 轉發打 local-agentPOST /api/local/issue-tokenbody {"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.3POST /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。
//
// 刻意設短5sissue-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 語意:
// - errLocalTokenLimitlocal-agent 回 429未使用 token 達 32 上限)→ caller 透傳 429。
// - session.ErrSessionNotFound / ErrSessionClosedtunnel 離線 → 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-tokenbody {"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 bodyapi-spec §6.3)。
type issueTokenRequest struct {
Serial string `json:"serial"`
}
// issueTokenEnvelope 是 local-agent /api/local/issue-token 的回應 envelopeapi-spec §6.3
//
// { "success": true, "data": { "token": "...", "expiresAt": <unix_ms>, "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 時回 nilcaller 據此回 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 tokenpickActiveSessionToken
// 2. 組 POST /api/local/issue-tokenbody {"serial":serial}),經 Forwarder.ForwardHTTP 送到 local-agent
// 3. 解 envelope429 → errLocalTokenLimitsuccess + 有 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()
// 限讀 bodyissue-token 回應極小;防禦性 64KB 上界,避免異常 local-agent 撐爆記憶體)。
raw, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return LocalUploadTicket{}, err
}
// 429token 上限已滿 → 透傳 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 組 defaultproduction
// - 兩者皆缺 → 回 nilhandler 回 501代表 tunnel 未配置)
func resolveLocalTokenIssuer(deps Deps) localTokenIssuer {
if deps.LocalTokenIssuer != nil {
return deps.LocalTokenIssuer
}
return newForwarderLocalTokenIssuer(deps)
}
// localUploadTicketHandler 實作 POST /api/devices/:id/local-upload-ticketADR-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拿不到 = 500middleware 設定錯誤)
// 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 → 透傳 token429 透傳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-tokenissuer 內部用 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_ERRORlocal-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-3tunnel 離線時無法取得 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)
}
}

View File

@ -1,204 +0,0 @@
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_DISCONNECTEDR-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)
}

View File

@ -1,363 +0,0 @@
/**
* local-agent.ts ADR-019 WP-3
*
* mock / local-agent
* - computeSerialHashSHA-256("visiona-local-v1" || serial) hex Web Crypto
* - probeLocalAgentPort 3721 37223740 timeout
* - resolveLocalAgentOK / 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<string> {
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 / resolveLocalAgentmock 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("resolveLocalAgentserial 相符 → 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");
});
});
/* -------------------------------------------------------------------------- */
/* uploadToLocalAgentmock XMLHttpRequest */
/* -------------------------------------------------------------------------- */
interface FakeXHR {
method?: string;
url?: string;
withCredentials?: boolean;
timeout?: number;
sent?: FormData;
headers: Record<string, string>;
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("uploadToLocalAgentXHR 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_INVALIDenvelope 有 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" });
});
});

View File

@ -1,443 +0,0 @@
/**
* Local-agent localhost visionA Cloud ADR-019 WP-3
*
* client utilendpoint
* - `probeLocalAgentPort()` local-agent port
* sessionStorage 3721 37223740 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:<port><path>,帶 X-Visiona-Local-Token。
*
* media.ts
* media.ts same-origin cookie sessionBFFlocal-agent
* loopback URL + one-time token headerADR-019 §2.1
*
*
* api-spec §6.26.5 / ADR-019 §2.3
* - port 37213740local-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 fallbackADR-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/healthADR-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 回傳的 dataapi-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 hashWeb 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<string> {
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/hellotimeout 500ms
*
* ProbeHit hello data reject / / timeout
* ** local-agent port** serial resolveLocalAgent
*/
async function probeSinglePort(port: number): Promise<ProbeHit> {
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、不需 cookieapi-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 → 37223740 並發) */
/* -------------------------------------------------------------------------- */
/**
* local-agent port ProbeHit
*
* ADR-019 §2.3
* 1. sessionStorage port session
* 2. 3721 port
* 3. 37223740 Promise.any
* timeout 500ms reject NOT_FOUND
*
*
* / 3721 20 port preflight +
*/
export async function probeLocalAgentPort(): Promise<ProbeHit> {
// 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) 37223740 並發,取第一個成功者
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 serialNumberkn_numberADR-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 agentADR-019 §2.3 / R-4
*/
export async function resolveLocalAgent(
serial: string,
): Promise<LocalAgentResolveResult> {
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 multipartendpoint
*
* callerimage / 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 URLhttp://127.0.0.1:<port>),非 same-origin
* - credentials cookiewithCredentials = falseapi-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<MediaUploadResponse> {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const url = `http://${LOCAL_AGENT_HOST}:${port}${normalizedPath}`;
return new Promise<MediaUploadResponse>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url, true);
// 直連 loopback不帶 cookietoken 走 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 的 errorLOCAL_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);
});
}

View File

@ -11,7 +11,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
MAX_BATCH_IMAGES,
MAX_BATCH_TOTAL_BYTES,
MAX_VIDEO_BYTES,
VIDEO_FALLBACK_FPS,
buildBatchImageUrl,
@ -86,41 +85,6 @@ 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", () => {

View File

@ -93,16 +93,6 @@ 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_BYTES20MB****
* 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 {
/** 上傳進度 callback0~100 */
onProgress?: (percent: number) => void;
@ -279,18 +269,9 @@ export function buildBatchImageUrl(index: number, cacheBust?: string): string {
return `${full}?_t=${encodeURIComponent(cacheBust)}`;
}
/**
* UI
*
* code
* - `TYPE`
* - `SIZE` image 20MB / video 90MB
* - `COUNT` MAX_BATCH_IMAGES
* - `EMPTY`
* - `TOTAL_SIZE` MAX_BATCH_TOTAL_BYTESADR-019 §2.2 filename
*/
/** 前端檔案驗證結果(給 UI 顯示錯誤用;不信任副檔名,也擋大小)。 */
export interface FileValidationError {
code: "TYPE" | "SIZE" | "COUNT" | "EMPTY" | "TOTAL_SIZE";
code: "TYPE" | "SIZE" | "COUNT" | "EMPTY";
filename?: string;
}
@ -316,24 +297,13 @@ export function validateVideoFile(file: File): FileValidationError | null {
return null;
}
/**
* + / + null
*
*
* 1. EMPTY
* 2. COUNT
* 3. / TYPE / SIZE沿 validateImageFile
* 4. MAX_BATCH_TOTAL_BYTES TOTAL_SIZEADR-019 §2.2
*/
/** 驗證整批圖片(數量 + 每張型別 / 大小)。回傳 null 表通過。 */
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;
}

View File

@ -41,12 +41,6 @@ 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" // 401token 不存在 / 過期 / 已使用 / 缺失
| "LOCAL_TOKEN_LIMIT" // 429未使用 token 達 32 上限
| "LOCAL_UPLOAD_TOO_LARGE" // 413上傳超過 size 上限
// 前端自建,表示「非後端回傳」的錯誤
| "NETWORK_ERROR"
| "TIMEOUT"