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

277 lines
8.9 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.

package tunnel
import (
"net/url"
"strings"
"testing"
"time"
)
// TestBackoffValues 驗證退避函式輸出。
//
// 這組測試的核心動機是 POC 的 backoff bugmath.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 tokenvAs_ + 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 無 pathstage 實際情境)",
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 但帶既有 queryquery 保留、補 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 加在最終 URLpath 已補好、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)
}
})
}
}