- 新增 VISIONA_INSECURE_SKIP_TLS_VERIFY(DEV/TEST ONLY、須明確 opt-in "true"): pairing exchange HTTP client、tunnel WSS dialer、設定頁 TestConnection 三路徑 共用 TLSConfigForDial(含 ALPN 釘 http/1.1);NewApp 唯一 env 讀取點注入欄位 - exchangeResponse 對齊雲端 /api/pairing/exchange success envelope (account/relay_url 選填 fallback 保留、舊頂層格式回歸防護) - .gitignore:.env.stage* + !.env.stage.example + *.pptx(堵 secrets 誤入) - start-agent.sh(新增):public 模式 export skip env + 預檢 curl https 帶 -k - 測試:自簽 TLS server 行為級(預設拒絕驗 x509 / 開啟通過)+ opt-in 規則 11+5 案例 + TestConnection 3 測試;go build/vet/test 3 packages 全綠 - review:2 輪通過(.autoflow/05-implementation/review/tls-skip-uncommitted-batch-review.md) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
368 lines
14 KiB
Go
368 lines
14 KiB
Go
// 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"
|
||
"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。
|
||
type exchangeRequest struct {
|
||
PairingToken string `json:"pairing_token"`
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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。
|
||
c := *client
|
||
c.Transport = &http.Transport{
|
||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // dev-only, gated by VISIONA_INSECURE_SKIP_TLS_VERIFY
|
||
}
|
||
client = &c
|
||
}
|
||
|
||
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)
|
||
}
|
||
// 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))
|
||
}
|
||
}
|
||
|
||
// 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]
|
||
}
|