- 新增 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>
277 lines
8.9 KiB
Go
277 lines
8.9 KiB
Go
package tunnel
|
||
|
||
import (
|
||
"net/url"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// TestBackoffValues 驗證退避函式輸出。
|
||
//
|
||
// 這組測試的核心動機是 POC 的 backoff bug:math.Min 單位 mix 造成 attempt>=1
|
||
// 永遠回 30s。修復後應該是純指數退避,clamp 在 [base, cap]。
|
||
func TestBackoffValues(t *testing.T) {
|
||
cases := []struct {
|
||
attempt int
|
||
want time.Duration
|
||
}{
|
||
{attempt: 0, want: backoffBase}, // 不應發生,但定義行為
|
||
{attempt: 1, want: 1 * time.Second}, // base × 2^0 = 1s
|
||
{attempt: 2, want: 2 * time.Second}, // base × 2^1 = 2s
|
||
{attempt: 3, want: 4 * time.Second},
|
||
{attempt: 4, want: 8 * time.Second},
|
||
{attempt: 5, want: 16 * time.Second},
|
||
{attempt: 6, want: backoffCap}, // 32s > 30s → cap
|
||
{attempt: 7, want: backoffCap},
|
||
{attempt: 10, want: backoffCap},
|
||
{attempt: 30, want: backoffCap},
|
||
{attempt: 100, want: backoffCap}, // 極大 attempt 應不 overflow
|
||
}
|
||
for _, tc := range cases {
|
||
got := backoff(tc.attempt)
|
||
if got != tc.want {
|
||
t.Errorf("backoff(%d) = %v, want %v", tc.attempt, got, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestBackoffMonotonicUntilCap 驗證退避在 cap 之前是遞增的。
|
||
// 這是 POC bug 會失敗的測試(POC 從 attempt=1 起就固定 30s,根本不遞增)。
|
||
func TestBackoffMonotonicUntilCap(t *testing.T) {
|
||
var prev time.Duration
|
||
for attempt := 1; attempt <= 5; attempt++ {
|
||
got := backoff(attempt)
|
||
if got <= prev {
|
||
t.Errorf("backoff not monotonically increasing: backoff(%d)=%v, backoff(%d)=%v", attempt-1, prev, attempt, got)
|
||
}
|
||
if got > backoffCap {
|
||
t.Errorf("backoff(%d)=%v exceeds cap %v", attempt, got, backoffCap)
|
||
}
|
||
prev = got
|
||
}
|
||
}
|
||
|
||
// TestKeepAliveIntervalDefault 固定住 10s 預設值,避免未來不小心改回 POC 的 30s。
|
||
// 10s 是 tunnel.md §4.2 M-5 統一的心跳週期。
|
||
func TestKeepAliveIntervalDefault(t *testing.T) {
|
||
if KeepAliveInterval != 10*time.Second {
|
||
t.Errorf("KeepAliveInterval = %v, want 10s (tunnel.md §4.2 M-5)", KeepAliveInterval)
|
||
}
|
||
}
|
||
|
||
// TestClientInsecureSkipTLSVerifyDefaultFalse 驗證 NewClient 預設不跳過 TLS 驗證
|
||
// (production-safe default)。
|
||
func TestClientInsecureSkipTLSVerifyDefaultFalse(t *testing.T) {
|
||
c := NewClient("wss://relay/tunnel/connect", "tok", "127.0.0.1:1234", nil)
|
||
if c.insecureSkipTLSVerify {
|
||
t.Error("NewClient should default insecureSkipTLSVerify=false")
|
||
}
|
||
}
|
||
|
||
// TestClientSetInsecureSkipTLSVerify 驗證 setter 正確翻轉欄位。
|
||
func TestClientSetInsecureSkipTLSVerify(t *testing.T) {
|
||
c := NewClient("wss://relay/tunnel/connect", "tok", "127.0.0.1:1234", nil)
|
||
c.SetInsecureSkipTLSVerify(true)
|
||
if !c.insecureSkipTLSVerify {
|
||
t.Error("SetInsecureSkipTLSVerify(true) should set field true")
|
||
}
|
||
c.SetInsecureSkipTLSVerify(false)
|
||
if c.insecureSkipTLSVerify {
|
||
t.Error("SetInsecureSkipTLSVerify(false) should set field false")
|
||
}
|
||
}
|
||
|
||
// TestManagerPropagatesInsecureSkipTLSVerify 驗證 Manager.Config.InsecureSkipTLSVerify
|
||
// 會經 newClientLocked 傳到 Client。
|
||
func TestManagerPropagatesInsecureSkipTLSVerify(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
skip bool
|
||
}{
|
||
{"enabled", true},
|
||
{"disabled", false},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
m := NewManager(Config{
|
||
RelayURL: "wss://relay/tunnel/connect",
|
||
SessionToken: "vAs_" + repeat64,
|
||
LocalAddr: "127.0.0.1:1234",
|
||
InsecureSkipTLSVerify: tc.skip,
|
||
})
|
||
m.mu.Lock()
|
||
c := m.newClientLocked()
|
||
m.mu.Unlock()
|
||
if c.insecureSkipTLSVerify != tc.skip {
|
||
t.Errorf("client.insecureSkipTLSVerify = %v, want %v", c.insecureSkipTLSVerify, tc.skip)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// repeat64 是 64 個 'a',組成合法長度的假 session token(vAs_ + 64 hex)。
|
||
const repeat64 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||
|
||
// TestTLSConfigForDialForcesHTTP11ALPN 驗證撥 WSS 的 TLS config 強制 ALPN http/1.1,
|
||
// 且 insecure 開 / 關 兩種分支都有設 NextProtos。
|
||
//
|
||
// 動機:ALPN 留空 → 公網反代選 HTTP/2 → h2 剝除 Connection:Upgrade → bad handshake。
|
||
// 釘死 http/1.1 才能讓 WebSocket 升級 header 不被剝。
|
||
func TestTLSConfigForDialForcesHTTP11ALPN(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
skipVerify bool
|
||
}{
|
||
{"正式分支(不 skip TLS 驗證)", false},
|
||
{"insecure 分支(skip TLS 驗證)", true},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
cfg := TLSConfigForDial(tc.skipVerify)
|
||
if cfg == nil {
|
||
t.Fatal("TLSConfigForDial returned nil")
|
||
}
|
||
// NextProtos 必須正好是 ["http/1.1"](強制 h1.1,不留空讓反代選 h2)
|
||
if len(cfg.NextProtos) != 1 || cfg.NextProtos[0] != "http/1.1" {
|
||
t.Errorf("NextProtos = %v, want [\"http/1.1\"]", cfg.NextProtos)
|
||
}
|
||
// InsecureSkipVerify 應反映入參(ALPN 修正不得改動 skip 邏輯本身)
|
||
if cfg.InsecureSkipVerify != tc.skipVerify {
|
||
t.Errorf("InsecureSkipVerify = %v, want %v", cfg.InsecureSkipVerify, tc.skipVerify)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestNormalizeRelayURL 驗證 relay URL 兜底補 /tunnel/connect path 的邏輯。
|
||
//
|
||
// 動機:exchange 回的 relay_url / agentconfig 預設值是裸 host(無 path),直接 Dial
|
||
// path=/ 會被 nginx 導去 frontend → bad handshake。normalizeRelayURL 補 path;
|
||
// 但對已含非根 path 的 URL 必須冪等(不重複加)。
|
||
func TestNormalizeRelayURL(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
in string
|
||
want string
|
||
}{
|
||
{
|
||
name: "base 無 path(stage 實際情境)",
|
||
in: "wss://stage-9527.innovedus.com:9527",
|
||
want: "wss://stage-9527.innovedus.com:9527/tunnel/connect",
|
||
},
|
||
{
|
||
name: "base path = /(trailing slash)",
|
||
in: "wss://stage-9527.innovedus.com:9527/",
|
||
want: "wss://stage-9527.innovedus.com:9527/tunnel/connect",
|
||
},
|
||
{
|
||
name: "已含 /tunnel/connect(冪等、不重複加)",
|
||
in: "wss://relay.visionA.cloud/tunnel/connect",
|
||
want: "wss://relay.visionA.cloud/tunnel/connect",
|
||
},
|
||
{
|
||
name: "已含其他非根 path(尊重既有)",
|
||
in: "wss://relay.example.com/custom/path",
|
||
want: "wss://relay.example.com/custom/path",
|
||
},
|
||
{
|
||
name: "ws(非 TLS)裸 host 也補",
|
||
in: "ws://127.0.0.1:8080",
|
||
want: "ws://127.0.0.1:8080/tunnel/connect",
|
||
},
|
||
{
|
||
name: "agentconfig 預設值(裸 host)",
|
||
in: "wss://relay.visionA.cloud",
|
||
want: "wss://relay.visionA.cloud/tunnel/connect",
|
||
},
|
||
{
|
||
name: "base 無 path 但帶既有 query(query 保留、補 path)",
|
||
in: "wss://host:9527?foo=bar",
|
||
want: "wss://host:9527/tunnel/connect?foo=bar",
|
||
},
|
||
{
|
||
name: "base path=/ 帶既有 query",
|
||
in: "wss://host:9527/?foo=bar",
|
||
want: "wss://host:9527/tunnel/connect?foo=bar",
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got := normalizeRelayURL(tc.in)
|
||
if got != tc.want {
|
||
t.Errorf("normalizeRelayURL(%q) = %q, want %q", tc.in, got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestNormalizeRelayURLThenTokenAppended 驗證 normalize 後,connect() 附加 ?token=
|
||
// 的邏輯仍能正確把 token 加在最終 URL(path 已補好、token query 正確)。
|
||
//
|
||
// 複製 connect() 的 URL 組裝步驟(normalize → parse → set token query)以驗證最終形狀,
|
||
// 不實際 Dial。
|
||
func TestNormalizeRelayURLThenTokenAppended(t *testing.T) {
|
||
const token = "vAs_" + repeat64
|
||
|
||
cases := []struct {
|
||
name string
|
||
relay string
|
||
wantPath string
|
||
wantToken string
|
||
wantHasPath bool
|
||
}{
|
||
{
|
||
name: "裸 host → 補 path 且 token 正確",
|
||
relay: "wss://stage-9527.innovedus.com:9527",
|
||
wantPath: "/tunnel/connect",
|
||
wantToken: token,
|
||
},
|
||
{
|
||
name: "已含 path → 不重複加且 token 正確",
|
||
relay: "wss://relay.visionA.cloud/tunnel/connect",
|
||
wantPath: "/tunnel/connect",
|
||
wantToken: token,
|
||
},
|
||
{
|
||
name: "裸 host 帶既有 query → path 補上、既有 query + token 並存",
|
||
relay: "wss://host:9527?foo=bar",
|
||
wantPath: "/tunnel/connect",
|
||
wantToken: token,
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
// 複製 connect() 的組裝邏輯
|
||
u, err := url.Parse(normalizeRelayURL(tc.relay))
|
||
if err != nil {
|
||
t.Fatalf("parse: %v", err)
|
||
}
|
||
q := u.Query()
|
||
if token != "" {
|
||
q.Set("token", token)
|
||
}
|
||
u.RawQuery = q.Encode()
|
||
|
||
if u.Path != tc.wantPath {
|
||
t.Errorf("path = %q, want %q", u.Path, tc.wantPath)
|
||
}
|
||
if got := u.Query().Get("token"); got != tc.wantToken {
|
||
t.Errorf("token query = %q, want %q", got, tc.wantToken)
|
||
}
|
||
// 最終 URL 字串應同時含 path 與 token=
|
||
final := u.String()
|
||
if !strings.Contains(final, tc.wantPath) {
|
||
t.Errorf("final URL %q missing path %q", final, tc.wantPath)
|
||
}
|
||
if !strings.Contains(final, "token=") {
|
||
t.Errorf("final URL %q missing token= query", final)
|
||
}
|
||
// 防回歸:絕不可出現重複 path
|
||
if strings.Contains(final, "/tunnel/connect/tunnel/connect") {
|
||
t.Errorf("final URL %q has duplicated /tunnel/connect path", final)
|
||
}
|
||
})
|
||
}
|
||
}
|