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

174 lines
5.2 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 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) }
}