實作 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>
174 lines
5.2 KiB
Go
174 lines
5.2 KiB
Go
package api
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"crypto/subtle"
|
||
"encoding/base64"
|
||
"errors"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// ADR-019 §2.4:one-time upload token store。
|
||
//
|
||
// 設計(已經 security review 議題 2 通過):
|
||
// - token = crypto/rand 32 bytes → base64url(禁 math/rand)
|
||
// - TTL 120s、one-time(consume 即刪)、綁 deviceId
|
||
// - 記憶體 store(map + 單一 sync.Mutex,不持久化)
|
||
// - 未使用上限 32 個(防記憶體 DoS)
|
||
// - 比對用 crypto/subtle.ConstantTimeCompare(防 timing attack)
|
||
// - consume single-flight:查存在 + 比對 + 刪除三步在同一 Lock 內完成(security m2,防 race)
|
||
const (
|
||
tokenTTL = 120 * time.Second
|
||
maxUnusedTokens = 32
|
||
tokenCleanupPeriod = 60 * time.Second
|
||
tokenRandBytes = 32
|
||
)
|
||
|
||
// 明確的錯誤,供 handler 對應到 api-spec §6.5 的錯誤碼。
|
||
var (
|
||
// ErrTokenLimit:未使用 token 達 32 上限(→ 429 LOCAL_TOKEN_LIMIT)。
|
||
ErrTokenLimit = errors.New("local token limit reached")
|
||
// ErrTokenInvalid:token 不存在 / 過期 / 已使用 / deviceId 不符(→ 401 LOCAL_TOKEN_INVALID)。
|
||
ErrTokenInvalid = errors.New("local token invalid")
|
||
)
|
||
|
||
// tokenEntry 是一筆未消費的 token 記錄。
|
||
type tokenEntry struct {
|
||
deviceID string
|
||
expiresAt time.Time
|
||
}
|
||
|
||
// TokenStore 是執行緒安全的 one-time token 記憶體 store。
|
||
//
|
||
// 併發正確性核心:所有讀寫都在單一 mu 內完成。
|
||
// Consume 是 single-flight——「查存在 + ConstantTimeCompare + 刪除」在同一 Lock()
|
||
// 內原子完成,兩個併發 consume 同一 token 不可能都成功(防 one-time 失效)。
|
||
type TokenStore struct {
|
||
mu sync.Mutex
|
||
tokens map[string]tokenEntry
|
||
now func() time.Time // 可注入,方便測試過期邏輯
|
||
}
|
||
|
||
// NewTokenStore 建立 store。now 預設為 time.Now。
|
||
func NewTokenStore() *TokenStore {
|
||
return &TokenStore{
|
||
tokens: make(map[string]tokenEntry),
|
||
now: time.Now,
|
||
}
|
||
}
|
||
|
||
// Issue 產生一個新 token 綁定 deviceID,single-flight 持鎖完成
|
||
// 「清過期 + 查 len < 32 + 插入」。達上限回 ErrTokenLimit。
|
||
//
|
||
// token 值以 crypto/rand 產生(32 bytes → base64url RawURL)。
|
||
func (s *TokenStore) Issue(deviceID string) (string, time.Time, error) {
|
||
// 先在鎖外產生亂數(crypto/rand 可能較慢,避免長時間持鎖)。
|
||
buf := make([]byte, tokenRandBytes)
|
||
if _, err := rand.Read(buf); err != nil {
|
||
return "", time.Time{}, err
|
||
}
|
||
token := base64.RawURLEncoding.EncodeToString(buf)
|
||
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
// 惰性清理過期 token,順便為上限計算釋放名額。
|
||
s.pruneExpiredLocked()
|
||
|
||
if len(s.tokens) >= maxUnusedTokens {
|
||
return "", time.Time{}, ErrTokenLimit
|
||
}
|
||
|
||
expiresAt := s.now().Add(tokenTTL)
|
||
s.tokens[token] = tokenEntry{deviceID: deviceID, expiresAt: expiresAt}
|
||
return token, expiresAt, nil
|
||
}
|
||
|
||
// Consume 驗證並消費一個 token(one-time)。single-flight 持鎖:
|
||
// 查存在 + 比對 deviceID + 未過期 + 刪除,全部在同一 Lock 內完成。
|
||
//
|
||
// 成功 → 回 nil(token 已從 store 移除,不可再用)。
|
||
// 失敗(不存在 / 過期 / deviceId 不符)→ 回 ErrTokenInvalid。
|
||
//
|
||
// deviceID 比對用 ConstantTimeCompare(雖然 deviceID 非高機密,維持一致的常數時間比對紀律)。
|
||
func (s *TokenStore) Consume(token, deviceID string) error {
|
||
if token == "" {
|
||
return ErrTokenInvalid
|
||
}
|
||
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
entry, ok := s.tokens[token]
|
||
if !ok {
|
||
return ErrTokenInvalid
|
||
}
|
||
|
||
// 不論後續成功與否,one-time 語意要求「命中即刪」——刪除放在最前面,
|
||
// 確保兩個併發 consume 只有第一個拿到 entry、第二個 map 查不到。
|
||
delete(s.tokens, token)
|
||
|
||
// 過期檢查(惰性)。
|
||
if !s.now().Before(entry.expiresAt) {
|
||
return ErrTokenInvalid
|
||
}
|
||
|
||
// deviceID 綁定檢查(常數時間比對)。
|
||
if subtle.ConstantTimeCompare([]byte(entry.deviceID), []byte(deviceID)) != 1 {
|
||
return ErrTokenInvalid
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// IsLimitErr 回報 err 是否為「達 token 上限」(給 handler 對應 429)。
|
||
// 讓 handlers 套件不需 import sentinel error 即可判斷。
|
||
func (s *TokenStore) IsLimitErr(err error) bool {
|
||
return errors.Is(err, ErrTokenLimit)
|
||
}
|
||
|
||
// pruneExpiredLocked 移除所有已過期的 token。呼叫端必須已持有 mu。
|
||
func (s *TokenStore) pruneExpiredLocked() {
|
||
now := s.now()
|
||
for tok, entry := range s.tokens {
|
||
if !now.Before(entry.expiresAt) {
|
||
delete(s.tokens, tok)
|
||
}
|
||
}
|
||
}
|
||
|
||
// pruneExpired 是背景 goroutine 用的加鎖版本。
|
||
func (s *TokenStore) pruneExpired() {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
s.pruneExpiredLocked()
|
||
}
|
||
|
||
// len 回傳目前未使用 token 數(測試用)。
|
||
func (s *TokenStore) len() int {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return len(s.tokens)
|
||
}
|
||
|
||
// StartCleanup 啟動背景清理 goroutine,每 tokenCleanupPeriod 掃一次過期 token。
|
||
// 惰性清理(Consume/Issue 時)+ 背景清理雙保險。
|
||
// 回傳 stop 函式(給測試 / graceful shutdown 用)。
|
||
func (s *TokenStore) StartCleanup() (stop func()) {
|
||
ticker := time.NewTicker(tokenCleanupPeriod)
|
||
done := make(chan struct{})
|
||
go func() {
|
||
for {
|
||
select {
|
||
case <-ticker.C:
|
||
s.pruneExpired()
|
||
case <-done:
|
||
ticker.Stop()
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
return func() { close(done) }
|
||
}
|