// pairing_exchange.go — pairing exchange 建 agent + representative device + N 顆真 USB device // 的協調者(A' 模型;ADR-018 / migration 0005,WP-B B3)。 // // 背景(DB-on FK 收尾 #2 → A' 重塑): // // session_tokens.device_id 是 NOT NULL FK → devices(id)。exchange 驗完 pairing token 後需建 // 一筆 device 供 session token 綁定。原雛形是「每次 exchange 自建一筆佔位 device」;A' 模型 // (ADR-018 走向 A')把這件事重塑為: // 1. 建/復用該 owner 的 agent(一 agent = 一條 tunnel 連線;agents 表,migration 0005)。 // 2. 建/復用該 agent 的 representative device(is_representative=true、serial=NULL), // session_tokens.device_id 綁它(維持現行「一條 tunnel 綁一個 device_id」的物理語意, // 但改綁「agent 代表 device」而非隨機佔位——session_tokens schema 一字不動)。 // 3. 對 agent 上報清單的每顆可用序號 USB,建/復用一筆真 USB device(is_representative=false、 // agent_id=同一 agent、填 serial)——R1 完整 N 顆(非只第一顆)。 // // 為什麼抽成 coordinator(比照 unpair.go 的 DeviceUnpairer): // - 讓 handler(pairing.go 的 exchange)維持薄。 // - Postgres 後端用 db.WithTx 把「建 agent + representative + N USB + session token」包成 // 單一交易——任一步失敗整筆 rollback,杜絕中間態(database.md §6 一致性精神)。 // - in-memory 後端依序執行(無交易),行為一致。 // - main.go 依 dbPool 是否非 nil 擇一注入 Deps.PairingExchanger。為 nil 時 exchange handler // fallback 到「不自建 device、直接用 info.DeviceID(可能為空)建 session token」的舊行為 // (與 DB-off 雛形相容;in-memory store 不檢查 FK,空 deviceID 可接受)。 // // 冪等:pairing token 是一次性(MarkUsed 後 Validate 回 ErrTokenUsed),故同一 token 不會被 // exchange 兩次成功。同 owner 多次配對復用同一 agent + 同一 representative device(A' 語意 // 「一 agent 一 representative」),各建一個新 session token 綁該 representative。真 USB device // 依 serial 去重復用(GetBySerial)。重試(exchange 後 MarkUsed 失敗被 abort)時 session token // 已 revoke、agent/device 已建但無新 token 指向它(無安全風險,僅閒置紀錄;雛形可接受)。 package api import ( "context" "errors" "fmt" "log/slog" "regexp" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" "visiona-backend/internal/agent" "visiona-backend/internal/auth" "visiona-backend/internal/db" "visiona-backend/internal/device" ) // representativeDeviceName / representativeDeviceType 是 agent 的 representative device 預設值。 // // representative device 代表「這條 tunnel 連線」本身(非任何一顆實體 USB),綁 session_tokens。 // 真 USB device 的 Name 由 DeviceType/serial 衍生(見 deriveDeviceName,S-2),不再共用此值。 const ( representativeDeviceName = "local-agent (paired)" representativeDeviceType = "local-agent" ) // ExchangeProvisionResult 回報 exchange 建 agent + representative + N USB + session token 的結果。 type ExchangeProvisionResult struct { // DeviceID 是 session token 綁定的 device id——A' 下為該 agent 的 representative device // (is_representative=true)。維持 session_tokens.device_id 綁一個 device 的物理語意。 DeviceID string AgentID string // 本次建/復用的 agent id USBDeviceIDs []string // 本次建/復用的真 USB device ids(is_representative=false,可為空) SessionPlaintext string // 新 session token 原文(caller 只此一次能拿到) SessionInfo *auth.SessionToken // session token 儲存層表示(含 ExpiresAt) } // ExchangeDeviceInput 是 exchange payload 中 agent 上報的單顆實體 USB 裝置 // (WP-0 / ADR-018 序號地基,對齊 agent 端 tunnel.exchangeDevice 的 JSON)。 // // Firmware(S-1 forward-compat):agent 上報韌體版本字串,目前 devices 表無 firmware 欄 // (migration 0005 未建),故 WP-B 尚未消費——收下但不落 DB。未來若要顯示/記錄韌體版本, // 需另開 migration 加 devices.firmware 欄 + 在此 struct → Device 的映射補上(超出 WP-B 範圍)。 // 保留此欄位讓 agent 端 payload 契約穩定、未來加欄時 agent 不需改。 type ExchangeDeviceInput struct { SerialNumber string `json:"serial_number"` DeviceType string `json:"device_type,omitempty"` Firmware string `json:"firmware,omitempty"` // forward-compat:WP-B 尚未消費(見上方說明) } // fakeSerialNumber 是 agent 端 pyusb fallback(無 Kneron SDK,如 macOS 缺 dylib) // 寫死上報的假序號。多顆無 SDK 裝置會撞同一個值,不可當唯一鍵 / 路由鍵—— // 視同「無序號」寫 NULL(ADR-018 §2.2 / task-1 mapping R2)。 const fakeSerialNumber = "0x00000000" // serialPattern 是 Kneron kn_number 的合法格式白名單(Mi#5):0x 前綴 + 8 位十六進位。 // // 為什麼加白名單:serial 是跨層路由鍵(雲端 device ↔ local agent sessions),亂格式序號 // 會污染路由與去重。不符此格式者視同「無序號」(寫 NULL、不進去重、不路由),與假序號同處理。 // 攻擊面受限(需持有效一次性 pairing token 才能觸發 exchange),reviewer 已判不升級 Security。 var serialPattern = regexp.MustCompile(`^0x[0-9A-Fa-f]{8}$`) // normalizeSerial 對上報序號做 trim + 白名單驗證。回傳 (正規化序號, 是否可用)。 // // 不可用(空 / 假序號 / 不符白名單)→ 回 ("", false),呼叫端視同無序號。 func normalizeSerial(raw string) (string, bool) { s := strings.TrimSpace(raw) if s == "" || strings.EqualFold(s, fakeSerialNumber) { return "", false } if !serialPattern.MatchString(s) { return "", false } return s, true } // usableSerialDevices 從 agent 上報清單挑出所有「序號可用」的裝置(R1:完整 N 顆)。 // // A' 模型(WP-B B3):一 agent 底下 N 顆實體 USB 各建一筆真 device。此函式過濾出可用序號者、 // 正規化序號、並對同一序號去重(同一次 exchange 上報重複序號只取第一顆,避免同 tx 內撞 // partial unique)。空序號 / 假序號 / 不符白名單者跳過。 func usableSerialDevices(devices []ExchangeDeviceInput) []ExchangeDeviceInput { out := make([]ExchangeDeviceInput, 0, len(devices)) seen := make(map[string]struct{}, len(devices)) for _, d := range devices { serial, ok := normalizeSerial(d.SerialNumber) if !ok { continue } key := strings.ToLower(serial) // 去重用大小寫不敏感(對齊 GetBySerial 的實體語意) if _, dup := seen[key]; dup { continue } seen[key] = struct{}{} d.SerialNumber = serial out = append(out, d) } return out } // deriveDeviceName 由 agent 上報的 device type + serial 衍生真 USB device 的顯示名稱(S-2)。 // // 取代舊的固定 "local-tool (paired)":真 USB device 應能從名稱看出是哪顆晶片/序號, // 如 "kneron_kl520 (0x1A2B3C4D)"。deviceType 空時退回 "USB device"。 func deriveDeviceName(deviceType, serial string) string { dt := strings.TrimSpace(deviceType) if dt == "" { dt = "USB device" } if serial != "" { return fmt.Sprintf("%s (%s)", dt, serial) } return dt } // PairingExchanger 把「建 agent + representative device + N 顆真 USB device + 建 session token」 // 包成一個原子(Postgres tx)或一致(in-memory 依序)操作(A' 模型,WP-B B3)。 // // Provision 語意:成功回 ExchangeProvisionResult;任一步失敗回 error(handler 經 errors.go // 映射成 5xx,不洩漏 raw error)。 type PairingExchanger interface { // Provision 建/復用該 owner 的 agent + 該 agent 的 representative device,並建一筆綁 // representative device 的 session token;再對 agent 上報清單的每顆可用序號 USB 建/復用 // 一筆真 USB device(掛同一 agent)。 // // parentTokenHash 為來源 pairing token 的 hash(稽核鏈,寫進 session_tokens.parent_token_hash)。 // // devices 為 agent 上報的實體 USB 清單(WP-0 序號地基,可為 nil = 舊 agent / // 撈不到清單,此時只建 agent + representative + session token,不建真 USB device)。 // 可用序號者(通過白名單):同 owner 已有同 serial 的未刪除 device → 復用(防 // uq_devices_owner_serial_active 23505;「同序號重配 = 復用」,R4);否則新建並填 serial。 Provision(ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput) (ExchangeProvisionResult, error) } // ── Postgres 後端 ───────────────────────────────────────────────────────────── // pgDeviceSaver 是 device 在 tx 內 upsert / 查詢的能力(由 device.PostgresRepository 滿足)。 // // 全部走 tx 版(Querier),讓「查既有 → 復用/建」與 session token 在同一交易內序列化 // (Mi#2 lost-update 收斂): // - GetBySerialTx:查同 owner 同 serial 的未刪除真 USB device(去重復用,R4)。 // - GetRepresentativeByAgentTx:查該 agent 既有 representative device(一 agent 一 representative)。 // - SaveTx:upsert device(representative / 真 USB 共用)。 type pgDeviceSaver interface { SaveTx(ctx context.Context, q db.Querier, d *device.Device) error GetBySerialTx(ctx context.Context, q db.Querier, ownerUserID, serial string) (*device.Device, error) GetRepresentativeByAgentTx(ctx context.Context, q db.Querier, agentID string) (*device.Device, error) } // pgAgentProvisioner 是「在 tx 內建/復用 agent」的能力(由 agent.PostgresRepository 滿足)。 type pgAgentProvisioner interface { GetOrCreateAgentTx(ctx context.Context, q db.Querier, ownerUserID, name, platform, agentVersion string, pairedAt time.Time) (*agent.Agent, error) } // pgSessionTokenCreator 是「在 tx 內建 session token」的能力(由 auth.PostgresSessionTokenStore 滿足)。 type pgSessionTokenCreator interface { CreateTx(ctx context.Context, q db.Querier, userID, deviceID, parentTokenHash string, ttl time.Duration) (string, *auth.SessionToken, error) } // pgPairingExchanger 用單一 pgx 交易完成 A' 的 exchange 重塑。 type pgPairingExchanger struct { pool *pgxpool.Pool agents pgAgentProvisioner devices pgDeviceSaver sessionToken pgSessionTokenCreator log *slog.Logger } // NewPostgresPairingExchanger 建立 Postgres 後端的 exchange 協調者。 func NewPostgresPairingExchanger( pool *pgxpool.Pool, agents pgAgentProvisioner, devices pgDeviceSaver, sessionToken pgSessionTokenCreator, log *slog.Logger, ) PairingExchanger { return &pgPairingExchanger{ pool: pool, agents: agents, devices: devices, sessionToken: sessionToken, log: logOrDefault(log), } } // Provision 在單一交易內完成 A' exchange 重塑: // // 1. 建/復用該 owner 的 agent(GetOrCreateAgentTx,advisory lock 序列化同 owner)。 // 2. 建/復用該 agent 的 representative device(is_representative=true、serial=NULL)。 // 3. 建綁 representative device 的 session token(session_tokens.device_id = representative.id)。 // 4. 對每顆可用序號 USB,建/復用一筆真 USB device(is_representative=false、掛同一 agent)。 // // 任一步失敗整筆 rollback(不留半建的 agent / device / token)。 // // Mi#2 lost-update 收斂:所有查詢(GetRepresentativeByAgentTx / GetBySerialTx)都在 tx 內、 // 復用路徑只更新必要欄位(PairedAt / serial / agent 關聯),不全欄覆寫。 func (e *pgPairingExchanger) Provision( ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput, ) (ExchangeProvisionResult, error) { var res ExchangeProvisionResult now := time.Now().UTC() usable := usableSerialDevices(devices) err := db.WithTx(ctx, e.pool, func(q db.Querier) error { // 1) 建/復用 agent。platform / agentVersion 目前 agent 上報 payload 未帶(forward-compat), // 先傳空——GetOrCreateAgentTx 空值不覆寫既有。 ag, agErr := e.agents.GetOrCreateAgentTx(ctx, q, userID, "", "", "", now) if agErr != nil { return fmt.Errorf("exchange: get-or-create agent: %w", agErr) } res.AgentID = ag.ID // 2) 建/復用該 agent 的 representative device(一 agent 一 representative)。 rep, repErr := e.representativeForAgentTx(ctx, q, userID, ag.ID, now) if repErr != nil { return fmt.Errorf("exchange: representative device: %w", repErr) } res.DeviceID = rep.ID // 3) 建綁 representative device 的 session token。 plaintext, info, createErr := e.sessionToken.CreateTx(ctx, q, userID, rep.ID, parentTokenHash, ttl) if createErr != nil { return fmt.Errorf("exchange: create session token: %w", createErr) } res.SessionPlaintext = plaintext res.SessionInfo = info // 4) 每顆可用序號 USB:建/復用真 USB device(掛同一 agent)。 usbIDs := make([]string, 0, len(usable)) for _, in := range usable { usbDev, uErr := e.upsertUSBDeviceTx(ctx, q, userID, ag.ID, in, now) if uErr != nil { return fmt.Errorf("exchange: upsert usb device (serial=%s): %w", in.SerialNumber, uErr) } usbIDs = append(usbIDs, usbDev.ID) } res.USBDeviceIDs = usbIDs return nil }) if err != nil { return ExchangeProvisionResult{}, err } return res, nil } // representativeForAgentTx 建/復用某 agent 的 representative device(tx 內)。 // // 復用(GetRepresentativeByAgentTx 命中):只更新 PairedAt(不全欄覆寫,Mi#2)。 // 新建:is_representative=true、serial=NULL、掛 agent_id。 func (e *pgPairingExchanger) representativeForAgentTx( ctx context.Context, q db.Querier, userID, agentID string, now time.Time, ) (*device.Device, error) { existing, gErr := e.devices.GetRepresentativeByAgentTx(ctx, q, agentID) switch { case gErr == nil: // 復用:只更新配對時間(保留既有欄位)。 existing.PairedAt = &now existing.UpdatedAt = now if saveErr := e.devices.SaveTx(ctx, q, existing); saveErr != nil { return nil, fmt.Errorf("save representative: %w", saveErr) } return existing, nil case errors.Is(gErr, device.ErrNotFound): rep := &device.Device{ ID: uuid.NewString(), OwnerUserID: userID, Name: representativeDeviceName, DeviceType: representativeDeviceType, AgentID: agentID, IsRepresentative: true, // serial_number 留空 → NULL(representative 非真 USB,不佔 partial unique)。 RemoteStatus: device.RemoteStatusOffline, Status: device.USBStatusUnknown, PairedAt: &now, CreatedAt: now, UpdatedAt: now, } if saveErr := e.devices.SaveTx(ctx, q, rep); saveErr != nil { return nil, fmt.Errorf("save representative: %w", saveErr) } return rep, nil default: return nil, fmt.Errorf("get representative: %w", gErr) } } // upsertUSBDeviceTx 建/復用一顆真 USB device(tx 內,掛指定 agent)。 // // 復用(GetBySerialTx 命中同 owner 同 serial 的未刪除 device):只更新 PairedAt + agent 關聯 // + device type/name(不全欄覆寫,Mi#2;R4 同序號重配 = 復用)。新建:is_representative=false、 // 填 serial、Name 由 DeviceType 衍生(S-2)。 func (e *pgPairingExchanger) upsertUSBDeviceTx( ctx context.Context, q db.Querier, userID, agentID string, in ExchangeDeviceInput, now time.Time, ) (*device.Device, error) { existing, gErr := e.devices.GetBySerialTx(ctx, q, userID, in.SerialNumber) switch { case gErr == nil: // 復用:更新配對時間 + agent 關聯 + 上報的 device type(非空時),Name 隨 type 衍生。 existing.PairedAt = &now existing.UpdatedAt = now existing.AgentID = agentID if in.DeviceType != "" { existing.DeviceType = in.DeviceType existing.Name = deriveDeviceName(in.DeviceType, existing.SerialNumber) } if saveErr := e.devices.SaveTx(ctx, q, existing); saveErr != nil { return nil, fmt.Errorf("save usb device: %w", saveErr) } return existing, nil case errors.Is(gErr, device.ErrNotFound): usbDev := &device.Device{ ID: uuid.NewString(), OwnerUserID: userID, Name: deriveDeviceName(in.DeviceType, in.SerialNumber), DeviceType: in.DeviceType, SerialNumber: in.SerialNumber, AgentID: agentID, IsRepresentative: false, RemoteStatus: device.RemoteStatusOffline, Status: device.USBStatusUnknown, PairedAt: &now, CreatedAt: now, UpdatedAt: now, } if saveErr := e.devices.SaveTx(ctx, q, usbDev); saveErr != nil { return nil, fmt.Errorf("save usb device: %w", saveErr) } return usbDev, nil default: return nil, fmt.Errorf("get usb device by serial: %w", gErr) } } // ── in-memory 後端 ──────────────────────────────────────────────────────────── // memSessionTokenCreator 是 in-memory store「建 session token」的能力 // (由 auth.InMemorySessionTokenStore 透過 SessionTokenStore interface 滿足)。 type memSessionTokenCreator interface { Create(ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration) (string, *auth.SessionToken, error) } // memDeviceRepo 是 in-memory device store 供 exchange 用的能力(由 *device.InMemoryRepository 滿足)。 // // 除 Repository 的 Save/GetBySerial 外,額外需要 GetRepresentativeByAgentTx(concrete method)。 // 傳 nil Querier(in-memory 無交易)。 type memDeviceRepo interface { Save(ctx context.Context, d *device.Device) error GetBySerial(ctx context.Context, ownerUserID, serial string) (*device.Device, error) GetRepresentativeByAgentTx(ctx context.Context, q db.Querier, agentID string) (*device.Device, error) } // memAgentRepo 是 in-memory agent store 供 exchange 用的能力(由 *agent.InMemoryRepository 滿足)。 type memAgentRepo interface { GetOrCreateAgentTx(ctx context.Context, q db.Querier, ownerUserID, name, platform, agentVersion string, pairedAt time.Time) (*agent.Agent, error) } // memPairingExchanger 依序(非交易)完成 A' exchange 重塑。 // // in-memory 為單機 local-dev fallback,無跨 store 交易需求;依序執行已能保證行為一致。 // 行為與 pgPairingExchanger 對齊:建/復用 agent → representative device(綁 session token) // → N 顆真 USB device。 type memPairingExchanger struct { agents memAgentRepo devices memDeviceRepo sessionToken memSessionTokenCreator } // NewInMemoryPairingExchanger 建立 in-memory 後端的 exchange 協調者。 func NewInMemoryPairingExchanger( agents memAgentRepo, devices memDeviceRepo, sessionToken memSessionTokenCreator, ) PairingExchanger { return &memPairingExchanger{ agents: agents, devices: devices, sessionToken: sessionToken, } } // Provision(in-memory):建/復用 agent → representative device(綁 session token)→ N USB。 // 行為與 pgPairingExchanger.Provision 對齊(無交易,依序執行)。 func (e *memPairingExchanger) Provision( ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput, ) (ExchangeProvisionResult, error) { var res ExchangeProvisionResult now := time.Now().UTC() usable := usableSerialDevices(devices) // 1) 建/復用 agent。 ag, agErr := e.agents.GetOrCreateAgentTx(ctx, nil, userID, "", "", "", now) if agErr != nil { return ExchangeProvisionResult{}, fmt.Errorf("exchange: get-or-create agent: %w", agErr) } res.AgentID = ag.ID // 2) 建/復用 representative device。 rep, repErr := e.representativeForAgent(ctx, userID, ag.ID, now) if repErr != nil { return ExchangeProvisionResult{}, fmt.Errorf("exchange: representative device: %w", repErr) } res.DeviceID = rep.ID // 3) 建綁 representative device 的 session token。 plaintext, info, err := e.sessionToken.Create(ctx, userID, rep.ID, parentTokenHash, ttl) if err != nil { return ExchangeProvisionResult{}, fmt.Errorf("exchange: create session token: %w", err) } res.SessionPlaintext = plaintext res.SessionInfo = info // 4) 每顆可用序號 USB。 usbIDs := make([]string, 0, len(usable)) for _, in := range usable { usbDev, uErr := e.upsertUSBDevice(ctx, userID, ag.ID, in, now) if uErr != nil { return ExchangeProvisionResult{}, fmt.Errorf("exchange: upsert usb device (serial=%s): %w", in.SerialNumber, uErr) } usbIDs = append(usbIDs, usbDev.ID) } res.USBDeviceIDs = usbIDs return res, nil } // representativeForAgent 建/復用某 agent 的 representative device(in-memory,對齊 pg 版)。 func (e *memPairingExchanger) representativeForAgent( ctx context.Context, userID, agentID string, now time.Time, ) (*device.Device, error) { existing, gErr := e.devices.GetRepresentativeByAgentTx(ctx, nil, agentID) switch { case gErr == nil: existing.PairedAt = &now existing.UpdatedAt = now if saveErr := e.devices.Save(ctx, existing); saveErr != nil { return nil, fmt.Errorf("save representative: %w", saveErr) } return existing, nil case errors.Is(gErr, device.ErrNotFound): rep := &device.Device{ ID: uuid.NewString(), OwnerUserID: userID, Name: representativeDeviceName, DeviceType: representativeDeviceType, AgentID: agentID, IsRepresentative: true, RemoteStatus: device.RemoteStatusOffline, Status: device.USBStatusUnknown, PairedAt: &now, CreatedAt: now, UpdatedAt: now, } if saveErr := e.devices.Save(ctx, rep); saveErr != nil { return nil, fmt.Errorf("save representative: %w", saveErr) } return rep, nil default: return nil, fmt.Errorf("get representative: %w", gErr) } } // upsertUSBDevice 建/復用一顆真 USB device(in-memory,對齊 pg 版)。 func (e *memPairingExchanger) upsertUSBDevice( ctx context.Context, userID, agentID string, in ExchangeDeviceInput, now time.Time, ) (*device.Device, error) { existing, gErr := e.devices.GetBySerial(ctx, userID, in.SerialNumber) switch { case gErr == nil: existing.PairedAt = &now existing.UpdatedAt = now existing.AgentID = agentID if in.DeviceType != "" { existing.DeviceType = in.DeviceType existing.Name = deriveDeviceName(in.DeviceType, existing.SerialNumber) } if saveErr := e.devices.Save(ctx, existing); saveErr != nil { return nil, fmt.Errorf("save usb device: %w", saveErr) } return existing, nil case errors.Is(gErr, device.ErrNotFound): usbDev := &device.Device{ ID: uuid.NewString(), OwnerUserID: userID, Name: deriveDeviceName(in.DeviceType, in.SerialNumber), DeviceType: in.DeviceType, SerialNumber: in.SerialNumber, AgentID: agentID, IsRepresentative: false, RemoteStatus: device.RemoteStatusOffline, Status: device.USBStatusUnknown, PairedAt: &now, CreatedAt: now, UpdatedAt: now, } if saveErr := e.devices.Save(ctx, usbDev); saveErr != nil { return nil, fmt.Errorf("save usb device: %w", saveErr) } return usbDev, nil default: return nil, fmt.Errorf("get usb device by serial: %w", gErr) } }