實作 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>
168 lines
5.5 KiB
Go
168 lines
5.5 KiB
Go
package handlers
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"log"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"visiona-agent/server/internal/device"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// LocalSerialSalt 是 serial 雜湊的固定公開常數 salt(ADR-019 §2.3 / api-spec §6.3 議題 1 裁決)。
|
||
//
|
||
// 刻意「公開、固定、前後端共用寫死」——非 server 私有隨機值。
|
||
// 目的是讓前端能用 Web Crypto 獨立重算 SHA-256("visiona-local-v1" || serial) 比對,
|
||
// 而非「防暴力還原」(序號熵低、salt 公開時仍可枚舉回推,security 判定與威脅相稱)。
|
||
//
|
||
// 明確不要做:不得用 crypto/rand 私有 salt(前端算不出);不得 per-request 隨機 salt。
|
||
const LocalSerialSalt = "visiona-local-v1"
|
||
|
||
// deviceLister 抽象 device.Manager 的 ListDevices,方便測試注入。
|
||
type deviceLister interface {
|
||
ListDevices() []deviceInfoView
|
||
}
|
||
|
||
// deviceInfoView 是 hello 需要的最小 device 視圖(只要 serial)。
|
||
type deviceInfoView struct {
|
||
SerialNumber string
|
||
}
|
||
|
||
// managerAdapter 把 *device.Manager 轉成 deviceLister。
|
||
type managerAdapter struct {
|
||
mgr *device.Manager
|
||
}
|
||
|
||
func (a managerAdapter) ListDevices() []deviceInfoView {
|
||
infos := a.mgr.ListDevices()
|
||
out := make([]deviceInfoView, 0, len(infos))
|
||
for _, info := range infos {
|
||
out = append(out, deviceInfoView{SerialNumber: info.SerialNumber})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// fakeSerialNumber 是 pyusb-fallback placeholder,代表「沒有真實序號」。
|
||
// 與 device 套件保持一致(device.manager.go),不對它計 hash(無意義且會洩漏 placeholder)。
|
||
const fakeSerialNumber = "0x00000000"
|
||
|
||
// LocalHandler 提供 ADR-019 的本機直連支援 endpoint(hello / issue-token)。
|
||
type LocalHandler struct {
|
||
devices deviceLister
|
||
store localTokenStore
|
||
}
|
||
|
||
// localTokenStore 是 LocalHandler 依賴的 token store 介面(issue 用)。
|
||
// 對應 api.TokenStore,用介面避免 handlers → api 的反向依賴。
|
||
type localTokenStore interface {
|
||
Issue(deviceID string) (token string, expiresAt time.Time, err error)
|
||
IsLimitErr(err error) bool
|
||
}
|
||
|
||
// NewLocalHandler 建立 LocalHandler。mgr 提供裝置序號、store 提供 token 發放。
|
||
func NewLocalHandler(mgr *device.Manager, store localTokenStore) *LocalHandler {
|
||
return &LocalHandler{
|
||
devices: managerAdapter{mgr: mgr},
|
||
store: store,
|
||
}
|
||
}
|
||
|
||
// hashSerial 計算 SHA-256(salt || fullSerial) 的 lowercase hex(ADR-019 §2.3)。
|
||
func hashSerial(serial string) string {
|
||
sum := sha256.Sum256([]byte(LocalSerialSalt + serial))
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
// Hello 是 GET /api/local/hello — 同機偵測 + 身分驗證(bootstrap,無 token)。
|
||
//
|
||
// 回傳(最小揭露,api-spec §6.3):
|
||
// - serialHashes:每個 = SHA-256("visiona-local-v1" || fullSerial) hex
|
||
// - supportsLocalUpload:布林 true
|
||
//
|
||
// 明確不回:agentVersion、完整 serial、deviceId、機器名、任何其他欄位。
|
||
func (h *LocalHandler) Hello(c *gin.Context) {
|
||
infos := h.devices.ListDevices()
|
||
hashes := make([]string, 0, len(infos))
|
||
for _, info := range infos {
|
||
serial := strings.TrimSpace(info.SerialNumber)
|
||
// 跳過空 / fake placeholder 序號——無真實身分、hash 它只會洩漏 placeholder。
|
||
if serial == "" || strings.EqualFold(serial, fakeSerialNumber) {
|
||
continue
|
||
}
|
||
hashes = append(hashes, hashSerial(serial))
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": gin.H{
|
||
"serialHashes": hashes,
|
||
"supportsLocalUpload": true,
|
||
},
|
||
})
|
||
}
|
||
|
||
// issueTokenRequest 是 issue-token 的請求 body。
|
||
type issueTokenRequest struct {
|
||
Serial string `json:"serial"`
|
||
}
|
||
|
||
// IssueToken 是 POST /api/local/issue-token — 產 one-time upload token。
|
||
//
|
||
// 取得路徑:僅經既有 tunnel 由 api-server 轉發呼叫(受 HostGuard 約束 = loopback)。
|
||
// 產出的 token 綁 deviceId(此處 = serial)+ one-time + 120s TTL。
|
||
// 達 32 上限 → 429 LOCAL_TOKEN_LIMIT。
|
||
//
|
||
// 稽核 log:記 deviceId + 成功/失敗,絕不 log token 明文。
|
||
func (h *LocalHandler) IssueToken(c *gin.Context) {
|
||
var req issueTokenRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{
|
||
"code": "BAD_REQUEST", "message": "serial is required",
|
||
}})
|
||
return
|
||
}
|
||
serial := strings.TrimSpace(req.Serial)
|
||
if serial == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": gin.H{
|
||
"code": "BAD_REQUEST", "message": "serial is required",
|
||
}})
|
||
return
|
||
}
|
||
|
||
token, expiresAt, err := h.store.Issue(serial)
|
||
if err != nil {
|
||
if h.store.IsLimitErr(err) {
|
||
// 稽核:達上限(不含 token)。
|
||
log.Printf("[local-token] issue REJECTED (limit) deviceId=%s ts=%s",
|
||
serial, time.Now().UTC().Format(time.RFC3339))
|
||
c.JSON(http.StatusTooManyRequests, gin.H{"success": false, "error": gin.H{
|
||
"code": "LOCAL_TOKEN_LIMIT", "message": "too many unused upload tokens",
|
||
}})
|
||
return
|
||
}
|
||
log.Printf("[local-token] issue ERROR deviceId=%s ts=%s err=%v",
|
||
serial, time.Now().UTC().Format(time.RFC3339), err)
|
||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": gin.H{
|
||
"code": "INTERNAL_ERROR", "message": "failed to issue token",
|
||
}})
|
||
return
|
||
}
|
||
|
||
// 稽核:發放成功(deviceId + 時間,絕不記 token 明文)。
|
||
log.Printf("[local-token] issue OK deviceId=%s ts=%s",
|
||
serial, time.Now().UTC().Format(time.RFC3339))
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"data": gin.H{
|
||
"token": token,
|
||
"expiresAt": expiresAt.UnixMilli(),
|
||
"ttlSeconds": 120,
|
||
},
|
||
})
|
||
}
|