// pairing.go — 雛形配對流程實作(AB5 範圍)。 // // 對應 TDD §4.3「配對流程(雛形 + Phase 1)」與 Design spec §5。 // // 責任: // 1. 驗證 Pairing Token 格式(vAc_ + 32 hex) // 2. 呼叫雲端 visionA-backend 的 POST /api/pairing/exchange // 3. 若 mock mode = true(AB11 尚未上線),本地產生假 Session Token 供 dev 測試 // 4. 回傳 session token + account + relay URL 給 Manager 寫入 TokenStore // // ⚠️ 這個檔不動 visionA-backend 程式碼(那是 AB11 的事)。當 AB11 做完 // /api/pairing/exchange 上線,把 config.MockMode 設回 false 就會走真實呼叫。 package tunnel import ( "bytes" "crypto/rand" "crypto/tls" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "net/http" "regexp" "strings" "time" ) // PairingMockEnvVar 是控制 mock pairing 模式的環境變數名。 // 預設行為:unset / 任何 ≠ "true" 的值 → 走真實 exchange(production-safe default)。 // 必須明確設成 "true"(不分大小寫)才啟用 mock 模式。 // // 此預設策略由 Fix-A3 引入;歷史上預設為 mock=on,AB11 完成後改為明確 opt-in 避免 // 使用者忘設環境變數誤用 mock token。 const PairingMockEnvVar = "VISIONA_PAIRING_MOCK" // IsPairingMockOptIn 根據環境變數判斷是否啟用 mock pairing 模式。 // // 規則(必須明確 opt-in): // - "true"(不分大小寫)→ true // - 其他任何值(unset / "false" / "1" / 拼錯字 / 空字串)→ false // // 抽出來的目的:app.go 與測試共用同一份判斷邏輯,避免規則飄移。 func IsPairingMockOptIn(envValue string) bool { return strings.EqualFold(envValue, "true") } // InsecureSkipTLSVerifyEnvVar 是控制「跳過 TLS 憑證驗證」的環境變數名。 // // ⚠️ DEV / TEST ONLY — 正式環境絕不可開。 // // 用途:連線到使用自簽憑證的 stage / 測試環境(例如 stage 用 self-signed cert, // curl 需要 -k 才連得上)。Go 預設 client 遇自簽憑證會直接 x509 驗證失敗, // 導致 pairing exchange 與 tunnel WSS 連線都無法建立。 // // 預設行為(production-safe):unset / 任何 ≠ "true" 的值 → 維持安全的 TLS 驗證。 // 必須明確設成 "true"(不分大小寫)才會跳過驗證。 const InsecureSkipTLSVerifyEnvVar = "VISIONA_INSECURE_SKIP_TLS_VERIFY" // IsInsecureSkipTLSVerify 根據環境變數判斷是否要跳過 TLS 憑證驗證。 // // 規則比照 IsPairingMockOptIn(必須明確 opt-in): // - "true"(不分大小寫)→ true // - 其他任何值(unset / "false" / "1" / 拼錯字 / 前導空白 / 空字串)→ false // // ⚠️ 回傳 true 代表進入不安全模式(中間人攻擊可竊聽 / 竄改流量),僅供 dev/test。 // 抽出來的目的:app.go、exchanger、tunnel client 共用同一份判斷邏輯,避免規則飄移。 func IsInsecureSkipTLSVerify(envValue string) bool { return strings.EqualFold(envValue, "true") } // ErrInvalidTokenFormat 表示 Pairing Token 不符合 vAc_ + 32 hex 格式。 var ErrInvalidTokenFormat = errors.New("invalid pairing token format (expected vAc_ + 32 hex)") // ErrTokenInvalid / ErrTokenExpired / ErrTokenUsed / ErrTokenRevoked 對應雲端 // /api/pairing/exchange 401 回應的四種 code(Design spec §5.4)。 // Manager 呼叫 Pair() 失敗時會把這些錯誤 emit 成 pairing:result event,前端依 // code 顯示本地化訊息。 var ( ErrTokenInvalid = errors.New("pairing token invalid") ErrTokenExpired = errors.New("pairing token expired") ErrTokenUsed = errors.New("pairing token already used") ErrTokenRevoked = errors.New("pairing token revoked") ) // ErrExchangeNetwork 為 network 層錯誤(DNS / TCP / TLS)。 var ErrExchangeNetwork = errors.New("exchange network error") // pairingTokenRegex 對應 TDD §4.3:vAc_ 開頭 + 正好 32 個小寫 hex。 // 大寫 hex 不接受,與 visionA-backend 雛形生成格式一致。 var pairingTokenRegex = regexp.MustCompile(`^vAc_[0-9a-f]{32}$`) // ValidatePairingToken 回傳 nil 代表格式正確。 func ValidatePairingToken(token string) error { if !pairingTokenRegex.MatchString(token) { return ErrInvalidTokenFormat } return nil } // ExchangeResult 是 exchange 成功後回傳給 Manager 的資料。 type ExchangeResult struct { SessionToken string // Account 是雲端帳號 email。雛形 mock 下為 "demo@visionA.local"。 Account string // RelayURL 雲端告訴 agent 接下來要連哪個 relay(ws(s)://host/tunnel/connect)。 // 若回應沒帶此欄位,Manager 會 fallback 用 Config 中原本的 RelayURL。 RelayURL string } // exchangeRequest 對應 TDD §4.3 定義的 request body。 // // Devices 為 WP-0(ADR-018 序號地基)新增的 optional 欄位:exchange 前向本地 // server 撈 `GET /api/devices` 取得實體 USB 清單(含 Kneron kn_number 序號), // 讓雲端把序號填進 devices.serial_number(serial 路由的資料來源)。 // `omitempty` 保證舊行為相容:撈不到清單(local server 未起 / timeout / 0 裝置) // 時不送此欄位,雲端走現行「自建 serial=NULL device」路徑。 type exchangeRequest struct { PairingToken string `json:"pairing_token"` Devices []exchangeDevice `json:"devices,omitempty"` } // exchangeDevice 是 exchange payload 中的單顆實體 USB 裝置。 // 用陣列因應「一 agent 多 USB」(WP-0 只保證序號送達,多顆的完整模型是 WP-B)。 type exchangeDevice struct { SerialNumber string `json:"serial_number"` DeviceType string `json:"device_type,omitempty"` Firmware string `json:"firmware,omitempty"` } // LocalDevice 是 DeviceLister 回傳的本地 USB 裝置摘要(exchange payload 的來源)。 type LocalDevice struct { SerialNumber string DeviceType string Firmware string } // DeviceLister 回傳本地 server 目前偵測到的 USB 裝置清單。 // // 由 app.go 組裝時注入(NewLocalDeviceLister(port)),Exchanger 不自己猜 port。 // 回傳 error 或 nil 清單都不會讓 exchange 失敗——序號是加值資訊,配對本身 // 不能因為撈不到 USB 而中斷(fallback 到不帶 devices 的現行行為)。 type DeviceLister func() ([]LocalDevice, error) // exchangeResponse 對齊雲端 /api/pairing/exchange 的成功 envelope(api/errors.go WriteSuccess): // // { // "success": true, // "data": { // "session_token": "vAs_...", // "account": "...@visionA.local", // "relay_url": "wss://...", // "expires_at": "2026-07-21T00:00:00Z" // } // } // // ⚠️ 歷史 contract drift:早期 agent 假設 session_token 在頂層(雛形註解寫「對齊雛形 // 雲端 handler」),但雲端後來統一改用 SuccessBody envelope(success + data), // payload 全在 data. 底下。直接解頂層會讓 session_token 永遠為空、報 // "exchange response missing session_token"。此 struct 已對齊現行 envelope。 type exchangeResponse struct { Success bool `json:"success"` Data exchangeResponseData `json:"data"` } // exchangeResponseData 是成功 envelope 的 data payload。 // 對齊雲端 api.PairingExchangeResponse 的 json 欄位。 type exchangeResponseData struct { SessionToken string `json:"session_token"` ExpiresAt string `json:"expires_at,omitempty"` Account string `json:"account,omitempty"` RelayURL string `json:"relay_url,omitempty"` } // exchangeErrorResponse 對齊雲端的錯誤 envelope(api/errors.go WriteError): // // { // "success": false, // "error": { "code": "INVALID_PAIRING_TOKEN", "message": "...", "request_id": "..." } // } // // ⚠️ 同樣的 contract drift:早期 agent 假設 401 body 為頂層 { "code": "token_invalid" }, // 但雲端用 ErrorBody envelope,code 在 error. 底下、且為大寫常數(見 // api.ErrCodeInvalidPairingToken 等)。此 struct + mapExchangeErrorCode 已對齊現行格式。 type exchangeErrorResponse struct { Success bool `json:"success"` Error exchangeErrorDetail `json:"error"` } // exchangeErrorDetail 是錯誤 envelope 的 error payload。 type exchangeErrorDetail struct { Code string `json:"code"` Message string `json:"message"` } // PairingExchanger 介面讓 Manager 在測試時能注入 fake(避免真的打 HTTP)。 type PairingExchanger interface { Exchange(pairingToken string) (ExchangeResult, error) } // HTTPPairingExchanger 是生產用的實作,打真實的 HTTP 端點。 // MockMode = true 時不打 HTTP,改為本地產 fake session token,方便 AB11 未完成前 // 先做 end-to-end 驗證。 type HTTPPairingExchanger struct { // CloudAPIURL 是 visionA-backend 的 base URL(不含 path)。 // 例:https://api.visionA.cloud CloudAPIURL string // Client 可注入自訂 http.Client(timeout 測試);nil 用預設 10 秒 timeout。 Client *http.Client // MockMode = true 時跳過真實 HTTP、直接產假 session token。 // 僅用於 AB11 尚未落地的 dev 流程;正式上線必須 false。 MockMode bool // MockAccount 可覆寫 mock 模式下回傳的 account email;空值用預設。 MockAccount string // MockRelayURL 可覆寫 mock 模式下回傳的 relay URL;空值時不帶,讓 Manager // fallback 用 Config.RelayURL。 MockRelayURL string // InsecureSkipTLSVerify = true 時,exchange 用的 http.Client 會跳過 TLS 憑證 // 驗證(tls.Config{InsecureSkipVerify: true})。 // // ⚠️ DEV / TEST ONLY — 正式環境絕不可開。僅用於連自簽憑證的 stage / 測試環境。 // 由 VISIONA_INSECURE_SKIP_TLS_VERIFY 控制,見 IsInsecureSkipTLSVerify。 // 僅在 Client == nil(走預設 client 建構)時生效;若呼叫者注入自訂 Client, // 以該 Client 自帶的 Transport 為準。 InsecureSkipTLSVerify bool // DeviceLister 供 exchange 前撈本地 USB 清單(含序號)塞進 payload。 // nil 或回傳失敗 → payload 不帶 devices(exchange 照常進行,見 DeviceLister 註解)。 DeviceLister DeviceLister // Logf 為 optional 的 log 函式(app.go 注入 appLog)。nil 時 fallback 到 // 標準 log.Printf。撈裝置清單失敗屬「可恢復、不中斷」情況,必須留下紀錄。 Logf func(format string, args ...interface{}) } // NewHTTPPairingExchanger 建立一個生產預設實例。 func NewHTTPPairingExchanger(cloudAPIURL string) *HTTPPairingExchanger { return &HTTPPairingExchanger{ CloudAPIURL: cloudAPIURL, Client: &http.Client{Timeout: 10 * time.Second}, } } // Exchange 執行 pairing exchange。Manager 會在 Pair() 流程呼叫。 func (e *HTTPPairingExchanger) Exchange(pairingToken string) (ExchangeResult, error) { if err := ValidatePairingToken(pairingToken); err != nil { return ExchangeResult{}, err } if e.MockMode { return e.exchangeMock(pairingToken) } return e.exchangeReal(pairingToken) } // exchangeMock 不打 HTTP,僅用 crypto/rand 產一個合法格式的 Session Token。 // 格式:vAs_ + 64 hex,對齊 TDD §4.3 雛形規格。 func (e *HTTPPairingExchanger) exchangeMock(_ string) (ExchangeResult, error) { token, err := generateMockSessionToken() if err != nil { return ExchangeResult{}, err } account := e.MockAccount if account == "" { account = "demo@visionA.local" } return ExchangeResult{ SessionToken: token, Account: account, RelayURL: e.MockRelayURL, // 空字串代表讓 Manager 用既有 RelayURL }, nil } // exchangeReal 真實呼叫 visionA-backend /api/pairing/exchange。 func (e *HTTPPairingExchanger) exchangeReal(pairingToken string) (ExchangeResult, error) { if e.CloudAPIURL == "" { return ExchangeResult{}, errors.New("cloud API URL not configured") } client := e.Client if client == nil { client = &http.Client{Timeout: 10 * time.Second} } if e.InsecureSkipTLSVerify && client.Transport == nil { // ⚠️ DEV / TEST ONLY:跳過 TLS 憑證驗證,供連自簽憑證的 stage。 // 正式環境絕不可開。WARNING log 由 app.go 啟動時統一印一次。 // // 注意:只在 client.Transport == nil(含 NewHTTPPairingExchanger 的預設 client) // 時覆寫,避免踩掉呼叫者注入的自訂 Transport;且不直接改 e.Client(用區域複本), // 不影響傳入的共享 client。 // // Clone DefaultTransport 而非新建 zero-value &http.Transport{}:只覆寫 // TLSClientConfig(跳過憑證驗證),保留預設的 Proxy: http.ProxyFromEnvironment // 與 dial / TLS handshake timeout。避免「開 skip 順便改掉 proxy 行為」的隱性 // 副作用(例如經 corp proxy 的 dev 環境會被迫改走直連)。 tr := http.DefaultTransport.(*http.Transport).Clone() tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // dev-only, gated by VISIONA_INSECURE_SKIP_TLS_VERIFY c := *client c.Transport = tr client = &c } body, err := json.Marshal(exchangeRequest{ PairingToken: pairingToken, Devices: e.collectLocalDevices(), }) if err != nil { return ExchangeResult{}, err } endpoint := strings.TrimRight(e.CloudAPIURL, "/") + "/api/pairing/exchange" req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return ExchangeResult{}, err } req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { return ExchangeResult{}, fmt.Errorf("%w: %v", ErrExchangeNetwork, err) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) switch resp.StatusCode { case http.StatusOK: var ok exchangeResponse if err := json.Unmarshal(respBody, &ok); err != nil { return ExchangeResult{}, fmt.Errorf("decode exchange response: %w", err) } // HTTP 200 + success:false 是 envelope 契約允許表達、但正常不該發生的異常組合。 // 先明確攔截,避免落到下方「missing session_token」這個誤導性訊息。 if !ok.Success { return ExchangeResult{}, fmt.Errorf("exchange returned success=false: %s", truncate(string(respBody), 256)) } // session_token 在 envelope 的 data. 底下(見 exchangeResponse 註解)。 if ok.Data.SessionToken == "" { return ExchangeResult{}, errors.New("exchange response missing session_token") } return ExchangeResult{ SessionToken: ok.Data.SessionToken, Account: ok.Data.Account, RelayURL: ok.Data.RelayURL, }, nil case http.StatusUnauthorized: var errResp exchangeErrorResponse _ = json.Unmarshal(respBody, &errResp) // error code 在 envelope 的 error. 底下(見 exchangeErrorResponse 註解)。 return ExchangeResult{}, mapExchangeErrorCode(errResp.Error.Code) case http.StatusNotFound: // AB11 尚未完成時的清楚訊號;訊息指引使用者往 mock_mode 或等 AB11。 return ExchangeResult{}, fmt.Errorf("exchange endpoint not found (AB11 pending; set mock_mode or wait for backend deploy)") default: return ExchangeResult{}, fmt.Errorf("exchange failed: http %d: %s", resp.StatusCode, truncate(string(respBody), 256)) } } // collectLocalDevices 在 exchange 前撈本地 USB 清單、轉成 payload 形狀。 // // 不弄壞守則(device-serial-task1-mapping.md §2 段 4a):撈清單失敗(local // server 沒起 / timeout / 0 裝置)**不可讓 exchange 失敗**——回 nil 讓 payload // 省略 devices 欄位(`omitempty`),雲端走現行「serial=NULL」路徑。 func (e *HTTPPairingExchanger) collectLocalDevices() []exchangeDevice { if e.DeviceLister == nil { return nil } devs, err := e.DeviceLister() if err != nil { e.logf("pairing: list local devices failed (exchange continues without serials): %v", err) return nil } if len(devs) == 0 { return nil } out := make([]exchangeDevice, 0, len(devs)) for _, d := range devs { // serial 是 devices payload 的唯一用途(雲端據此填 serial_number 做 serial // 路由)。空 serial 的裝置對雲端無意義、雲端本來就會濾掉——agent 端先濾, // 減少 payload 面積、也讓語意對稱(WP-0 review S-3)。 serial := strings.TrimSpace(d.SerialNumber) if serial == "" { continue } out = append(out, exchangeDevice{ SerialNumber: serial, DeviceType: d.DeviceType, Firmware: d.Firmware, }) } // 全部 serial 皆空 → 回 nil,讓 payload 省略 devices 欄位(omitempty), // 與「無 DeviceLister」的舊行為一致。 if len(out) == 0 { return nil } return out } // logf 走注入的 Logf(app.go 的 appLog);未注入時 fallback 標準 log。 func (e *HTTPPairingExchanger) logf(format string, args ...interface{}) { if e.Logf != nil { e.Logf(format, args...) return } log.Printf(format, args...) } // localDeviceListTimeout 是撈本地 /api/devices 的 timeout。 // 比照 server_control.go probe 的 2 秒——序號是加值資訊,不能拖慢配對。 const localDeviceListTimeout = 2 * time.Second // localDevicesEnvelope 對齊 local server `GET /api/devices` 的回應 envelope: // // { "success": true, "data": { "devices": [ { "id", "serialNumber", "type", "firmwareVersion", ... } ] } } // // 只解需要的欄位(serialNumber / type / firmwareVersion)。 type localDevicesEnvelope struct { Success bool `json:"success"` Data struct { Devices []struct { SerialNumber string `json:"serialNumber"` Type string `json:"type"` FirmwareVersion string `json:"firmwareVersion"` } `json:"devices"` } `json:"data"` } // NewLocalDeviceLister 建立一個向本地 server(127.0.0.1:port)撈 // `GET /api/devices` 的 DeviceLister。port 由 app.go 從 ServerController 取得 // 後注入(Exchanger 不自己猜 port)。 func NewLocalDeviceLister(port int) DeviceLister { client := &http.Client{Timeout: localDeviceListTimeout} url := fmt.Sprintf("http://127.0.0.1:%d/api/devices", port) return func() ([]LocalDevice, error) { resp, err := client.Get(url) if err != nil { return nil, fmt.Errorf("local device list: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("local device list: http %d", resp.StatusCode) } var env localDevicesEnvelope if err := json.NewDecoder(resp.Body).Decode(&env); err != nil { return nil, fmt.Errorf("local device list: decode: %w", err) } out := make([]LocalDevice, 0, len(env.Data.Devices)) for _, d := range env.Data.Devices { out = append(out, LocalDevice{ SerialNumber: d.SerialNumber, DeviceType: d.Type, Firmware: d.FirmwareVersion, }) } return out, nil } } // mapExchangeErrorCode 把雲端錯誤 envelope 的 error.code 映射成 agent 內部 sentinel error。 // // code 為雲端的大寫常數(見 visionA-backend api.ErrCodeInvalidPairingToken 等): // - INVALID_PAIRING_TOKEN → ErrTokenInvalid // - PAIRING_TOKEN_EXPIRED → ErrTokenExpired // - PAIRING_TOKEN_USED → ErrTokenUsed // - PAIRING_TOKEN_REVOKED → ErrTokenRevoked // // ⚠️ contract drift 修正:早期 agent 比對的是 token_invalid 等小寫值(與雲端不符), // 導致所有 401 都落到 default 分支、前端拿不到正確的 error code 顯示對應 UI 文案。 func mapExchangeErrorCode(code string) error { switch code { case "INVALID_PAIRING_TOKEN": return ErrTokenInvalid case "PAIRING_TOKEN_EXPIRED": return ErrTokenExpired case "PAIRING_TOKEN_USED": return ErrTokenUsed case "PAIRING_TOKEN_REVOKED": return ErrTokenRevoked default: return fmt.Errorf("%w (code=%q)", ErrTokenInvalid, code) } } // generateMockSessionToken 產生 vAs_ + 64 hex 的假 token(mock mode 用)。 func generateMockSessionToken() (string, error) { buf := make([]byte, 32) // 32 bytes → 64 hex chars if _, err := rand.Read(buf); err != nil { return "", err } return "vAs_" + hex.EncodeToString(buf), nil } // MaskSessionToken 產生 Session Token 的遮蔽顯示字串,供 UI / log 使用。 // 格式:前綴(vAs_) + 前 8 hex + " ··· " + 後 4 hex,例「vAs_a1b2c3d4 ··· e7f8」。 // 對齊 Design spec §4.2 (B) 的 Session Token 遮蔽規則。 func MaskSessionToken(token string) string { if !strings.HasPrefix(token, "vAs_") { // 非預期格式;回空字串避免洩漏 return "" } rest := strings.TrimPrefix(token, "vAs_") if len(rest) < 12 { return "" } return "vAs_" + rest[:8] + " ··· " + rest[len(rest)-4:] } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] }