jim800121chen 9031153553 feat(adr-019): 影片/圖片/批次上傳走同機 localhost 直連 local-agent
實作 ADR-019 混合路徑:影片/圖片/批次的檔案上傳改由瀏覽器同機直連
local-agent localhost endpoint(繞過雲端 tunnel),控制面 + MJPEG 結果 +
推論 WS 仍走 tunnel。解決大檔頻寬雙倍 + nginx 100M + 300s timeout。

三條 stream(全數過 reviewer + security code-level 複審 APPROVED):

local-agent(Go):
- CORS 雲端 origin 完整精確比對 + Allow-Credentials:false + HostGuard(loopback)
  + PNA header(middleware.go)
- 新 route /api/local/media/upload/*(一律要 token、不看 Origin,關 C1 後門)
- one-time token store(crypto/rand、TTL 120s、綁 deviceId、single-flight consume、
  上限 32→429;200 goroutine -race 綠)
- GET /api/local/hello(回 salted SHA-256 serialHashes、最小揭露)
  + POST /api/local/issue-token(Host-based)
- LocalUploadGuard(token+size 驗證放 FormFile 前);video≤500MB / batch 合計 80MB
  → 413;stopActivePipeline + batch 生命週期 temp 檔清理

cloud(visionA-backend):
- POST /api/devices/:serial/local-upload-ticket(OIDC + 裝置歸屬 + 經 tunnel
  轉發 issue-token;IDOR-safe、錯誤不洩漏)

frontend(visionA-frontend):
- lib/local-agent.ts(port 探測 3721-3740 並發+快取、Web Crypto serial hash 比對
  同機判定、uploadToLocalAgent 通用函式)
- validateBatchFiles 合計大小檢查(MAX_BATCH_TOTAL_BYTES=80MB,消 50×19MB 撞 413 地雷)

回歸:ADR-019 相關 270 測試全綠、既有 tunnel 路徑未被打斷、無 regression。
既有 tunnel(無 Origin)不要求 token(C1 route 分離相容性保證)。

Refs: ADR-019。WP-0(PNA 實機)/WP-4(影片分頁接線)下一批。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 12:32:26 +08:00

168 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package 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,
},
})
}