tunnel: support multi relay
rabbitprincess committed
Mar 2, 2026 at 19:35 UTC
3deb5409bf6cac73c2d295dc46d02d760ce13c4a
11 files changed
+517
-410
AGENTS.md
+6
@@ -90,6 +90,7 @@ Portal uses environment variables for domain and TLS configuration:
90
| `TLS_MODE` | `no-tls`, `self`, or `keyless` |
91
| `TLS_CERT_FILE` | Self TLS certificate chain path (self mode only) |
92
| `TLS_KEY_FILE` | Self TLS private key path (self mode only) |
93
+| `TLS_BASE_DOMAIN` | Base domain used for keyless certificate hostname validation (keyless mode) |
94
95
### Domain Derivation
96
@@ -169,3 +170,8 @@ CI/CD:
170
Verbalized sampling:
171
- For non-trivial changes: sample multiple intents, explore edge cases, assess coupling, tidy first, and surface tradeoffs.
172
173
+Refactoring discipline:
174
+- Do not stack repeated "minimal patches" that leave logic fragmented across files.
175
+- For domain/URL parsing and normalization, keep a single source of truth and make all callers use it.
176
+- If a flow is being refactored (e.g., SDK client/listener TLS domain handling), complete consolidation in the same change instead of leaving temporary split logic.
177
+
cmd/demo-app/main.go
+15
-19
@@ -27,29 +27,30 @@ var staticFiles embed.FS
27
var thumbnailPNG []byte
28
29
var (
30
- flagServerURL string
31
- flagPort int
32
- flagName string
33
- flagDesc string
34
- flagTags string
35
- flagOwner string
36
- flagHide bool
37
- flagTLSMode string
38
- flagTLSCert string
39
- flagTLSKey string
30
+ flagServerURL string
31
+ flagPort int
32
+ flagName string
33
+ flagDesc string
34
+ flagTags string
35
+ flagOwner string
36
+ flagHide bool
37
+ flagTLSMode string
38
+ flagTLSCert string
39
+ flagTLSKey string
40
+ flagTLSBaseDomain string
41
)
42
43
func main() {
43
- flag.StringVar(&flagServerURL, "server-url", "https://gosunuts.xyz", "relay API URL (http/https)")
44
+ flag.StringVar(&flagServerURL, "server-url", "http://localhost:4017", "relay API URL (http/https)")
45
flag.IntVar(&flagPort, "port", 8092, "local demo HTTP port")
46
flag.StringVar(&flagName, "name", "demo-app", "backend display name")
47
flag.StringVar(&flagDesc, "description", "Portal demo connectivity app", "lease description")
48
flag.StringVar(&flagTags, "tags", "demo,connectivity,activity,cloud,sun,moning", "comma-separated lease tags")
49
flag.StringVar(&flagOwner, "owner", "PortalApp Developer", "lease owner")
50
flag.BoolVar(&flagHide, "hide", false, "hide this lease from listings")
50
- defaultTLSMode := strings.TrimSpace(os.Getenv("TLS_MODE"))
51
+ defaultTLSMode := os.Getenv("TLS_MODE")
52
if defaultTLSMode == "" {
52
- defaultTLSMode = string(sdk.TLSModeKeyless)
53
+ defaultTLSMode = string(sdk.TLSModeNoTLS)
54
}
55
flag.StringVar(&flagTLSMode, "tls-mode", defaultTLSMode, "TLS mode: no-tls, self, or keyless [env: TLS_MODE]")
56
flag.StringVar(&flagTLSCert, "tls-cert-file", os.Getenv("TLS_CERT_FILE"), "certificate chain PEM file for --tls-mode self [env: TLS_CERT_FILE]")
@@ -65,17 +66,12 @@ func main() {
66
func runDemo() error {
67
// 1) Create SDK client and connect to relay(s)
68
opts := []sdk.ClientOption{sdk.WithBootstrapServers([]string{flagServerURL})}
68
- mode := sdk.TLSMode(strings.ToLower(strings.TrimSpace(flagTLSMode)))
69
+ mode := sdk.TLSMode(flagTLSMode)
70
switch mode {
71
case sdk.TLSModeNoTLS:
72
case sdk.TLSModeSelf:
73
opts = append(opts, sdk.WithTLSSelfCertificateFiles(flagTLSCert, flagTLSKey))
74
case sdk.TLSModeKeyless:
74
- if certFile := strings.TrimSpace(flagTLSCert); certFile != "" {
75
- log.Warn().
76
- Str("cert_file", certFile).
77
- Msg("Ignoring --tls-cert-file in keyless mode (SDK auto configuration only)")
78
- }
75
opts = append(opts, sdk.WithTLSKeylessDefaults())
76
default:
77
return fmt.Errorf("unsupported tls mode: %s", flagTLSMode)
cmd/portal-tunnel/README.md
+3
@@ -53,6 +53,7 @@ Options:
53
--tls-mode TLS mode: no-tls, self, or keyless [default: no-tls] [env: TLS_MODE]
54
--tls-cert-file PEM certificate chain for --tls-mode self [env: TLS_CERT_FILE]
55
--tls-key-file PEM private key for --tls-mode self [env: TLS_KEY_FILE]
56
+ --tls-base-domain Base domain for keyless certificate validation (e.g., example.com) [env: TLS_BASE_DOMAIN]
57
--description Service description metadata [env: APP_DESCRIPTION]
58
--tags Service tags metadata (comma-separated) [env: APP_TAGS]
59
--thumbnail Service thumbnail URL metadata [env: APP_THUMBNAIL]
@@ -80,6 +81,7 @@ export RELAYS=https://portal.example.com
81
export APP_HOST=localhost:3000
82
export APP_NAME=myapp
83
export TLS_MODE=keyless
84
+export TLS_BASE_DOMAIN=example.com
85
86
./bin/portal-tunnel
87
```
@@ -104,6 +106,7 @@ export RELAYS=https://portal.example.com
106
export APP_HOST=localhost:3000
107
export APP_NAME=myapp
108
export TLS_MODE=keyless
109
+export TLS_BASE_DOMAIN=example.com
110
111
./bin/portal-tunnel
112
```
cmd/portal-tunnel/main.go
+13
-29
@@ -45,7 +45,7 @@ func main() {
45
flag.StringVar(&flagHost, "host", os.Getenv("APP_HOST"), "Target host to proxy to (host:port or URL) [env: APP_HOST]")
46
flag.StringVar(&flagName, "name", os.Getenv("APP_NAME"), "Service name [env: APP_NAME]")
47
48
- defaultTLSMode := strings.ToLower(strings.TrimSpace(os.Getenv("TLS_MODE")))
48
+ defaultTLSMode := os.Getenv("TLS_MODE")
49
if defaultTLSMode == "" {
50
defaultTLSMode = string(sdk.TLSModeNoTLS)
51
}
@@ -67,7 +67,6 @@ func main() {
67
flag.Usage()
68
os.Exit(1)
69
}
70
- flagTLSMode = strings.ToLower(strings.TrimSpace(flagTLSMode))
70
if flagTLSMode != string(sdk.TLSModeNoTLS) &&
71
flagTLSMode != string(sdk.TLSModeSelf) &&
72
flagTLSMode != string(sdk.TLSModeKeyless) {
@@ -111,36 +110,20 @@ func runServiceTunnel(ctx context.Context, relayURLs []string) error {
110
log.Info().Msg("Starting Portal Tunnel...")
111
log.Info().Msgf(" Local: %s", flagHost)
112
log.Info().Msgf(" Relays: %s", strings.Join(relayURLs, ", "))
114
- tlsEnabled := flagTLSMode != string(sdk.TLSModeNoTLS)
115
- log.Info().Msgf(" TLS: %v", tlsEnabled)
116
- if tlsEnabled {
117
- log.Info().Msgf(" TLS Mode: %s", flagTLSMode)
118
- }
113
+ log.Info().Msgf(" TLS Mode: %s", flagTLSMode)
114
115
var clientOpts []sdk.ClientOption
116
clientOpts = append(clientOpts, sdk.WithBootstrapServers(relayURLs))
117
123
- if tlsEnabled {
124
- if flagTLSMode == string(sdk.TLSModeSelf) {
125
- certFile := strings.TrimSpace(flagTLSCertFile)
126
- keyFile := strings.TrimSpace(flagTLSKeyFile)
127
- clientOpts = append(clientOpts, sdk.WithTLSSelfCertificateFiles(certFile, keyFile))
128
- log.Info().
129
- Str("cert_file", certFile).
130
- Str("key_file", keyFile).
131
- Msg("TLS: Using self-managed local certificate")
132
- } else if flagTLSMode == string(sdk.TLSModeKeyless) {
133
- certFile := strings.TrimSpace(flagTLSCertFile)
134
- if certFile != "" {
135
- log.Warn().
136
- Str("cert_file", certFile).
137
- Msg("Ignoring --tls-cert-file in keyless mode (SDK auto configuration only)")
138
- }
139
- clientOpts = append(clientOpts, sdk.WithTLSKeylessDefaults())
140
- log.Info().Msg("TLS: Using keyless remote signer (SDK auto configuration)")
141
- } else {
142
- return fmt.Errorf("unsupported TLS mode: %s", flagTLSMode)
143
- }
118
+ if flagTLSMode == string(sdk.TLSModeSelf) {
119
+ clientOpts = append(clientOpts, sdk.WithTLSSelfCertificateFiles(flagTLSCertFile, flagTLSKeyFile))
120
+ log.Info().
121
+ Str("cert_file", flagTLSCertFile).
122
+ Str("key_file", flagTLSKeyFile).
123
+ Msg("TLS: Using self-managed local certificate")
124
+ } else if flagTLSMode == string(sdk.TLSModeKeyless) {
125
+ clientOpts = append(clientOpts, sdk.WithTLSKeylessDefaults())
126
+ log.Info().Msg("TLS: Using keyless remote signer (SDK auto configuration)")
127
}
128
129
client, err := sdk.NewClient(clientOpts...)
@@ -174,7 +157,7 @@ func runServiceTunnel(ctx context.Context, relayURLs []string) error {
157
if leaseAware, ok := listener.(interface{ LeaseID() string }); ok {
158
log.Info().Msgf("- Lease ID: %s", leaseAware.LeaseID())
159
}
177
- if tlsEnabled {
160
+ if flagTLSMode != string(sdk.TLSModeNoTLS) {
161
log.Info().Msg("- TLS: Enabled")
162
}
163
@@ -207,6 +190,7 @@ func runServiceTunnel(ctx context.Context, relayURLs []string) error {
190
connWG.Add(1)
191
go func(relayConn net.Conn) {
192
defer connWG.Done()
193
+ tlsEnabled := flagTLSMode != string(sdk.TLSModeNoTLS)
194
proxyType := "TCP"
195
if tlsEnabled {
196
proxyType = "TLS→TCP"
cmd/relay-server/main.go
+2
-1
@@ -17,6 +17,7 @@ import (
17
"gosuda.org/portal/cmd/relay-server/manager"
18
"gosuda.org/portal/portal"
19
"gosuda.org/portal/portal/sni"
20
+ "gosuda.org/portal/sdk"
21
)
22
23
const (
@@ -89,7 +90,7 @@ func runServer(cfg relayServerConfig) error {
90
Strs("bootstrap_uris", cfg.Bootstraps).
91
Msg("[server] frontend configuration")
92
92
- baseHost := extractBaseDomain(cfg.PortalURL)
93
+ baseHost := sdk.ExtractBaseDomain(cfg.PortalURL)
94
rootSNI := portalRootHost(cfg.PortalURL)
95
apiUpstreamAddr := loopbackForwardAddr(fmt.Sprintf(":%d", cfg.AdminPort))
96
serv, err := portal.NewRelayServer(ctx, cfg.Bootstraps, sniListenAddr, baseHost, cfg.KeylessDir, cfg.CloudflareToken)
cmd/relay-server/utils.go
-22
@@ -234,28 +234,6 @@ func servicePublicURL(portalURL, serviceName string) string {
234
return fmt.Sprintf("%s://%s.%s", scheme, serviceName, host)
235
}
236
237
-func extractBaseDomain(rawURL string) string {
238
- trimmed := strings.TrimSpace(rawURL)
239
- if trimmed == "" {
240
- return ""
241
- }
242
- if !strings.Contains(trimmed, "://") {
243
- trimmed = "https://" + trimmed
244
- }
245
-
246
- u, err := url.Parse(trimmed)
247
- if err != nil || u.Hostname() == "" {
248
- return ""
249
- }
250
-
251
- host := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(u.Hostname())), "*.")
252
- parts := strings.Split(host, ".")
253
- if len(parts) < 2 {
254
- return ""
255
- }
256
- return parts[len(parts)-2] + "." + parts[len(parts)-1]
257
-}
258
-
237
func portalRootHost(portalURL string) string {
238
raw := strings.TrimSpace(portalURL)
239
if raw == "" {
portal/keyless/bootstrap.go
new
+156
@@ -0,0 +1,156 @@
1
+package keyless
2
+
3
+import (
4
+ "context"
5
+ "crypto/tls"
6
+ "crypto/x509"
7
+ "encoding/pem"
8
+ "fmt"
9
+ "net"
10
+ "net/url"
11
+ "strings"
12
+ "time"
13
+
14
+ "github.com/rs/zerolog/log"
15
+)
16
+
17
+// ResolveMaterials prepares certificate chain and root CAs for keyless TLS mode.
18
+func ResolveMaterials(
19
+ ctx context.Context,
20
+ keylessEndpoint string,
21
+ keylessServerName string,
22
+ inlineCertPEM []byte,
23
+ inlineRootCAPEM []byte,
24
+) ([]byte, []byte, error) {
25
+ certPEM := append([]byte(nil), inlineCertPEM...)
26
+ rootCAPEM := append([]byte(nil), inlineRootCAPEM...)
27
+
28
+ // If both are explicitly provided, no need for endpoint fetch.
29
+ if len(certPEM) > 0 && len(rootCAPEM) > 0 {
30
+ return certPEM, rootCAPEM, nil
31
+ }
32
+
33
+ chainFromEndpoint, err := FetchEndpointCertificateChain(ctx, keylessEndpoint, keylessServerName)
34
+ if err != nil && len(certPEM) == 0 {
35
+ return nil, nil, fmt.Errorf("auto-discover certificate chain from signer endpoint: %w", err)
36
+ }
37
+ if err != nil {
38
+ log.Debug().Err(err).Msg("[SDK] Failed to fetch cert from endpoint, using inline materials")
39
+ }
40
+
41
+ if len(certPEM) == 0 {
42
+ certPEM = chainFromEndpoint
43
+ }
44
+ if len(certPEM) == 0 {
45
+ return nil, nil, fmt.Errorf("keyless certificate chain is required")
46
+ }
47
+
48
+ if len(rootCAPEM) == 0 && len(chainFromEndpoint) > 0 {
49
+ rootCAPEM = append([]byte(nil), chainFromEndpoint...)
50
+ }
51
+ if len(rootCAPEM) == 0 {
52
+ rootCAPEM = append([]byte(nil), certPEM...)
53
+ }
54
+
55
+ return certPEM, rootCAPEM, nil
56
+}
57
+
58
+// VerifyCertificateHostname checks whether the leaf cert covers hostname.
59
+func VerifyCertificateHostname(certPEM []byte, hostname string) error {
60
+ _, leaf, err := ParseCertificateChainPEM(certPEM)
61
+ if err != nil {
62
+ return err
63
+ }
64
+ return leaf.VerifyHostname(hostname)
65
+}
66
+
67
+// ParseCertificateChainPEM parses PEM cert chain and returns DER chain + leaf.
68
+func ParseCertificateChainPEM(certPEM []byte) ([][]byte, *x509.Certificate, error) {
69
+ if len(certPEM) == 0 {
70
+ return nil, nil, fmt.Errorf("certificate PEM is empty")
71
+ }
72
+
73
+ var chain [][]byte
74
+ rest := certPEM
75
+ for {
76
+ block, next := pem.Decode(rest)
77
+ if block == nil {
78
+ break
79
+ }
80
+ if block.Type == "CERTIFICATE" {
81
+ chain = append(chain, block.Bytes)
82
+ }
83
+ rest = next
84
+ }
85
+ if len(chain) == 0 {
86
+ return nil, nil, fmt.Errorf("no certificate blocks found")
87
+ }
88
+
89
+ leaf, err := x509.ParseCertificate(chain[0])
90
+ if err != nil {
91
+ return nil, nil, fmt.Errorf("parse leaf certificate: %w", err)
92
+ }
93
+
94
+ return chain, leaf, nil
95
+}
96
+
97
+// FetchEndpointCertificateChain fetches peer cert chain from signer endpoint.
98
+func FetchEndpointCertificateChain(ctx context.Context, endpoint string, serverName string) ([]byte, error) {
99
+ raw := endpoint
100
+ if raw == "" {
101
+ return nil, fmt.Errorf("endpoint is required")
102
+ }
103
+ if !strings.Contains(raw, "://") {
104
+ raw = "https://" + raw
105
+ }
106
+
107
+ u, err := url.Parse(raw)
108
+ if err != nil {
109
+ return nil, fmt.Errorf("parse endpoint URL: %w", err)
110
+ }
111
+ if u.Scheme == "http" {
112
+ return nil, fmt.Errorf("http signer endpoint does not expose TLS certificate chain (use https endpoint)")
113
+ }
114
+
115
+ host := u.Hostname()
116
+ if host == "" {
117
+ return nil, fmt.Errorf("endpoint hostname is empty")
118
+ }
119
+ port := u.Port()
120
+ if port == "" {
121
+ port = "443"
122
+ }
123
+ if serverName == "" {
124
+ serverName = host
125
+ }
126
+
127
+ dialer := &net.Dialer{Timeout: 5 * time.Second}
128
+ rawConn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
129
+ if err != nil {
130
+ return nil, fmt.Errorf("dial signer endpoint: %w", err)
131
+ }
132
+
133
+ tlsConn := tls.Client(rawConn, &tls.Config{
134
+ MinVersion: tls.VersionTLS12,
135
+ ServerName: serverName,
136
+ InsecureSkipVerify: true,
137
+ })
138
+ defer tlsConn.Close()
139
+ if err := tlsConn.HandshakeContext(ctx); err != nil {
140
+ return nil, fmt.Errorf("TLS handshake with signer endpoint: %w", err)
141
+ }
142
+
143
+ peerCerts := tlsConn.ConnectionState().PeerCertificates
144
+ if len(peerCerts) == 0 {
145
+ return nil, fmt.Errorf("no peer certificates from signer endpoint")
146
+ }
147
+
148
+ var chainPEM []byte
149
+ for _, cert := range peerCerts {
150
+ chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{
151
+ Type: "CERTIFICATE",
152
+ Bytes: cert.Raw,
153
+ })...)
154
+ }
155
+ return chainPEM, nil
156
+}
sdk/client.go
+215
-323
@@ -5,46 +5,26 @@ import (
5
"context"
6
"crypto/rand"
7
"crypto/tls"
8
- "crypto/x509"
8
"encoding/hex"
10
- "encoding/json"
11
- "encoding/pem"
9
"fmt"
13
- "io"
10
"net"
15
- "net/http"
11
"net/url"
12
"regexp"
13
"strings"
14
"sync"
15
"time"
16
22
- "github.com/gosuda/keyless_tls/keyless"
23
-
17
+ keylesstls "github.com/gosuda/keyless_tls/keyless"
18
"github.com/rs/zerolog/log"
19
+
20
"gosuda.org/portal/portal"
21
+ "gosuda.org/portal/portal/keyless"
22
)
23
28
-var urlSafeNameRegex = regexp.MustCompile(`^[\p{L}\p{N}_-]+$`)
29
-
30
-// isURLSafeName checks if a name contains only URL-safe characters.
31
-func isURLSafeName(name string) bool {
32
- if name == "" {
33
- return true
34
- }
35
- return urlSafeNameRegex.MatchString(name)
36
-}
37
-
24
// Client is a minimal client for lease registration with the relay.
25
type Client struct {
26
mu sync.Mutex
27
config *ClientConfig
42
-
43
- leases map[string]*portal.Lease
44
-
45
- stopch chan struct{}
46
- stopOnce sync.Once
47
- waitGroup sync.WaitGroup
28
}
29
30
// NewClient creates a new SDK client.
@@ -60,11 +40,17 @@ func NewClient(opt ...ClientOption) (*Client, error) {
40
o(config)
41
}
42
63
- return &Client{
64
- config: config,
65
- leases: make(map[string]*portal.Lease),
66
- stopch: make(chan struct{}),
67
- }, nil
43
+ return &Client{config: config}, nil
44
+}
45
+
46
+var urlSafeNameRegex = regexp.MustCompile(`^[\p{L}\p{N}_-]+$`)
47
+
48
+// isURLSafeName checks if a name contains only URL-safe characters.
49
+func isURLSafeName(name string) bool {
50
+ if name == "" {
51
+ return true
52
+ }
53
+ return urlSafeNameRegex.MatchString(name)
54
}
55
56
// Listen creates a listener and registers it with the relay.
@@ -74,7 +60,6 @@ func (c *Client) Listen(name string, options ...MetadataOption) (net.Listener, e
60
c.mu.Lock()
61
defer c.mu.Unlock()
62
77
- // Validate name
63
if name == "" {
64
return nil, fmt.Errorf("name is required")
65
}
@@ -82,153 +67,64 @@ func (c *Client) Listen(name string, options ...MetadataOption) (net.Listener, e
67
return nil, ErrInvalidName
68
}
69
85
- var metadata portal.Metadata
86
- for _, option := range options {
87
- option(&metadata)
88
- }
89
-
90
- relayAddr, err := firstRelayAPIURL(c.config.BootstrapServers)
70
+ relayAddrs, err := normalizeRelayAPIURLs(c.config.BootstrapServers)
71
if err != nil {
72
return nil, err
73
}
74
95
- // Create lease
96
- reverseToken, err := generateToken(16)
75
+ lease, err := c.newLease(name, options...)
76
if err != nil {
98
- return nil, fmt.Errorf("generate reverse token: %w", err)
99
- }
100
-
101
- lease := &portal.Lease{
102
- ID: generateID(),
103
- Name: name,
104
- TLSMode: string(c.config.TLSMode),
105
- ReverseToken: reverseToken,
106
- Metadata: portal.Metadata{
107
- Description: metadata.Description,
108
- Tags: metadata.Tags,
109
- Thumbnail: metadata.Thumbnail,
110
- Owner: metadata.Owner,
111
- Hide: metadata.Hide,
112
- },
113
- Expires: time.Now().Add(30 * time.Second),
77
+ return nil, err
78
}
79
116
- // Build TLS config if enabled
117
- var tlsConfig *tls.Config
118
- var listenerCloseFns []func()
119
- tlsMode := c.config.TLSMode
120
- tlsEnabled := tlsMode != TLSModeNoTLS
121
- if tlsEnabled {
122
- switch tlsMode {
123
- case TLSModeSelf:
124
- var cert tls.Certificate
125
- if c.config.TLSCertificate != nil {
126
- cert = *c.config.TLSCertificate
127
- } else {
128
- certFile := strings.TrimSpace(c.config.TLSSelfCertFile)
129
- keyFile := strings.TrimSpace(c.config.TLSSelfKeyFile)
130
- if certFile == "" || keyFile == "" {
131
- return nil, fmt.Errorf("self TLS mode requires certificate/key (WithTLSSelfCertificate or WithTLSSelfCertificateFiles)")
132
- }
133
- certPair, err := tls.LoadX509KeyPair(certFile, keyFile)
134
- if err != nil {
135
- return nil, fmt.Errorf("load self TLS certificate files: %w", err)
136
- }
137
- cert = certPair
138
- }
139
- tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12}
140
- tlsConfig.NextProtos = []string{"http/1.1"}
141
- tlsConfig.GetCertificate = func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
142
- return &cert, nil
143
- }
144
- case TLSModeKeyless:
145
- keylessEndpoint := strings.TrimSpace(c.config.TLSKeyless.Endpoint)
146
- if keylessEndpoint == "" {
147
- keylessEndpoint = strings.TrimSpace(relayAddr)
148
- }
149
- keylessKeyID := strings.TrimSpace(c.config.TLSKeyless.KeyID)
150
- if keylessKeyID == "" {
151
- keylessKeyID = "relay-cert"
152
- }
153
- keylessServerName := strings.TrimSpace(c.config.TLSKeyless.ServerName)
154
- if keylessServerName == "" {
155
- if parsed, err := url.Parse(keylessEndpoint); err == nil {
156
- keylessServerName = parsed.Hostname()
157
- }
158
- }
159
-
160
- certPEM, rootCAPEM, err := resolveKeylessMaterials(
161
- context.Background(),
162
- keylessEndpoint,
163
- keylessServerName,
164
- c.config.TLSKeylessCertificatePEM,
165
- c.config.TLSKeyless.RootCAPEM,
166
- )
167
- if err != nil {
168
- return nil, fmt.Errorf("prepare keyless materials: %w", err)
80
+ listeners := make([]net.Listener, 0, len(relayAddrs))
81
+ for _, relayAddr := range relayAddrs {
82
+ tlsConfig, listenerCloseFns, tlsErr := c.buildTLSConfig(relayAddr, name)
83
+ if tlsErr != nil {
84
+ for _, l := range listeners {
85
+ _ = l.Close()
86
}
87
+ return nil, tlsErr
88
+ }
89
171
- baseDomain, err := fetchBaseDomain(context.Background(), relayAddr)
172
- if err != nil {
173
- return nil, fmt.Errorf("get base domain for keyless mode: %w", err)
174
- }
175
- domain := strings.ToLower(name + "." + baseDomain)
176
- _, leaf, err := parseCertificateChainPEM(certPEM)
177
- if err != nil {
178
- return nil, fmt.Errorf("parse keyless certificate chain: %w", err)
90
+ leaseCopy := *lease
91
+ listener, listenerErr := NewListener(relayAddr, &leaseCopy, tlsConfig, c.config.ReverseWorkers, c.config.ReverseDialTimeout, listenerCloseFns...)
92
+ if listenerErr != nil {
93
+ for _, closeFn := range listenerCloseFns {
94
+ if closeFn != nil {
95
+ closeFn()
96
+ }
97
}
180
- if err := leaf.VerifyHostname(domain); err != nil {
181
- return nil, fmt.Errorf("keyless certificate does not cover %s: %w", domain, err)
98
+ for _, l := range listeners {
99
+ _ = l.Close()
100
}
101
+ return nil, fmt.Errorf("create relay listener: %w", listenerErr)
102
+ }
103
184
- remoteSigner, err := keyless.NewRemoteSigner(keyless.RemoteSignerConfig{
185
- Endpoint: keylessEndpoint,
186
- ServerName: keylessServerName,
187
- KeyID: keylessKeyID,
188
- EnableMTLS: c.config.TLSKeyless.EnableMTLS,
189
- ClientCertPEM: c.config.TLSKeyless.ClientCertPEM,
190
- ClientKeyPEM: c.config.TLSKeyless.ClientKeyPEM,
191
- RootCAPEM: rootCAPEM,
192
- }, certPEM)
193
- if err != nil {
194
- return nil, fmt.Errorf("create keyless remote signer: %w", err)
195
- }
196
- listenerCloseFns = append(listenerCloseFns, func() {
197
- _ = remoteSigner.Close()
198
- })
199
-
200
- tlsConfig, err = keyless.NewServerTLSConfig(keyless.ServerTLSConfig{
201
- CertPEM: certPEM,
202
- Signer: remoteSigner,
203
- })
204
- if err != nil {
205
- _ = remoteSigner.Close()
206
- return nil, fmt.Errorf("create keyless TLS config: %w", err)
104
+ if startErr := listener.Start(); startErr != nil {
105
+ _ = listener.Close()
106
+ for _, l := range listeners {
107
+ _ = l.Close()
108
}
208
- tlsConfig.NextProtos = []string{"http/1.1"}
209
- default:
210
- return nil, fmt.Errorf("unsupported TLS mode: %s", tlsMode)
109
+ return nil, fmt.Errorf("start relay listener: %w", startErr)
110
}
212
- }
111
214
- listener, err := NewListener(relayAddr, lease, tlsConfig, c.config.ReverseWorkers, c.config.ReverseDialTimeout, listenerCloseFns...)
215
- if err != nil {
216
- return nil, fmt.Errorf("create relay listener: %w", err)
112
+ listeners = append(listeners, listener)
113
}
114
219
- // Register lease with relay BEFORE requesting certificate
220
- if err := listener.Start(); err != nil {
221
- return nil, fmt.Errorf("start relay listener: %w", err)
115
+ var listener net.Listener
116
+ if len(listeners) == 1 {
117
+ listener = listeners[0]
118
+ } else {
119
+ listener = newMultiRelayListener(lease.ID, listeners)
120
}
121
224
- c.leases[lease.ID] = lease
225
-
226
- if tlsEnabled {
122
+ if c.config.TLSMode != TLSModeNoTLS {
123
log.Info().
124
Str("lease_id", lease.ID).
125
Str("name", name).
126
Bool("tls", true).
231
- Str("tls_mode", string(tlsMode)).
127
+ Str("tls_mode", string(c.config.TLSMode)).
128
Msg("[SDK] Lease registered with TLS")
129
} else {
130
log.Info().
@@ -241,208 +137,204 @@ func (c *Client) Listen(name string, options ...MetadataOption) (net.Listener, e
137
return listener, nil
138
}
139
244
-// Close closes the client.
245
-func (c *Client) Close() error {
246
- c.stopOnce.Do(func() {
247
- close(c.stopch)
248
- })
249
- c.waitGroup.Wait()
250
- return nil
251
-}
252
-
253
-func firstRelayAPIURL(bootstrapServers []string) (string, error) {
254
- if len(bootstrapServers) == 0 {
255
- return "", ErrNoAvailableRelay
140
+func (c *Client) newLease(name string, options ...MetadataOption) (*portal.Lease, error) {
141
+ var metadata portal.Metadata
142
+ for _, option := range options {
143
+ option(&metadata)
144
}
145
258
- for _, relay := range bootstrapServers {
259
- normalized, err := normalizeRelayAPIURL(relay)
260
- if err == nil {
261
- return normalized, nil
262
- }
146
+ reverseToken, err := generateToken(16)
147
+ if err != nil {
148
+ return nil, fmt.Errorf("generate reverse token: %w", err)
149
}
150
265
- return "", ErrNoAvailableRelay
266
-}
267
-
268
-// generateID generates a unique ID for the lease.
269
-func generateID() string {
270
- b := make([]byte, 16)
271
- rand.Read(b)
272
- return hex.EncodeToString(b)
273
-}
274
-
275
-func generateToken(size int) (string, error) {
276
- if size <= 0 {
277
- size = 16
278
- }
279
- b := make([]byte, size)
280
- if _, err := rand.Read(b); err != nil {
281
- return "", err
151
+ lease := &portal.Lease{
152
+ ID: generateID(),
153
+ Name: name,
154
+ TLSMode: string(c.config.TLSMode),
155
+ ReverseToken: reverseToken,
156
+ Metadata: portal.Metadata{
157
+ Description: metadata.Description,
158
+ Tags: metadata.Tags,
159
+ Thumbnail: metadata.Thumbnail,
160
+ Owner: metadata.Owner,
161
+ Hide: metadata.Hide,
162
+ },
163
+ Expires: time.Now().Add(30 * time.Second),
164
}
283
- return hex.EncodeToString(b), nil
165
+ return lease, nil
166
}
167
286
-func parseCertificateChainPEM(certPEM []byte) ([][]byte, *x509.Certificate, error) {
287
- if len(certPEM) == 0 {
288
- return nil, nil, fmt.Errorf("certificate PEM is empty")
168
+func ExtractBaseDomain(rawURL string) string {
169
+ trimmed := strings.TrimSpace(rawURL)
170
+ if trimmed == "" {
171
+ return ""
172
}
290
- var chain [][]byte
291
- rest := certPEM
292
- for {
293
- block, next := pem.Decode(rest)
294
- if block == nil {
295
- break
296
- }
297
- if block.Type == "CERTIFICATE" {
298
- chain = append(chain, block.Bytes)
299
- }
300
- rest = next
173
+ if !strings.Contains(trimmed, "://") {
174
+ trimmed = "https://" + trimmed
175
}
302
- if len(chain) == 0 {
303
- return nil, nil, fmt.Errorf("no certificate blocks found")
176
+
177
+ u, err := url.Parse(trimmed)
178
+ if err != nil || u.Hostname() == "" {
179
+ return ""
180
}
305
- leaf, err := x509.ParseCertificate(chain[0])
306
- if err != nil {
307
- return nil, nil, fmt.Errorf("parse leaf certificate: %w", err)
181
+
182
+ host := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(u.Hostname())), "*.")
183
+ parts := strings.Split(host, ".")
184
+ if len(parts) < 2 {
185
+ return ""
186
}
309
- return chain, leaf, nil
187
+ return parts[len(parts)-2] + "." + parts[len(parts)-1]
188
}
189
312
-func fetchBaseDomain(ctx context.Context, relayAPIURL string) (string, error) {
313
- endpoint := strings.TrimSuffix(strings.TrimSpace(relayAPIURL), "/") + "/sdk/domain"
314
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
315
- if err != nil {
316
- return "", fmt.Errorf("create request: %w", err)
190
+func normalizeRelayAPIURLs(bootstrapServers []string) ([]string, error) {
191
+ if len(bootstrapServers) == 0 {
192
+ return nil, ErrNoAvailableRelay
193
}
194
319
- client := &http.Client{Timeout: 60 * time.Second}
320
- resp, err := client.Do(req)
321
- if err != nil {
322
- return "", fmt.Errorf("send request: %w", err)
195
+ seen := make(map[string]struct{}, len(bootstrapServers))
196
+ out := make([]string, 0, len(bootstrapServers))
197
+ for _, relay := range bootstrapServers {
198
+ normalized, err := normalizeRelayAPIURL(relay)
199
+ if err != nil {
200
+ continue
201
+ }
202
+ if _, exists := seen[normalized]; exists {
203
+ continue
204
+ }
205
+ seen[normalized] = struct{}{}
206
+ out = append(out, normalized)
207
}
324
- defer resp.Body.Close()
208
326
- respBody, err := io.ReadAll(resp.Body)
327
- if err != nil {
328
- return "", fmt.Errorf("read response: %w", err)
209
+ if len(out) == 0 {
210
+ return nil, ErrNoAvailableRelay
211
}
212
+ return out, nil
213
+}
214
331
- var domainResp struct {
332
- Success bool `json:"success"`
333
- BaseDomain string `json:"base_domain"`
334
- Message string `json:"message"`
335
- }
336
- if err := json.Unmarshal(respBody, &domainResp); err != nil {
337
- return "", fmt.Errorf("parse response: %w", err)
338
- }
339
- if !domainResp.Success {
340
- msg := strings.TrimSpace(domainResp.Message)
341
- if msg == "" {
342
- msg = "base domain not configured"
215
+func (c *Client) buildTLSConfig(relayAddr, leaseName string) (*tls.Config, []func(), error) {
216
+ tlsMode := c.config.TLSMode
217
+ if tlsMode == TLSModeNoTLS {
218
+ return nil, nil, nil
219
+ }
220
+
221
+ switch tlsMode {
222
+ case TLSModeSelf:
223
+ var cert tls.Certificate
224
+ var err error
225
+ if c.config.TLSCertificate != nil {
226
+ cert = *c.config.TLSCertificate
227
+ } else {
228
+ if c.config.TLSSelfCertFile == "" || c.config.TLSSelfKeyFile == "" {
229
+ return nil, nil, fmt.Errorf("self TLS mode requires certificate/key (WithTLSSelfCertificate or WithTLSSelfCertificateFiles)")
230
+ }
231
+ cert, err = tls.LoadX509KeyPair(c.config.TLSSelfCertFile, c.config.TLSSelfKeyFile)
232
+ if err != nil {
233
+ return nil, nil, fmt.Errorf("load self TLS certificate files: %w", err)
234
+ }
235
}
344
- return "", fmt.Errorf("get base domain: %s", msg)
345
- }
346
- return domainResp.BaseDomain, nil
347
-}
236
349
-func resolveKeylessMaterials(
350
- ctx context.Context,
351
- keylessEndpoint string,
352
- keylessServerName string,
353
- inlineCertPEM []byte,
354
- inlineRootCAPEM []byte,
355
-) ([]byte, []byte, error) {
356
- certPEM := append([]byte(nil), inlineCertPEM...)
357
- rootCAPEM := append([]byte(nil), inlineRootCAPEM...)
358
-
359
- // If both are explicitly provided, no need for cache or fetch
360
- if len(certPEM) > 0 && len(rootCAPEM) > 0 {
361
- return certPEM, rootCAPEM, nil
362
- }
237
+ tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
238
+ tlsConfig.NextProtos = []string{"http/1.1"}
239
+ tlsConfig.GetCertificate = func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
240
+ return &cert, nil
241
+ }
242
+ return tlsConfig, nil, nil
243
364
- // Fetch from endpoint
365
- chainFromEndpoint, err := fetchEndpointCertificateChain(ctx, keylessEndpoint, keylessServerName)
366
- if err != nil && len(certPEM) == 0 {
367
- return nil, nil, fmt.Errorf("auto-discover certificate chain from signer endpoint: %w", err)
368
- }
369
- if err != nil {
370
- log.Debug().Err(err).Msg("[SDK] Failed to fetch cert from endpoint, using inline materials")
371
- }
244
+ case TLSModeKeyless:
245
+ keylessEndpoint := c.config.TLSKeyless.Endpoint
246
+ if keylessEndpoint == "" {
247
+ keylessEndpoint = relayAddr
248
+ }
249
373
- if len(certPEM) == 0 {
374
- certPEM = chainFromEndpoint
375
- }
376
- if len(certPEM) == 0 {
377
- return nil, nil, fmt.Errorf("keyless certificate chain is required")
378
- }
250
+ keylessKeyID := c.config.TLSKeyless.KeyID
251
+ if keylessKeyID == "" {
252
+ keylessKeyID = "relay-cert"
253
+ }
254
380
- if len(rootCAPEM) == 0 && len(chainFromEndpoint) > 0 {
381
- rootCAPEM = append([]byte(nil), chainFromEndpoint...)
382
- }
383
- if len(rootCAPEM) == 0 {
384
- rootCAPEM = append([]byte(nil), certPEM...)
385
- }
255
+ keylessServerName := c.config.TLSKeyless.ServerName
256
+ if keylessServerName == "" {
257
+ if parsed, err := url.Parse(keylessEndpoint); err == nil {
258
+ keylessServerName = parsed.Hostname()
259
+ }
260
+ }
261
387
- return certPEM, rootCAPEM, nil
388
-}
262
+ certPEM, rootCAPEM, err := keyless.ResolveMaterials(
263
+ context.Background(),
264
+ keylessEndpoint,
265
+ keylessServerName,
266
+ c.config.TLSKeylessCertificatePEM,
267
+ c.config.TLSKeyless.RootCAPEM,
268
+ )
269
+ if err != nil {
270
+ return nil, nil, fmt.Errorf("prepare keyless materials: %w", err)
271
+ }
272
390
-func fetchEndpointCertificateChain(ctx context.Context, endpoint string, serverName string) ([]byte, error) {
391
- raw := strings.TrimSpace(endpoint)
392
- if raw == "" {
393
- return nil, fmt.Errorf("endpoint is required")
394
- }
395
- if !strings.Contains(raw, "://") {
396
- raw = "https://" + raw
397
- }
273
+ baseDomain := c.config.TLSKeyless.BaseDomain
274
+ if baseDomain == "" {
275
+ baseDomain = ExtractBaseDomain(relayAddr)
276
+ }
277
+ if baseDomain == "" {
278
+ baseDomain = ExtractBaseDomain(keylessEndpoint)
279
+ }
280
+ if baseDomain == "" {
281
+ return nil, nil, fmt.Errorf("keyless base domain is required for relay %s", relayAddr)
282
+ }
283
+ domain := leaseName + "." + baseDomain
284
+ if err := keyless.VerifyCertificateHostname(certPEM, domain); err != nil {
285
+ return nil, nil, fmt.Errorf("keyless certificate does not cover %s: %w", domain, err)
286
+ }
287
399
- u, err := url.Parse(raw)
400
- if err != nil {
401
- return nil, fmt.Errorf("parse endpoint URL: %w", err)
402
- }
403
- if strings.EqualFold(u.Scheme, "http") {
404
- return nil, fmt.Errorf("http signer endpoint does not expose TLS certificate chain (use https endpoint)")
405
- }
288
+ remoteSigner, err := keylesstls.NewRemoteSigner(keylesstls.RemoteSignerConfig{
289
+ Endpoint: keylessEndpoint,
290
+ ServerName: keylessServerName,
291
+ KeyID: keylessKeyID,
292
+ EnableMTLS: c.config.TLSKeyless.EnableMTLS,
293
+ ClientCertPEM: c.config.TLSKeyless.ClientCertPEM,
294
+ ClientKeyPEM: c.config.TLSKeyless.ClientKeyPEM,
295
+ RootCAPEM: rootCAPEM,
296
+ }, certPEM)
297
+ if err != nil {
298
+ return nil, nil, fmt.Errorf("create keyless remote signer: %w", err)
299
+ }
300
407
- host := u.Hostname()
408
- if host == "" {
409
- return nil, fmt.Errorf("endpoint hostname is empty")
410
- }
411
- port := u.Port()
412
- if port == "" {
413
- port = "443"
414
- }
415
- if strings.TrimSpace(serverName) == "" {
416
- serverName = host
417
- }
301
+ tlsConfig, err := keylesstls.NewServerTLSConfig(keylesstls.ServerTLSConfig{
302
+ CertPEM: certPEM,
303
+ Signer: remoteSigner,
304
+ })
305
+ if err != nil {
306
+ _ = remoteSigner.Close()
307
+ return nil, nil, fmt.Errorf("create keyless TLS config: %w", err)
308
+ }
309
+ tlsConfig.NextProtos = []string{"http/1.1"}
310
419
- dialer := &net.Dialer{Timeout: 5 * time.Second}
420
- rawConn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(host, port))
421
- if err != nil {
422
- return nil, fmt.Errorf("dial signer endpoint: %w", err)
311
+ return tlsConfig, []func(){
312
+ func() { _ = remoteSigner.Close() },
313
+ }, nil
314
+ default:
315
+ return nil, nil, fmt.Errorf("unsupported TLS mode: %s", tlsMode)
316
}
317
+}
318
425
- tlsConn := tls.Client(rawConn, &tls.Config{
426
- MinVersion: tls.VersionTLS12,
427
- ServerName: serverName,
428
- InsecureSkipVerify: true,
429
- })
430
- defer tlsConn.Close()
431
- if err := tlsConn.HandshakeContext(ctx); err != nil {
432
- return nil, fmt.Errorf("TLS handshake with signer endpoint: %w", err)
433
- }
319
+// Close closes the client.
320
+func (c *Client) Close() error {
321
+ return nil
322
+}
323
435
- peerCerts := tlsConn.ConnectionState().PeerCertificates
436
- if len(peerCerts) == 0 {
437
- return nil, fmt.Errorf("no peer certificates from signer endpoint")
438
- }
324
+// generateID generates a unique ID for the lease.
325
+func generateID() string {
326
+ b := make([]byte, 16)
327
+ rand.Read(b)
328
+ return hex.EncodeToString(b)
329
+}
330
440
- var chainPEM []byte
441
- for _, cert := range peerCerts {
442
- chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{
443
- Type: "CERTIFICATE",
444
- Bytes: cert.Raw,
445
- })...)
331
+func generateToken(size int) (string, error) {
332
+ if size <= 0 {
333
+ size = 16
334
}
447
- return chainPEM, nil
335
+ b := make([]byte, size)
336
+ if _, err := rand.Read(b); err != nil {
337
+ return "", err
338
+ }
339
+ return hex.EncodeToString(b), nil
340
}
sdk/listener.go
+96
@@ -488,3 +488,99 @@ func relayConnectURL(relayAddr, leaseID, token string) (string, error) {
488
u.Fragment = ""
489
return u.String(), nil
490
}
491
+
492
+type multiRelayListener struct {
493
+ leaseID string
494
+ listeners []net.Listener
495
+
496
+ acceptCh chan net.Conn
497
+ stopCh chan struct{}
498
+
499
+ closeOnce sync.Once
500
+ wg sync.WaitGroup
501
+}
502
+
503
+func newMultiRelayListener(leaseID string, listeners []net.Listener) *multiRelayListener {
504
+ m := &multiRelayListener{
505
+ leaseID: leaseID,
506
+ listeners: listeners,
507
+ acceptCh: make(chan net.Conn, 128),
508
+ stopCh: make(chan struct{}),
509
+ }
510
+
511
+ for i, listener := range listeners {
512
+ m.wg.Add(1)
513
+ go m.forwardAccept(i, listener)
514
+ }
515
+
516
+ return m
517
+}
518
+
519
+func (m *multiRelayListener) forwardAccept(index int, listener net.Listener) {
520
+ defer m.wg.Done()
521
+
522
+ for {
523
+ conn, err := listener.Accept()
524
+ if err != nil {
525
+ select {
526
+ case <-m.stopCh:
527
+ return
528
+ default:
529
+ }
530
+ if errors.Is(err, net.ErrClosed) {
531
+ return
532
+ }
533
+ log.Debug().
534
+ Err(err).
535
+ Int("relay_index", index).
536
+ Msg("[SDK] relay listener accept failed")
537
+ continue
538
+ }
539
+
540
+ select {
541
+ case <-m.stopCh:
542
+ _ = conn.Close()
543
+ return
544
+ case m.acceptCh <- conn:
545
+ }
546
+ }
547
+}
548
+
549
+func (m *multiRelayListener) Accept() (net.Conn, error) {
550
+ select {
551
+ case <-m.stopCh:
552
+ return nil, net.ErrClosed
553
+ case conn := <-m.acceptCh:
554
+ if conn == nil {
555
+ return nil, net.ErrClosed
556
+ }
557
+ return conn, nil
558
+ }
559
+}
560
+
561
+func (m *multiRelayListener) Close() error {
562
+ var retErr error
563
+ m.closeOnce.Do(func() {
564
+ close(m.stopCh)
565
+
566
+ for _, listener := range m.listeners {
567
+ if err := listener.Close(); err != nil && retErr == nil {
568
+ retErr = err
569
+ }
570
+ }
571
+
572
+ m.wg.Wait()
573
+ })
574
+ return retErr
575
+}
576
+
577
+func (m *multiRelayListener) Addr() net.Addr {
578
+ if len(m.listeners) > 0 {
579
+ return m.listeners[0].Addr()
580
+ }
581
+ return &net.TCPAddr{IP: net.IPv4(0, 0, 0, 0), Port: 0}
582
+}
583
+
584
+func (m *multiRelayListener) LeaseID() string {
585
+ return m.leaseID
586
+}
sdk/listener_test.go
-16
@@ -52,22 +52,6 @@ func TestNormalizeRelayAPIURL(t *testing.T) {
52
}
53
}
54
55
-func TestFirstRelayAPIURL(t *testing.T) {
56
- t.Parallel()
57
-
58
- got, err := firstRelayAPIURL([]string{"invalid://relay", "http://localhost:4017"})
59
- if err != nil {
60
- t.Fatalf("unexpected error: %v", err)
61
- }
62
- if got != "http://localhost:4017" {
63
- t.Fatalf("unexpected relay URL: got %q", got)
64
- }
65
-
66
- if _, err := firstRelayAPIURL(nil); err == nil {
67
- t.Fatal("expected error with no bootstrap servers")
68
- }
69
-}
70
-
55
func TestRelayConnectURL(t *testing.T) {
56
t.Parallel()
57
sdk/types.go
+11
@@ -32,6 +32,7 @@ const (
32
type TLSKeylessConfig struct {
33
Endpoint string
34
ServerName string
35
+ BaseDomain string
36
KeyID string
37
RootCAPEM []byte
38
EnableMTLS bool
@@ -131,6 +132,7 @@ func WithTLSKeyless(certPEM []byte, cfg TLSKeylessConfig) ClientOption {
132
c.TLSKeyless = TLSKeylessConfig{
133
Endpoint: cfg.Endpoint,
134
ServerName: cfg.ServerName,
135
+ BaseDomain: cfg.BaseDomain,
136
KeyID: cfg.KeyID,
137
RootCAPEM: append([]byte(nil), cfg.RootCAPEM...),
138
EnableMTLS: cfg.EnableMTLS,
@@ -140,6 +142,15 @@ func WithTLSKeyless(certPEM []byte, cfg TLSKeylessConfig) ClientOption {
142
}
143
}
144
145
+// WithTLSKeylessBaseDomain sets a global base domain override for keyless certificate hostname validation.
146
+// If unset, base domain is derived per relay URL.
147
+func WithTLSKeylessBaseDomain(baseDomain string) ClientOption {
148
+ return func(c *ClientConfig) {
149
+ c.TLSMode = TLSModeKeyless
150
+ c.TLSKeyless.BaseDomain = baseDomain
151
+ }
152
+}
153
+
154
// WithTLSKeylessDefaults enables keyless TLS mode with SDK-managed defaults.
155
// Certificate chain and signer trust are auto-discovered from signer endpoint when not explicitly provided.
156
func WithTLSKeylessDefaults() ClientOption {