remove client option

rabbitprincess committed Mar 7, 2026 at 21:51 UTC c7a84388041f52fb0d058351712d935374682460
4 files changed +180 -39
cmd/demo-app/main.go
+1 -1
@@ -53,7 +53,7 @@ func runDemo() error {
53 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
54 defer stop()
55
56 - sdkClient, err := sdk.NewClient(sdk.ClientConfig{RelayURL: flagServerURL})
56 + sdkClient, err := sdk.NewClient(flagServerURL)
57 if err != nil {
58 return fmt.Errorf("new client: %w", err)
59 }
cmd/portal-tunnel/relays.go
+1 -1
@@ -75,7 +75,7 @@ func (r *relayRuntime) run(ctx context.Context, localAddr string, connWG *sync.W
75 func startRelayRuntimes(ctx context.Context, relayURLs []string, req sdk.ListenRequest) ([]*relayRuntime, error) {
76 runtimes := make([]*relayRuntime, 0, len(relayURLs))
77 for _, relayURL := range relayURLs {
78 - client, err := sdk.NewClient(sdk.ClientConfig{RelayURL: relayURL})
78 + client, err := sdk.NewClient(relayURL)
79 if err != nil {
80 _ = closeRelayRuntimes(runtimes)
81 return nil, fmt.Errorf("create relay client %s: %w", relayURL, err)
sdk/client.go
+105 -36
@@ -32,39 +32,114 @@ const (
32 defaultHTTPShutdownTimeout = 5 * time.Second
33 )
34
35 -// ClientConfig configures the SDK client.
36 -type ClientConfig struct {
37 - RelayURL string
38 - RootCAPEM []byte
35 +type ClientOption func(*Client)
36 +
37 +func WithRootCAPEM(rootCAPEM []byte) ClientOption {
38 + rootCAPEM = append([]byte(nil), rootCAPEM...)
39 + return func(client *Client) {
40 + client.rootCAPEM = append([]byte(nil), rootCAPEM...)
41 + }
42 +}
43 +
44 +func WithInsecureSkipVerify(skip bool) ClientOption {
45 + return func(client *Client) {
46 + client.insecureSkipVerify = skip
47 + }
48 +}
49 +
50 +func WithDialTimeout(timeout time.Duration) ClientOption {
51 + return func(client *Client) {
52 + if timeout > 0 {
53 + client.dialTimeout = timeout
54 + }
55 + }
56 +}
57 +
58 +func WithRequestTimeout(timeout time.Duration) ClientOption {
59 + return func(client *Client) {
60 + if timeout > 0 {
61 + client.requestTimeout = timeout
62 + }
63 + }
64 +}
65 +
66 +func WithHandshakeTimeout(timeout time.Duration) ClientOption {
67 + return func(client *Client) {
68 + if timeout > 0 {
69 + client.handshakeTimeout = timeout
70 + }
71 + }
72 +}
73 +
74 +func WithLeaseTTL(ttl time.Duration) ClientOption {
75 + return func(client *Client) {
76 + if ttl > 0 {
77 + client.leaseTTL = ttl
78 + }
79 + }
80 +}
81 +
82 +func WithRenewBefore(d time.Duration) ClientOption {
83 + return func(client *Client) {
84 + if d > 0 {
85 + client.renewBefore = d
86 + }
87 + }
88 +}
89 +
90 +func WithReadyTarget(n int) ClientOption {
91 + return func(client *Client) {
92 + if n > 0 {
93 + client.readyTarget = n
94 + }
95 + }
96 }
97
98 type Client struct {
42 - baseURL *url.URL
43 - httpClient *http.Client
44 - rawTLSConfig *tls.Config
45 - dialTimeout time.Duration
46 - handshakeTimeout time.Duration
47 - leaseTTL time.Duration
48 - renewBefore time.Duration
49 - readyTarget int
99 + baseURL *url.URL
100 + httpClient *http.Client
101 + rawTLSConfig *tls.Config
102 + rootCAPEM []byte
103 + insecureSkipVerify bool
104 + dialTimeout time.Duration
105 + requestTimeout time.Duration
106 + handshakeTimeout time.Duration
107 + leaseTTL time.Duration
108 + renewBefore time.Duration
109 + readyTarget int
110 }
111
52 -func NewClient(cfg ClientConfig) (*Client, error) {
53 - baseURL, err := url.Parse(strings.TrimSpace(cfg.RelayURL))
112 +func NewClient(relayURL string, options ...ClientOption) (*Client, error) {
113 + baseURL, err := url.Parse(strings.TrimSpace(relayURL))
114 if err != nil {
115 return nil, fmt.Errorf("parse relay url: %w", err)
116 }
117 if !strings.EqualFold(baseURL.Scheme, "https") {
58 - return nil, fmt.Errorf("relay url must use https: %q", cfg.RelayURL)
118 + return nil, fmt.Errorf("relay url must use https: %q", relayURL)
119 }
120 if baseURL.Host == "" {
61 - return nil, fmt.Errorf("relay url host is empty: %q", cfg.RelayURL)
121 + return nil, fmt.Errorf("relay url host is empty: %q", relayURL)
122 }
123 baseURL.Path = strings.TrimRight(baseURL.Path, "/")
124 baseURL.RawQuery = ""
125 baseURL.Fragment = ""
126
67 - if len(cfg.RootCAPEM) == 0 && isLocalRelayHost(baseURL.Hostname()) {
127 + client := &Client{
128 + baseURL: baseURL,
129 + dialTimeout: defaultDialTimeout,
130 + requestTimeout: defaultRequestTimeout,
131 + handshakeTimeout: defaultHandshakeTimeout,
132 + leaseTTL: defaultLeaseTTL,
133 + renewBefore: defaultRenewBefore,
134 + readyTarget: defaultReadyTarget,
135 + }
136 + for _, option := range options {
137 + if option != nil {
138 + option(client)
139 + }
140 + }
141 +
142 + if len(client.rootCAPEM) == 0 && !client.insecureSkipVerify && isLocalRelayHost(baseURL.Hostname()) {
143 bootstrapCtx, cancel := context.WithTimeout(context.Background(), defaultDialTimeout+defaultHandshakeTimeout)
144 defer cancel()
145
@@ -72,19 +147,20 @@ func NewClient(cfg ClientConfig) (*Client, error) {
147 if bootstrapErr != nil {
148 return nil, fmt.Errorf("bootstrap localhost relay trust: %w", bootstrapErr)
149 }
75 - cfg.RootCAPEM = rootCAPEM
150 + client.rootCAPEM = rootCAPEM
151 }
152
78 - rootCAs, err := buildRootCAs(cfg.RootCAPEM)
153 + rootCAs, err := buildRootCAs(client.rootCAPEM)
154 if err != nil {
155 return nil, err
156 }
157
158 baseTLS := &tls.Config{
84 - MinVersion: tls.VersionTLS12,
85 - ServerName: baseURL.Hostname(),
86 - RootCAs: rootCAs,
87 - NextProtos: []string{"http/1.1"},
159 + MinVersion: tls.VersionTLS12,
160 + ServerName: baseURL.Hostname(),
161 + RootCAs: rootCAs,
162 + InsecureSkipVerify: client.insecureSkipVerify,
163 + NextProtos: []string{"http/1.1"},
164 }
165
166 transport := &http.Transport{
@@ -92,19 +168,12 @@ func NewClient(cfg ClientConfig) (*Client, error) {
168 ForceAttemptHTTP2: false,
169 }
170
95 - return &Client{
96 - baseURL: baseURL,
97 - httpClient: &http.Client{
98 - Transport: transport,
99 - Timeout: defaultRequestTimeout,
100 - },
101 - rawTLSConfig: baseTLS,
102 - dialTimeout: defaultDialTimeout,
103 - handshakeTimeout: defaultHandshakeTimeout,
104 - leaseTTL: defaultLeaseTTL,
105 - renewBefore: defaultRenewBefore,
106 - readyTarget: defaultReadyTarget,
107 - }, nil
171 + client.httpClient = &http.Client{
172 + Transport: transport,
173 + Timeout: client.requestTimeout,
174 + }
175 + client.rawTLSConfig = baseTLS
176 + return client, nil
177 }
178
179 func (c *Client) Close() {
sdk/client_test.go
+73 -1
@@ -4,6 +4,7 @@ import (
4 "context"
5 "crypto/tls"
6 "encoding/json"
7 + "encoding/pem"
8 "errors"
9 "net/http"
10 "net/http/httptest"
@@ -22,7 +23,7 @@ func TestNewClientAutoTrustsLocalhostRelayCertificate(t *testing.T) {
23 }))
24 defer server.Close()
25
25 - client, err := NewClient(ClientConfig{RelayURL: server.URL})
26 + client, err := NewClient(server.URL)
27 if err != nil {
28 t.Fatalf("NewClient() error = %v", err)
29 }
@@ -39,6 +40,77 @@ func TestNewClientAutoTrustsLocalhostRelayCertificate(t *testing.T) {
40 }
41 }
42
43 +func TestNewClientAppliesOptions(t *testing.T) {
44 + t.Parallel()
45 +
46 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
47 + w.WriteHeader(http.StatusOK)
48 + }))
49 + defer server.Close()
50 +
51 + rootCAPEM := pem.EncodeToMemory(&pem.Block{
52 + Type: "CERTIFICATE",
53 + Bytes: server.Certificate().Raw,
54 + })
55 + client, err := NewClient(
56 + "https://relay.example.com/base/",
57 + WithRootCAPEM(rootCAPEM),
58 + WithInsecureSkipVerify(true),
59 + WithDialTimeout(2*time.Second),
60 + WithRequestTimeout(3*time.Second),
61 + WithHandshakeTimeout(4*time.Second),
62 + WithLeaseTTL(5*time.Minute),
63 + WithRenewBefore(45*time.Second),
64 + WithReadyTarget(3),
65 + )
66 + if err != nil {
67 + t.Fatalf("NewClient() error = %v", err)
68 + }
69 + defer client.Close()
70 +
71 + if got := client.baseURL.String(); got != "https://relay.example.com/base" {
72 + t.Fatalf("baseURL.String() = %q, want %q", got, "https://relay.example.com/base")
73 + }
74 + if !client.insecureSkipVerify {
75 + t.Fatal("insecureSkipVerify = false, want true")
76 + }
77 + if client.dialTimeout != 2*time.Second {
78 + t.Fatalf("dialTimeout = %v, want %v", client.dialTimeout, 2*time.Second)
79 + }
80 + if client.requestTimeout != 3*time.Second {
81 + t.Fatalf("requestTimeout = %v, want %v", client.requestTimeout, 3*time.Second)
82 + }
83 + if client.handshakeTimeout != 4*time.Second {
84 + t.Fatalf("handshakeTimeout = %v, want %v", client.handshakeTimeout, 4*time.Second)
85 + }
86 + if client.leaseTTL != 5*time.Minute {
87 + t.Fatalf("leaseTTL = %v, want %v", client.leaseTTL, 5*time.Minute)
88 + }
89 + if client.renewBefore != 45*time.Second {
90 + t.Fatalf("renewBefore = %v, want %v", client.renewBefore, 45*time.Second)
91 + }
92 + if client.readyTarget != 3 {
93 + t.Fatalf("readyTarget = %d, want %d", client.readyTarget, 3)
94 + }
95 + if client.httpClient.Timeout != 3*time.Second {
96 + t.Fatalf("httpClient.Timeout = %v, want %v", client.httpClient.Timeout, 3*time.Second)
97 + }
98 + if !client.rawTLSConfig.InsecureSkipVerify {
99 + t.Fatal("rawTLSConfig.InsecureSkipVerify = false, want true")
100 + }
101 + if string(client.rootCAPEM) != string(rootCAPEM) {
102 + t.Fatalf("rootCAPEM = %q, want copied PEM input", string(client.rootCAPEM))
103 + }
104 +
105 + rootCAPEM[0] = 'X'
106 + if string(client.rootCAPEM) == string(rootCAPEM) {
107 + t.Fatalf("rootCAPEM changed with caller slice mutation: %q", string(client.rootCAPEM))
108 + }
109 + if len(client.rootCAPEM) == 0 || client.rootCAPEM[0] != '-' {
110 + t.Fatalf("rootCAPEM changed with caller slice mutation: %q", string(client.rootCAPEM))
111 + }
112 +}
113 +
114 func TestOpenReverseSessionPreservesAPIErrorCode(t *testing.T) {
115 t.Parallel()
116