jim800121chen b9ee184586 feat(device): WP-0 序號串通 + serial 路由(ADR-018 走向 A' 第一階段)
- local-agent/server:detector 保留 kn_number(parseScanDevices 可測化、
  合成 ID 語意不動)+ DeviceInfo.SerialNumber + Manager serialToLocalID
  反查表 + GetDevice 雙查(sessions 先、serial 後,純加法)
- visiona-agent:pairing exchange 帶本地裝置清單(DeviceLister 失敗不中斷
  配對、timeout 2s、omitempty 舊版相容)
- visionA-backend:exchange 收 devices —— R1 取第一顆可用序號、R2 假序號
  0x00000000 寫 NULL、R4 同 owner 同序號復用既有 device_id(防 23505)、
  pg+mem 兩實作對齊
- 五個 proxy 操作(flash/inference/camera/connect/disconnect)收斂於
  GetDevice 單一入口,serial 路由一處涵蓋
- docs:api-spec.md §2 增補 POST /api/pairing/exchange(schema + R1/R2/R4)
- 測試:行為級四環節鏈 + dbtest 130 實跑 6/6 + DBOn 回歸 4/4;
  三 module build/vet/test 全綠
- review:通過 0C/0M/6Mi/5Sug(.autoflow/05-implementation/review/
  wp0-serial-routing-review.md);Minor #1 Rescan stale session 掛 WP-C 前置

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:31:51 +08:00

557 lines
20 KiB
Go
Raw Permalink 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 (
"context"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"visiona-backend/internal/auth"
"visiona-backend/internal/session"
)
// pairingTokenTTL 是新發 pairing token 的存活時間。
//
// 對齊 security.md §1.3 的「短期一次性 token」設計15 分鐘足夠完成
// 「使用者啟動 local-tool → 點配對按鈕 → token 進來」的流程。
const pairingTokenTTL = 15 * time.Minute
// registerPairingRoutes 註冊 /api/pairing/* 的 routes。
//
// MVP 全集合B4 + B5
// - POST /api/pairing/token → 建立 + 回傳 pairing token
// - GET /api/pairing/status → 回傳當前 user 的 tunnel 連線狀態
// - GET /api/pairing/tokens → 列當前 user 的所有 tokenB5
// - DELETE /api/pairing/tokens/:token → 撤銷指定 tokenB5
func registerPairingRoutes(g *gin.RouterGroup, deps Deps) {
g.POST("/pairing/token", pairingCreateTokenHandler(deps))
g.GET("/pairing/status", pairingStatusHandler(deps))
g.GET("/pairing/tokens", pairingListTokensHandler(deps))
g.DELETE("/pairing/tokens/:token", pairingRevokeTokenHandler(deps))
}
// PairingTokenResponse 是 POST /api/pairing/token 的 data payload。
//
// 對齊 api-spec.md §2
//
// { "token": "vAc_...", "expires_at": "..." }
type PairingTokenResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
// pairingCreateTokenHandler 建立一個新的 pairing token。
//
// 流程:
// 1. 從 UserContext 取出 userIDOIDC sub由 AuthMiddleware 注入)
// 2. PairingStore.Create 產生一個合法格式的 vAc_ token
// 3. 回傳 token plaintext 給前端「只此一次」顯示
//
// 失敗回應:
// - PairingStore 未注入 → 501 NOT_IMPLEMENTED
// - Create 內部錯誤 → 500 INTERNAL_ERROR
//
// 安全提醒plaintext 只回給合法登入的使用者,不寫進 server log。
func pairingCreateTokenHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
if deps.PairingStore == nil {
WriteNotImplemented(c, "pairing store not configured")
return
}
// Phase 0.7 security fix M1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
// 移除 inline demo-user fallback強制要求 AuthMiddleware 已注入合法 UserContext。
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(), 5*time.Second)
defer cancel()
plaintext, info, err := deps.PairingStore.Create(ctx, userID, pairingTokenTTL)
if err != nil {
logOrDefault(deps.Logger).Error("pairing: create token failed",
"error", err,
"user_id", userID,
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"failed to create pairing token", nil)
return
}
// 取 ExpiresAt — 雛形必定非 nilpairingTokenTTL > 0保險檢查
var expires time.Time
if info != nil && info.ExpiresAt != nil {
expires = *info.ExpiresAt
}
// 故意不 log plaintext只 log token prefix 與 expires
logOrDefault(deps.Logger).Info("pairing: token created",
"user_id", userID,
"token_prefix", tokenPrefix(plaintext),
"expires_at", expires,
"request_id", RequestIDFrom(c))
WriteSuccess(c, http.StatusOK, PairingTokenResponse{
Token: plaintext,
ExpiresAt: expires,
})
}
}
// PairingStatusResponse 是 GET /api/pairing/status 的 data payload。
//
// 對齊 api-spec.md §2
//
// {
// "connected": true,
// "connected_at": "...",
// "last_seen_at": "...",
// "device_id": "dev-xxx",
// "agent_version": "..."
// }
type PairingStatusResponse struct {
Connected bool `json:"connected"`
ConnectedAt *time.Time `json:"connected_at,omitempty"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
DeviceID string `json:"device_id,omitempty"`
AgentVersion string `json:"agent_version,omitempty"`
}
// pairingStatusHandler 回報當前 user 的 tunnel 連線狀態。
//
// 雛形實作:直接 List 所有 sessions單 user 場景),找第一個。
// 多 user 階段B5會改成「按 user_id 過濾」並考慮多 device 場景。
func pairingStatusHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
resp := PairingStatusResponse{Connected: false}
if deps.SessionStore == nil {
WriteSuccess(c, http.StatusOK, resp)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
summaries, err := deps.SessionStore.List(ctx)
if err != nil {
// 跟 system/health 一樣list 失敗不致命,回 connected=false
logOrDefault(deps.Logger).Warn("pairing/status: list sessions failed",
"error", err,
"request_id", RequestIDFrom(c))
WriteSuccess(c, http.StatusOK, resp)
return
}
// Phase 0.7 security audit M2寬鬆比對暫保留待人工介入修復。
// 詳細理由見 proxy.go pickActiveSessionToken 註解relay 端 LocalHandle.Summary
// 不帶 UserIDstrict equality 會讓所有 e2e proxy 鏈路全斷。
// 此處仍要求 UserContext 非空C1 加固),但 s.UserID 暫接受空字串。
uc, ok := UserContextFrom(c)
var picked *session.Summary
if ok && uc != nil && uc.UserID != "" {
for _, s := range summaries {
// 寬鬆比對:暫接受 s.UserID == "" 直到 relay 端 backfill UserID。
if s.UserID == "" || s.UserID == uc.UserID {
picked = s
break
}
}
}
if picked == nil {
WriteSuccess(c, http.StatusOK, resp)
return
}
resp.Connected = true
ca := picked.ConnectedAt
ls := picked.LastHeartbeat
resp.ConnectedAt = &ca
resp.LastSeenAt = &ls
resp.DeviceID = picked.DeviceID
// AgentVersion 雛形未從 tunnel 讀回B5 會在 tunnel handshake 時收集
WriteSuccess(c, http.StatusOK, resp)
}
}
// tokenPrefix 截前 8 字元用於 log避免完整 token 進日誌。
//
// 在 pairing.go 中重複實作(不複用 relay package 的同名函式)以避免跨層循環依賴;
// 行為與 relay.tokenPrefix 一致。
func tokenPrefix(t string) string {
if len(t) <= 8 {
return t
}
return t[:8]
}
// ==========================================================================
// B5 新增List / Revoke tokens
// ==========================================================================
// PairingTokenListItem 是 GET /api/pairing/tokens 回應中的單筆 token。
//
// **注意**:不回 Plaintext — 那只能在建立時給一次。
type PairingTokenListItem struct {
TokenPrefix string `json:"token_prefix"` // 前 12 字元,例:`vAc_7f3c8e2a`
Kind string `json:"kind"`
DeviceID string `json:"device_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
UsedAt *time.Time `json:"used_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// pairingListTokensHandler 實作 GET /api/pairing/tokens。
//
// 回當前 user 的所有 token含已使用 / 撤銷 / 過期),供 UI 顯示。
// **絕對不回 Plaintext** — 前端已在建立時保留那份 plaintext只此一次
func pairingListTokensHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
if deps.PairingStore == nil {
WriteNotImplemented(c, "pairing store not configured")
return
}
// Phase 0.7 security fix M1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
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()
tokens, err := deps.PairingStore.List(ctx, userID)
if err != nil {
// 塊 5.4DB 錯誤經 errors.go 映射PG down → 503其餘 → 500收掉塊 3 M1 raw error 洩漏。
WriteDBError(c, deps.Logger, "list pairing tokens", err)
return
}
out := make([]PairingTokenListItem, 0, len(tokens))
for _, t := range tokens {
item := PairingTokenListItem{
TokenPrefix: tokenPrefix12(t.Plaintext),
Kind: string(t.Kind),
DeviceID: t.DeviceID,
CreatedAt: t.CreatedAt,
ExpiresAt: t.ExpiresAt,
UsedAt: t.UsedAt,
RevokedAt: t.RevokedAt,
}
out = append(out, item)
}
WriteSuccess(c, http.StatusOK, out)
}
}
// pairingRevokeTokenHandler 實作 DELETE /api/pairing/tokens/:token。
//
// 雛形:直接收 plaintext token 作為 path parampath 可被日誌記錄 — 雛形容忍;
// Phase 1 會改為回「token id」而非 plaintext路徑不洩漏原文
//
// Revoke 成功回 204 No Content不存在回 404 + NOT_FOUND。
func pairingRevokeTokenHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
if deps.PairingStore == nil {
WriteNotImplemented(c, "pairing store not configured")
return
}
token := c.Param("token")
if token == "" {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
"token param required", nil)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
if err := deps.PairingStore.Revoke(ctx, token); err != nil {
if errors.Is(err, auth.ErrInvalidToken) {
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "token not found", nil)
return
}
// 塊 5.4DB 錯誤經 errors.go 映射PG down → 503其餘 → 500收掉塊 3 M1 raw error 洩漏。
WriteDBError(c, deps.Logger, "revoke pairing token", err)
return
}
logOrDefault(deps.Logger).Info("pairing: token revoked",
"token_prefix", tokenPrefix(token),
"request_id", RequestIDFrom(c))
c.Status(http.StatusNoContent)
}
}
// tokenPrefix12 截前 12 字元(`vAc_` + 8 hex用於 list 顯示。
func tokenPrefix12(t string) string {
if len(t) <= 12 {
return t
}
return t[:12]
}
// ==========================================================================
// AB11 新增POST /api/pairing/exchangepublic — 不走 AuthMiddleware
// ==========================================================================
//
// 行為對齊 visiona-agent-tdd.md §4.3 + security.md §1.2
// 1. agent 送 Pairing Token 過來
// 2. 雲端驗證(存在 / 未過期 / 未使用 / 未撤銷)
// 3. 產生 Session TokenvAs_ + 64 hex90 天 TTL
// 4. 把 Pairing Token 標為 used一次性無法再交換
// 5. 回 { session_token, account, relay_url, expires_at }
//
// 雛形取捨:
// - remote-proxy 端目前只做 token 格式驗證relay/server.go isAcceptableToken
// **不會**實際查 SessionTokenStore。這是對齊 TDD「選項 A」的雛形設計
// Phase 1 要新增 remote-proxy → api-server 的 `/internal/session-token/:token` 驗證。
// - Rate limit / token rotation / 真實 DB 都留給 Phase 1。
// defaultRelayPublicURL 是 relay_url 的雛形 placeholder當 Deps.RelayPublicURL
// 未設定時用此值(讓 agent 至少能收到一個格式正確的 URL實機請透過
// VISIONA_RELAY_PUBLIC_URL 覆寫)。
const defaultRelayPublicURL = "wss://relay.visionA.cloud"
// registerPairingPublicRoutes 註冊**不需要 auth**的 pairing endpoints。
//
// 目前只有 /api/pairing/exchange — agent 拿 Pairing Token 換 Session Token 時
// 本身還沒有登入身份,故不能套 AuthMiddleware。
// 呼叫方NewRouter() 在 engine 層級直接註冊(不加 apiGroup
func registerPairingPublicRoutes(r gin.IRouter, deps Deps) {
r.POST("/api/pairing/exchange", pairingExchangeHandler(deps))
}
// PairingExchangeRequest 是 POST /api/pairing/exchange 的 request body。
//
// Devices 為 WP-0ADR-018 序號地基)新增的 optional 欄位agent 在 exchange 前
// 撈本地 USB 清單(含 Kneron kn_number 序號)上報,雲端據此填
// devices.serial_number。舊 agent 不送此欄位 → 行為與現行完全一致
// (自建 serial=NULL device
type PairingExchangeRequest struct {
PairingToken string `json:"pairing_token" binding:"required"`
Devices []ExchangeDeviceInput `json:"devices,omitempty"`
}
// PairingExchangeResponse 是 POST /api/pairing/exchange 成功時的 data payload。
//
// 欄位對齊 visiona-agent-tdd.md §7.1
//
// {
// "session_token": "vAs_...",
// "account": "demo@visionA.local",
// "relay_url": "wss://relay.visionA.cloud",
// "expires_at": "2026-07-21T00:00:00Z"
// }
type PairingExchangeResponse struct {
SessionToken string `json:"session_token"`
Account string `json:"account"`
RelayURL string `json:"relay_url"`
ExpiresAt time.Time `json:"expires_at"`
}
// Pairing exchange 專用錯誤碼(對齊 TDD §7.1 四種 case
//
// 注意:這些 code 走 ErrorBody.Error.Code 欄位,不是 envelope top-level code。
// 刻意與 security.md §1.2 的語意一致;前端 agent 可以 switch 對應 UI 文案。
const (
ErrCodeInvalidPairingToken = "INVALID_PAIRING_TOKEN"
ErrCodePairingTokenExpired = "PAIRING_TOKEN_EXPIRED"
ErrCodePairingTokenUsed = "PAIRING_TOKEN_USED"
ErrCodePairingTokenRevoked = "PAIRING_TOKEN_REVOKED"
)
// pairingExchangeHandler 實作 Pairing → Session Token 交換。
//
// 失敗回應:
// - 400 VALIDATION_FAILED — body 缺 pairing_token
// - 401 INVALID_PAIRING_TOKEN — 格式錯 / 不存在
// - 401 PAIRING_TOKEN_EXPIRED — 過期
// - 401 PAIRING_TOKEN_USED — 已交換過
// - 401 PAIRING_TOKEN_REVOKED — 已撤銷
// - 500 INTERNAL_ERROR — Session Token 產生失敗
// - 501 NOT_IMPLEMENTED — store 未注入
//
// 安全提醒:回應內**絕對不包含** Pairing Token 原文log 只印 prefix。
func pairingExchangeHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
// 雛形store 任一缺失 → 501避免 nil pointer
if deps.PairingStore == nil || deps.SessionTokenStore == nil {
WriteNotImplemented(c, "pairing exchange store not configured")
return
}
// Parse body
var req PairingExchangeRequest
if err := c.ShouldBindJSON(&req); err != nil {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
"pairing_token is required", nil)
return
}
// 格式驗證 — 不合格直接 401 INVALID_PAIRING_TOKEN避免把格式錯誤漏進 store
if !auth.IsValidPairingToken(req.PairingToken) {
logOrDefault(deps.Logger).Warn("pairing exchange: invalid token format",
"token_prefix", tokenPrefix(req.PairingToken),
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusUnauthorized, ErrCodeInvalidPairingToken,
"pairing token format invalid", nil)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
// Validate — store 會判斷存在 / 未過期 / 未使用 / 未撤銷
info, err := deps.PairingStore.Validate(ctx, req.PairingToken)
if err != nil {
code, msg := mapPairingExchangeError(err)
logOrDefault(deps.Logger).Warn("pairing exchange: validate failed",
"code", code,
"token_prefix", tokenPrefix(req.PairingToken),
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusUnauthorized, code, msg, nil)
return
}
// 自建 device + 建綁該 device 的 session tokenDB-on FK 收尾,問題 #2
//
// session_tokens.device_id 是 NOT NULL FK → devices(id),但雛形 pairing 流程從不建 device。
// 改由 PairingExchanger 在 exchange 時自建一筆 deviceowner = info.UserID該 user 已透過
// OIDC callback provision 進 users 表),並建綁該 device 的 session token——Postgres 後端用
// 單一交易把兩步包成原子(建 device 失敗或建 token 失敗都整筆 rollback
//
// info.TokenHash 作為 parent_token_hash稽核鏈session token ← pairing token
//
// PairingExchanger 為 nilDB-off 雛形最小骨架,未注入)→ fallback 到舊行為:直接用
// info.DeviceID可能為空建 session token不自建 device。in-memory store 不檢查 FK
// 空 deviceID 可接受;此分支只在 main.go 沒注入 exchanger 時走到。
var (
plaintext string
sessionInfo *auth.SessionToken
deviceID string
)
if deps.PairingExchanger != nil {
res, exErr := deps.PairingExchanger.Provision(ctx, info.UserID, info.TokenHash, auth.SessionTokenTTL, req.Devices)
if exErr != nil {
logOrDefault(deps.Logger).Error("pairing exchange: provision device+session failed",
"error", exErr,
"user_id", info.UserID,
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"failed to create session token", nil)
return
}
plaintext = res.SessionPlaintext
sessionInfo = res.SessionInfo
deviceID = res.DeviceID
} else {
var err error
plaintext, sessionInfo, err = deps.SessionTokenStore.Create(
ctx,
info.UserID,
info.DeviceID, // fallback雛形未綁 device_id為空僅 in-memory 可接受)
info.TokenHash,
auth.SessionTokenTTL,
)
if err != nil {
logOrDefault(deps.Logger).Error("pairing exchange: create session token failed",
"error", err,
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"failed to create session token", nil)
return
}
deviceID = info.DeviceID
}
// Mark pairing token as used。
//
// Phase 0.7 security fix M3 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
// MarkUsed 失敗代表「pairing token 一次性」保證可能被破壞 — 同一個 token
// 可能再被 exchange 一次。改為 abort撤銷剛產生的 session token、回 500
// 而不是 silent log warn 繼續往前。
//
// 注意deviceID 現在綁的是自建 deviceDB-on或 info.DeviceIDfallback
// MarkUsed 把 pairing token 也綁上同一個 device_id稽核哪台 device 用掉了這個 token
if err := deps.PairingStore.MarkUsed(ctx, req.PairingToken, deviceID); err != nil {
// 嘗試 revoke 剛產生的 session tokenrevoke 自身失敗不再 retry只 log。
revokeErr := deps.SessionTokenStore.Revoke(ctx, plaintext)
logOrDefault(deps.Logger).Error("pairing exchange: mark used failed; aborted",
"error", err,
"revoke_err", revokeErr,
"token_prefix", tokenPrefix(req.PairingToken),
"session_token_prefix", tokenPrefix(plaintext),
"device_id", deviceID,
"request_id", RequestIDFrom(c))
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"pairing token mark-used failed; aborted", nil)
return
}
// 組 response
relayURL := deps.RelayPublicURL
if relayURL == "" {
relayURL = defaultRelayPublicURL
}
var expires time.Time
if sessionInfo.ExpiresAt != nil {
expires = *sessionInfo.ExpiresAt
} else {
expires = time.Now().UTC().Add(auth.SessionTokenTTL)
}
// 雛形 account — OIDC sub 通常是 UUID 不適合給人看,這裡用 userID + 固定 suffix
// 當 placeholder。Phase 1 接 DB 後可改回 session.EmailOIDC 已帶)。
account := info.UserID + "@visionA.local"
// 故意只 log token prefix避免完整 session token 進日誌
logOrDefault(deps.Logger).Info("pairing exchange: success",
"user_id", info.UserID,
"device_id", deviceID,
"session_token_prefix", tokenPrefix(plaintext),
"pairing_token_prefix", tokenPrefix(req.PairingToken),
"request_id", RequestIDFrom(c))
WriteSuccess(c, http.StatusOK, PairingExchangeResponse{
SessionToken: plaintext,
Account: account,
RelayURL: relayURL,
ExpiresAt: expires,
})
}
}
// mapPairingExchangeError 把 PairingStore.Validate 回傳的 sentinel error 轉成
// 對應的 pairing exchange error code + 對使用者可見的訊息。
//
// 未匹配時 fallback 到 INVALID_PAIRING_TOKEN避免洩漏內部錯誤細節
func mapPairingExchangeError(err error) (code, message string) {
switch {
case errors.Is(err, auth.ErrTokenExpired):
return ErrCodePairingTokenExpired, "pairing token expired"
case errors.Is(err, auth.ErrTokenUsed):
return ErrCodePairingTokenUsed, "pairing token already used"
case errors.Is(err, auth.ErrTokenRevoked):
return ErrCodePairingTokenRevoked, "pairing token revoked"
case errors.Is(err, auth.ErrInvalidToken):
return ErrCodeInvalidPairingToken, "pairing token invalid"
default:
return ErrCodeInvalidPairingToken, "pairing token invalid"
}
}