// 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" "encoding/hex" "encoding/json" "errors" "fmt" "io" "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") } // 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。 type exchangeRequest struct { PairingToken string `json:"pairing_token"` } // exchangeResponse 對齊雛形雲端 handler 回傳欄位。 // 雛形 handler 只回 session_token + expires_at;account / relay_url 視為選填 // (Phase 1 才有;沒有時 Manager 用既有 Config fallback)。 type exchangeResponse struct { SessionToken string `json:"session_token"` ExpiresAt string `json:"expires_at,omitempty"` Account string `json:"account,omitempty"` RelayURL string `json:"relay_url,omitempty"` } // exchangeErrorResponse 對齊雛形雲端 401 body: { "code": "token_invalid" | ... }。 type exchangeErrorResponse struct { Code string `json:"code"` } // 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 } // 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} } body, err := json.Marshal(exchangeRequest{PairingToken: pairingToken}) 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) } if ok.SessionToken == "" { return ExchangeResult{}, errors.New("exchange response missing session_token") } return ExchangeResult{ SessionToken: ok.SessionToken, Account: ok.Account, RelayURL: ok.RelayURL, }, nil case http.StatusUnauthorized: var errResp exchangeErrorResponse _ = json.Unmarshal(respBody, &errResp) return ExchangeResult{}, mapExchangeErrorCode(errResp.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)) } } func mapExchangeErrorCode(code string) error { switch code { case "token_invalid": return ErrTokenInvalid case "token_expired": return ErrTokenExpired case "token_used": return ErrTokenUsed case "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] }