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 {