jim800121chen 26b433eb10 fix(local-agent): review follow-up 小批(TLS Mi-3/4 + WP-0 S-3/4)
- Mi-3: insecure Transport 改用 DefaultTransport.Clone() 只覆寫 TLSConfig
  (保留 proxy/timeout,消除「開 skip 順便改掉 proxy 行為」副作用)
- Mi-4: exchange 200 分支檢查 Success 欄位(避免 200+success:false 落到
  誤導性的 missing session_token;既有回歸測試改用直接斷言防護不減反增)
- S-3: collectLocalDevices 全空 serial 濾掉不送 devices 陣列(payload 對稱)
- S-4: 假序號比對統一用 EqualFold(防未來 bridge 輸出 casing 變化)

Reviewer 通過(0C/0M/1Mi/3Sug)。兩 module build/vet/test + -race 綠、
gitleaks 0、TLS 行為級測試全 PASS。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:40:35 +08:00

519 lines
20 KiB
Go
Raw 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.

// 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 = trueAB11 尚未上線),本地產生假 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" 的值 → 走真實 exchangeproduction-safe default
// 必須明確設成 "true"(不分大小寫)才啟用 mock 模式。
//
// 此預設策略由 Fix-A3 引入;歷史上預設為 mock=onAB11 完成後改為明確 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-safeunset / 任何 ≠ "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 回應的四種 codeDesign 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.3vAc_ 開頭 + 正好 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 接下來要連哪個 relayws(s)://host/tunnel/connect
// 若回應沒帶此欄位Manager 會 fallback 用 Config 中原本的 RelayURL。
RelayURL string
}
// exchangeRequest 對應 TDD §4.3 定義的 request body。
//
// Devices 為 WP-0ADR-018 序號地基)新增的 optional 欄位exchange 前向本地
// server 撈 `GET /api/devices` 取得實體 USB 清單(含 Kneron kn_number 序號),
// 讓雲端把序號填進 devices.serial_numberserial 路由的資料來源)。
// `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 的成功 envelopeapi/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 envelopesuccess + 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 對齊雲端的錯誤 envelopeapi/errors.go WriteError
//
// {
// "success": false,
// "error": { "code": "INVALID_PAIRING_TOKEN", "message": "...", "request_id": "..." }
// }
//
// ⚠️ 同樣的 contract drift早期 agent 假設 401 body 為頂層 { "code": "token_invalid" }
// 但雲端用 ErrorBody envelopecode 在 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.Clienttimeout 測試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 不帶 devicesexchange 照常進行,見 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 走注入的 Logfapp.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 建立一個向本地 server127.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 的假 tokenmock 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]
}