jim800121chen 3d30fdc580 feat(local-agent): TLS skip opt-in for self-signed stage + exchange envelope 對齊
- 新增 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>
2026-07-10 09:01:10 +08:00

368 lines
14 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"
"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。
type exchangeRequest struct {
PairingToken string `json:"pairing_token"`
}
// 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
}
// 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 的假 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]
}