- 新增 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>
453 lines
14 KiB
Go
453 lines
14 KiB
Go
// Package tunnel 實作 visionA Agent 的 tunnel client。
|
||
//
|
||
// tunnel client 主動撥 WSS 到雲端 relay,accept 反向 yamux stream
|
||
// 並轉發到本機 Gin server(由 Wails shell 啟動的 server 子行程)。
|
||
//
|
||
// 此 package 於 2026-04-22 從 POC (edge-ai-platform) 複製:
|
||
//
|
||
// Source: edge-ai-platform/server/internal/tunnel/client.go
|
||
// Baseline commit: c9d56a62e23bc45554391123152ca90a07a60bdc
|
||
//
|
||
// 與 POC 的差異:
|
||
//
|
||
// 1. Module path 改為 visiona-agent/internal/tunnel(不沿用 server module,
|
||
// 因 Wails app shell 與 server binary 是兩個獨立 Go module;詳見 wsconn/wsconn.go 檔頂註解)
|
||
// 2. KeepAliveInterval 預設 10s(對齊 tunnel.md §4.2 M-5 決議;POC 預設 30s)
|
||
// 3. 修復 backoff bug(POC 單位 mix 導致 attempt>=1 永遠回 30s;見 backoff() 註解)
|
||
// 4. localAddr 不再寫死,由 NewClient 參數注入(呼叫者從 server controller 取 random port)
|
||
// 5. 加入 logger 注入點(nil 時用預設 log.Default)
|
||
//
|
||
// 參考:
|
||
//
|
||
// .autoflow/04-architecture/tunnel.md
|
||
// .autoflow/04-architecture/adr/adr-002-tunnel-protocol.md
|
||
// .autoflow/04-architecture/adr/adr-008-tunnel-client-reuse.md (v2)
|
||
package tunnel
|
||
|
||
import (
|
||
"bufio"
|
||
"crypto/tls"
|
||
"io"
|
||
"log"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"sync"
|
||
"time"
|
||
|
||
"visiona-agent/internal/wsconn"
|
||
|
||
"github.com/gorilla/websocket"
|
||
"github.com/hashicorp/yamux"
|
||
)
|
||
|
||
// 心跳 / 退避相關常數。
|
||
//
|
||
// KeepAliveInterval 對齊 tunnel.md §4.2 M-5 決議:10 秒送一次 yamux ping,
|
||
// 連續 3 次未收到 pong(= 30 秒)視為 tunnel 掉線。POC 預設為 30 秒,
|
||
// 掉線判定過慢(達 90 秒),故此處改為 10 秒。
|
||
const (
|
||
KeepAliveInterval = 10 * time.Second
|
||
ConnectionWriteTimeout = 10 * time.Second
|
||
|
||
// backoffBase / backoffCap 用於指數退避。
|
||
// base × 2^(attempt-1),clamp 至 cap。例:
|
||
// attempt=1 → 1s,attempt=2 → 2s,attempt=3 → 4s,...,attempt>=6 → 30s
|
||
backoffBase = 1 * time.Second
|
||
backoffCap = 30 * time.Second
|
||
)
|
||
|
||
// Logger 是 tunnel client 的可注入日誌介面。
|
||
// 傳 nil 則使用標準 log.Default()。保持極簡;結構化 log 由呼叫者包裝。
|
||
type Logger interface {
|
||
Printf(format string, args ...interface{})
|
||
}
|
||
|
||
// Client 維持與 relay server 的長連線 tunnel。
|
||
//
|
||
// 生命週期:Start() → 背景 goroutine 持續重連並 accept stream。
|
||
// Stop() 會關閉當前 session 並等 run goroutine 結束。
|
||
type Client struct {
|
||
relayURL string // ws(s)://host:port/tunnel/connect
|
||
token string
|
||
localAddr string // 本機 server 位址,例:127.0.0.1:3721
|
||
|
||
// insecureSkipTLSVerify = true 時,撥 WSS 用的 websocket.Dialer 會跳過 TLS
|
||
// 憑證驗證。⚠️ DEV / TEST ONLY — 正式環境絕不可開。僅用於連自簽憑證的
|
||
// stage / 測試 relay。由 Manager 從 VISIONA_INSECURE_SKIP_TLS_VERIFY 傳入。
|
||
insecureSkipTLSVerify bool
|
||
|
||
logger Logger
|
||
|
||
// Hooks 讓外層 Manager 觀察 session lifecycle(AB5)。
|
||
// 全部 optional;nil 時不呼叫。所有 callback 在 client 的 run goroutine 內
|
||
// 執行,呼叫者必須自己做 non-blocking(或用 goroutine 發 event)。
|
||
hooks ClientHooks
|
||
|
||
stopCh chan struct{}
|
||
stoppedCh chan struct{}
|
||
}
|
||
|
||
// ClientHooks 讓 Manager 觀察 Client 的連線事件(AB5 新增)。
|
||
//
|
||
// 設計取捨:把狀態決策留在 Manager,不要污染 Client 的簡潔邏輯——Client 仍
|
||
// 只負責「撥 → 連 → 重試」,Manager 訂閱 hooks 後才投射到對外的 ConnectionState。
|
||
type ClientHooks struct {
|
||
// OnDialAttempt 在每次 connect() 開始前呼叫(attempt 從 1 起算)。
|
||
// Manager 用它把 state 推到 "connecting" / "reconnecting"。
|
||
OnDialAttempt func(attempt int)
|
||
|
||
// OnSessionUp 在 yamux.Client 成功建立後呼叫(tunnel 已可轉發)。
|
||
// Manager 用它把 state 推到 "online" 並重置 attempt 計數。
|
||
OnSessionUp func()
|
||
|
||
// OnSessionDown 在 session Accept loop 跳出、session 已結束時呼叫。
|
||
// 參數 err 可能為 nil(主動 Stop)或 non-nil(掉線)。
|
||
OnSessionDown func(err error)
|
||
|
||
// OnDialFailed 在 connect() 建立失敗(dial / yamux handshake 失敗)時呼叫。
|
||
// Manager 用它記錄 lastError / attempt 數。
|
||
OnDialFailed func(attempt int, err error)
|
||
|
||
// OnRetryScheduled 在失敗後排定下一次重試前呼叫,讓 Manager 知道 backoff 值。
|
||
// delay 是 backoff() 回傳的值。
|
||
OnRetryScheduled func(attempt int, delay time.Duration)
|
||
}
|
||
|
||
// NewClient 建立 tunnel client,連到 relayURL 並把進入的 stream 轉發到 localAddr。
|
||
// logger 為 nil 時走標準 log.Default()。
|
||
func NewClient(relayURL, token, localAddr string, logger Logger) *Client {
|
||
if logger == nil {
|
||
logger = log.Default()
|
||
}
|
||
return &Client{
|
||
relayURL: relayURL,
|
||
token: token,
|
||
localAddr: localAddr,
|
||
logger: logger,
|
||
stopCh: make(chan struct{}),
|
||
stoppedCh: make(chan struct{}),
|
||
}
|
||
}
|
||
|
||
// SetHooks 注入 session lifecycle callbacks。必須在 Start() 前呼叫才保證觀測第一次連線。
|
||
// AB5 Manager 用。
|
||
func (c *Client) SetHooks(h ClientHooks) {
|
||
c.hooks = h
|
||
}
|
||
|
||
// SetInsecureSkipTLSVerify 設定撥 WSS 時是否跳過 TLS 憑證驗證。
|
||
//
|
||
// ⚠️ DEV / TEST ONLY — 正式環境絕不可開。僅用於連自簽憑證的 stage / 測試 relay。
|
||
// 必須在 Start() 前呼叫;run goroutine 啟動後改值不保證生效。
|
||
// 由 Manager 從 VISIONA_INSECURE_SKIP_TLS_VERIFY 傳入。
|
||
func (c *Client) SetInsecureSkipTLSVerify(skip bool) {
|
||
c.insecureSkipTLSVerify = skip
|
||
}
|
||
|
||
// Start 在背景啟動 tunnel 連線迴圈,失敗時以指數退避重連。
|
||
// 只能呼叫一次;重複呼叫行為未定義。
|
||
func (c *Client) Start() {
|
||
go c.run()
|
||
}
|
||
|
||
// Stop 關閉 tunnel 連線並停止重連。會阻塞直到 run goroutine 結束。
|
||
// 可安全呼叫多次(第二次會 panic 於 close(stopCh),由呼叫者保證單次)。
|
||
func (c *Client) Stop() {
|
||
close(c.stopCh)
|
||
<-c.stoppedCh
|
||
}
|
||
|
||
func (c *Client) run() {
|
||
defer close(c.stoppedCh)
|
||
|
||
attempt := 0
|
||
for {
|
||
select {
|
||
case <-c.stopCh:
|
||
return
|
||
default:
|
||
}
|
||
|
||
attempt++
|
||
if c.hooks.OnDialAttempt != nil {
|
||
c.hooks.OnDialAttempt(attempt)
|
||
}
|
||
|
||
err := c.connect()
|
||
if err != nil {
|
||
if c.hooks.OnDialFailed != nil {
|
||
c.hooks.OnDialFailed(attempt, err)
|
||
}
|
||
delay := backoff(attempt)
|
||
c.logger.Printf("[tunnel] connection failed (attempt %d): %v — retrying in %v", attempt, err, delay)
|
||
if c.hooks.OnRetryScheduled != nil {
|
||
c.hooks.OnRetryScheduled(attempt, delay)
|
||
}
|
||
|
||
select {
|
||
case <-c.stopCh:
|
||
return
|
||
case <-time.After(delay):
|
||
}
|
||
continue
|
||
}
|
||
|
||
// connect() 正常 return 表示 session 曾經建立後又關閉(非 dial error);
|
||
// attempt 重置後由下一圈重連(不等 backoff,避免 UX 上「重新連線」卡住)。
|
||
attempt = 0
|
||
}
|
||
}
|
||
|
||
// relayConnectPath 是 relay 端 tunnel 連線的固定 endpoint path。
|
||
//
|
||
// 架構上 relay base URL 不含 path(exchange 回的 relay_url / agentconfig 預設值都是裸
|
||
// host),實際 WS endpoint 在 /tunnel/connect。normalizeRelayURL 負責兜底補上。
|
||
const relayConnectPath = "/tunnel/connect"
|
||
|
||
// normalizeRelayURL 確保 relay URL 帶上 /tunnel/connect path。
|
||
//
|
||
// 為什麼需要:relay URL 有三個來源(env VISIONA_RELAY_URL / exchange 回的 relay_url /
|
||
// agentconfig DefaultRelayURL),其中後兩者是裸 host(無 path)。若直接 Dial 裸 host,
|
||
// path 會是 "/",被 relay 前面的 nginx location / 導去 Next.js frontend → 非 WS endpoint
|
||
// → "websocket: bad handshake"。此函式在連線前統一補 path。
|
||
//
|
||
// 冪等規則(補不補的判斷):
|
||
// - path 為空 或 只有 "/" → 補上 /tunnel/connect
|
||
// - path 已是非根路徑(使用者 env 帶了完整 URL,含已是 /tunnel/connect)→ 尊重既有、不動
|
||
// (避免重複加成 /tunnel/connect/tunnel/connect)
|
||
//
|
||
// robust 處理:保留既有 query(?token= 在呼叫端另外附加);trailing slash 視為「只有 /」
|
||
// 補 path;URL 無法 parse 時原樣回傳(讓呼叫端的 url.Parse 自己報錯,不在此吞錯)。
|
||
func normalizeRelayURL(relayURL string) string {
|
||
u, err := url.Parse(relayURL)
|
||
if err != nil {
|
||
return relayURL
|
||
}
|
||
if u.Path == "" || u.Path == "/" {
|
||
u.Path = relayConnectPath
|
||
}
|
||
return u.String()
|
||
}
|
||
|
||
// TLSConfigForDial 建立撥 WSS 用的 *tls.Config。
|
||
//
|
||
// 核心:NextProtos 強制只談 "http/1.1"(ALPN)。
|
||
//
|
||
// 為什麼正式 + insecure 兩種情況都要:WebSocket 升級依賴 HTTP/1.1 的 hop-by-hop
|
||
// header(Connection: Upgrade / Upgrade: websocket)。若 ALPN 留空,公網反向代理
|
||
// (nginx 等)會自由選 HTTP/2;而 RFC 7540 禁止 h2 攜帶這些 hop-by-hop header,
|
||
// 反代會合法剝除 → backend 收不到 upgrade → "websocket: bad handshake"。
|
||
// 把 ALPN 釘死 http/1.1 即可確保升級 header 不被 h2 規則剝除。
|
||
//
|
||
// skipVerify = true 時額外跳過 TLS 憑證驗證(⚠️ DEV / TEST ONLY,連自簽憑證 stage 用;
|
||
// 正式環境絕不可開;WARNING log 由 app.go 啟動時統一印一次)。
|
||
//
|
||
// Export 原因:app 層的 TestConnection binding(設定頁「測試連線」按鈕)是第三條
|
||
// TLS 撥號路徑,必須與 tunnel client 主路徑共用同一份 TLS 設定(ALPN + skip 同源),
|
||
// 避免「測試連線失敗但 tunnel 其實連得上」的規則飄移。
|
||
func TLSConfigForDial(skipVerify bool) *tls.Config {
|
||
return &tls.Config{
|
||
NextProtos: []string{"http/1.1"},
|
||
InsecureSkipVerify: skipVerify, //nolint:gosec // dev-only, gated by VISIONA_INSECURE_SKIP_TLS_VERIFY
|
||
}
|
||
}
|
||
|
||
// connect 建立一次 tunnel session 並阻塞直到 session 關閉。
|
||
func (c *Client) connect() error {
|
||
// 兜底補 /tunnel/connect path(relay base URL 不含 path;見 normalizeRelayURL)。
|
||
u, err := url.Parse(normalizeRelayURL(c.relayURL))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
q := u.Query()
|
||
if c.token != "" {
|
||
q.Set("token", c.token)
|
||
}
|
||
u.RawQuery = q.Encode()
|
||
|
||
c.logger.Printf("[tunnel] connecting to %s", u.Host)
|
||
|
||
// 複製一份 DefaultDialer(不污染 package 全域),覆寫 TLSClientConfig:
|
||
// 不管 insecure 與否,都強制 ALPN 只談 http/1.1(見 TLSConfigForDial 註解)。
|
||
d := *websocket.DefaultDialer
|
||
d.TLSClientConfig = TLSConfigForDial(c.insecureSkipTLSVerify)
|
||
dialer := &d
|
||
|
||
conn, _, err := dialer.Dial(u.String(), nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
netConn := wsconn.New(conn)
|
||
|
||
// yamux 設定:改用 10s keepalive(對齊 tunnel.md §4.2 M-5)
|
||
cfg := yamux.DefaultConfig()
|
||
cfg.EnableKeepAlive = true
|
||
cfg.KeepAliveInterval = KeepAliveInterval
|
||
cfg.ConnectionWriteTimeout = ConnectionWriteTimeout
|
||
|
||
session, err := yamux.Client(netConn, cfg)
|
||
if err != nil {
|
||
conn.Close()
|
||
return err
|
||
}
|
||
|
||
c.logger.Printf("[tunnel] connected to relay at %s", u.Host)
|
||
if c.hooks.OnSessionUp != nil {
|
||
c.hooks.OnSessionUp()
|
||
}
|
||
|
||
var wg sync.WaitGroup
|
||
|
||
// stopCh 觸發時主動關 session,讓 Accept() 返回
|
||
go func() {
|
||
<-c.stopCh
|
||
session.Close()
|
||
}()
|
||
|
||
var lastErr error
|
||
for {
|
||
stream, err := session.Accept()
|
||
if err != nil {
|
||
if session.IsClosed() {
|
||
break
|
||
}
|
||
c.logger.Printf("[tunnel] accept error: %v", err)
|
||
lastErr = err
|
||
break
|
||
}
|
||
|
||
wg.Add(1)
|
||
go func(s net.Conn) {
|
||
defer wg.Done()
|
||
c.handleStream(s)
|
||
}(stream)
|
||
}
|
||
|
||
wg.Wait()
|
||
c.logger.Printf("[tunnel] disconnected from relay")
|
||
if c.hooks.OnSessionDown != nil {
|
||
c.hooks.OnSessionDown(lastErr)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// handleStream 從 yamux stream 讀一個 HTTP request,轉給本機 server,再把 response 寫回 stream。
|
||
func (c *Client) handleStream(stream net.Conn) {
|
||
defer stream.Close()
|
||
|
||
req, err := http.ReadRequest(bufio.NewReader(stream))
|
||
if err != nil {
|
||
c.logger.Printf("[tunnel] failed to read request: %v", err)
|
||
return
|
||
}
|
||
|
||
req.URL.Scheme = "http"
|
||
req.URL.Host = c.localAddr
|
||
req.RequestURI = "" // http.Client 要求清空
|
||
|
||
if isWebSocketUpgrade(req) {
|
||
c.handleWebSocket(stream, req)
|
||
return
|
||
}
|
||
|
||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||
if err != nil {
|
||
c.logger.Printf("[tunnel] local request failed: %v", err)
|
||
errResp := &http.Response{
|
||
StatusCode: http.StatusBadGateway,
|
||
ProtoMajor: 1,
|
||
ProtoMinor: 1,
|
||
Header: make(http.Header),
|
||
Body: http.NoBody,
|
||
}
|
||
_ = errResp.Write(stream)
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
_ = resp.Write(stream)
|
||
}
|
||
|
||
// handleWebSocket 用 raw TCP 把 WebSocket upgrade 請求轉發到本機 server,之後做雙向 pipe。
|
||
func (c *Client) handleWebSocket(stream net.Conn, req *http.Request) {
|
||
localConn, err := net.DialTimeout("tcp", c.localAddr, 10*time.Second)
|
||
if err != nil {
|
||
c.logger.Printf("[tunnel] ws: failed to connect to local: %v", err)
|
||
return
|
||
}
|
||
defer localConn.Close()
|
||
|
||
req.RequestURI = req.URL.RequestURI() // raw write 需要還原
|
||
_ = req.Write(localConn)
|
||
|
||
var wg sync.WaitGroup
|
||
wg.Add(2)
|
||
|
||
go func() {
|
||
defer wg.Done()
|
||
_, _ = io.Copy(localConn, stream)
|
||
localConn.Close()
|
||
}()
|
||
go func() {
|
||
defer wg.Done()
|
||
_, _ = io.Copy(stream, localConn)
|
||
stream.Close()
|
||
}()
|
||
|
||
wg.Wait()
|
||
}
|
||
|
||
func isWebSocketUpgrade(r *http.Request) bool {
|
||
for _, v := range r.Header["Upgrade"] {
|
||
if v == "websocket" || v == "Websocket" || v == "WebSocket" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// backoff 回傳指數退避時長,上限 backoffCap。
|
||
//
|
||
// 演算法:base × 2^(attempt-1),clamp 至 [base, cap]。
|
||
//
|
||
// POC bug 修復紀錄:
|
||
//
|
||
// POC 原實作:
|
||
// d := time.Duration(math.Min(float64(time.Second)*math.Pow(2, float64(attempt-1)), 30)) * time.Second
|
||
// 問題:math.Min 的第二參數「30」是 float64 純數字,但第一參數是「time.Second
|
||
// (= 1e9 ns) × 2^(attempt-1)」,單位是 ns。attempt=1 時 1e9 vs 30 取 min = 30
|
||
// (ns),再乘以 time.Second → 30s;attempt=2 時 2e9 vs 30 → 30 → 30s;任何
|
||
// attempt>=1 都永遠回 30s(而非預期的 1s、2s、4s...)。下方 clamp `if d < 1s`
|
||
// 把 1ns 拉回 1s,掩蓋了這個 bug 的第一次現身,但從 attempt=1 起就是 30s。
|
||
//
|
||
// 修復後:用純 time.Duration 做位移/比較,不再 mix float64 與 Duration。
|
||
// attempt=0(不應發生)回 base;attempt 非常大時 overflow 前先 cap。
|
||
//
|
||
// 預期輸出:
|
||
//
|
||
// attempt=1 → 1s
|
||
// attempt=2 → 2s
|
||
// attempt=3 → 4s
|
||
// attempt=4 → 8s
|
||
// attempt=5 → 16s
|
||
// attempt=6 → 30s(cap)
|
||
// attempt>=6 → 30s
|
||
func backoff(attempt int) time.Duration {
|
||
if attempt <= 0 {
|
||
return backoffBase
|
||
}
|
||
// shift 太大會 overflow,所以 attempt >= 某個值就直接回 cap
|
||
// backoffBase = 1s = 1e9 ns,2^30 × 1e9 ≈ 10^18 已接近 int64 上限,
|
||
// attempt >= 31 之後一律 cap 即可。
|
||
if attempt >= 31 {
|
||
return backoffCap
|
||
}
|
||
d := backoffBase << (attempt - 1)
|
||
if d > backoffCap || d < backoffBase {
|
||
return backoffCap
|
||
}
|
||
return d
|
||
}
|